acts_as_subscription 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ require 'acts_as_subscription/error'
2
+
3
+ module ActsAsSubscription
4
+ module Subscription
5
+
6
+ # Provides a consistent interface to the various recurring billing services.
7
+ #
8
+ # There are three ways to initialize the backend :
9
+ # 1. Pass the parameters to the _acts_as_subscription_ method inside the model :
10
+ #
11
+ # class Subscription < ActiveRecord::Base
12
+ # acts_as_subscription :backend => :cheddar_getter,
13
+ # :user => 'my_user',
14
+ # :password => 'my_pass',
15
+ # :product_code => 'my_product'
16
+ # end
17
+ #
18
+ # 2. Set the params in rails via <tt>config/initializers/acts_as_subscription.rb</tt> :
19
+ #
20
+ # ActsAsSubscription::Subscription::Backend.config = {
21
+ # :backend => :cheddar_getter,
22
+ # :user => 'my_user',
23
+ # :password => 'my_pass',
24
+ # :product_code => 'my_product'
25
+ # }
26
+ #
27
+ # 3. Add a <tt>config/subscription.yml</tt> file to your rails project :
28
+ #
29
+ # development:
30
+ # backend: 'cheddar_getter'
31
+ # user: 'my_user'
32
+ # password: 'my_pass'
33
+ # product_code: 'my_product'
34
+
35
+ module Backend
36
+
37
+ # Stores the backend configuration data.
38
+ mattr_accessor :config
39
+
40
+ # Stores an instance of the backend for a particular recurring billing service.
41
+ mattr_accessor :instance
42
+
43
+ # Sets the configuration hash for the backend.
44
+ #
45
+ # _args_ should be a hash containing the subscription backend account and product information :
46
+ # * <tt>backend</tt> : Name of the recurring billing service being used. Current choices are
47
+ # <tt>:chargify</tt>, <tt>:cheddar_getter</tt> and <tt>:recurly</tt>.
48
+ # * <tt>user</tt> : Username used to log into the billling service API.
49
+ # * <tt>password</tt> : Password used to log into the billling service API.
50
+ # * <tt>product_code</tt> : Code of your product on the backend service.
51
+ #
52
+ # If the backend configuration has already been set, _args_ will be merged with the current settings.
53
+ def self.config=(*args)
54
+ args = args.extract_options!
55
+
56
+ if @@config
57
+ @@config = self.config.merge(args)
58
+ else
59
+ @@config = args
60
+ end
61
+ end
62
+
63
+ # Validates the configuration options, and creates an instance of the requested <tt>:backend</tt>.
64
+ #
65
+ # _args_ should be a hash containing the subscription backend account and product information.
66
+ def self.initialize(*args)
67
+ # Don't do anything if the backend has already been loaded.
68
+ return if self.instance
69
+
70
+ self.read_yaml_options
71
+ self.config = *args
72
+ self.validate_options!
73
+
74
+ # Make sure the backend is a valid class.
75
+ begin
76
+ require "acts_as_subscription/backend/#{self.config[:backend]}_client"
77
+ class_name = "#{self.config[:backend].to_s}_client".camelize
78
+ klass = ActsAsSubscription::Subscription::Backend.const_get(class_name)
79
+ self.instance = klass.new(self.config[:user], self.config[:password], self.config[:product_code])
80
+ rescue LoadError
81
+ raise ActsAsSubscription::Subscription::Error::BackendError.new("Backend '#{self.config[:backend]}' could not be found")
82
+ rescue NameError
83
+ raise ActsAsSubscription::Subscription::Error::BackendError.new("Backend '#{self.config[:backend]}' does not appear to implement class '#{class_name}'")
84
+ end
85
+ end
86
+
87
+ # Creates a customer on the backend subscription service, using the settings from the given
88
+ # <tt>subscription</tt> instance.
89
+ #
90
+ # <tt>subscription</tt> should be a subclass of <tt>ActiveRecord::Base</tt> that implements
91
+ # <tt>acts_as_subscription</tt> :
92
+ #
93
+ # class Subscription < ActiveRecord::Base
94
+ # acts_as_subscription
95
+ # end
96
+ #
97
+ # Returns true if the operation was successful, otherwise an error message.
98
+ def self.create_subscription(subscription)
99
+ self.instance.create_subscription(subscription)
100
+ end
101
+
102
+ # Updates a customer on the backend subscription service, using the settings from the given
103
+ # <tt>subscription</tt> instance.
104
+ #
105
+ # <tt>subscription</tt> should be a subclass of <tt>ActiveRecord::Base</tt> that implements
106
+ # <tt>acts_as_subscription</tt> :
107
+ #
108
+ # class Subscription < ActiveRecord::Base
109
+ # acts_as_subscription
110
+ # end
111
+ #
112
+ # Returns true if the update was successful, otherwise an error message.
113
+ def self.update_subscription(subscription)
114
+ self.instance.update_subscription(subscription)
115
+ end
116
+
117
+ # Cancels the customer with the given <tt>customer_code</tt> on the backend subscription service.
118
+ #
119
+ # Returns true if the cancellation was successful, otherwise an error message.
120
+ def self.cancel_subscription!(customer_code)
121
+ self.instance.cancel_subscription!(customer_code)
122
+ end
123
+
124
+ # Returns a list of subscription plans registered with the backend subscription service.
125
+ def self.plans
126
+ self.instance.plans
127
+ end
128
+
129
+
130
+ protected
131
+
132
+ # Tries to read configuration options from the subscription.yml configuration file.
133
+ def self.read_yaml_options
134
+ # Try to get initial config from a YAML file if possible.
135
+ if Rails.root
136
+ config_path = File.join(Rails.root, 'config/subscription.yml')
137
+ if FileTest.exists?(config_path)
138
+ config = YAML::load(ERB.new(IO.read(config_path)).result)[Rails.env]
139
+ # Convert string keys to symbols.
140
+ config = config.inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo}
141
+ self.config = config
142
+ end
143
+ end
144
+ end
145
+
146
+ # Validates the configuration options, and makes sure all required parameters are present.
147
+ def self.validate_options!
148
+ unless self.config[:backend]
149
+ raise ActsAsSubscription::Subscription::Error::ArgumentError.new(':backend parameter must be provided')
150
+ end
151
+
152
+ unless self.config[:product_code]
153
+ raise ActsAsSubscription::Subscription::Error::ArgumentError.new(':product_code parameter must be provided')
154
+ end
155
+
156
+ unless self.config[:user]
157
+ raise ActsAsSubscription::Subscription::Error::ArgumentError.new(':user parameter must be provided')
158
+ end
159
+
160
+ unless self.config[:password]
161
+ raise ActsAsSubscription::Subscription::Error::ArgumentError.new(':password parameter must be provided')
162
+ end
163
+
164
+ end
165
+ end
166
+
167
+ end
168
+ end
@@ -0,0 +1,15 @@
1
+ module ActsAsSubscription
2
+ module Subscription
3
+ module Error # :nodoc:
4
+
5
+ # Raised when invalid arguments are passed.
6
+ class ArgumentError < Exception
7
+ end
8
+
9
+ # Raised if there is a problem with the backend configuration.
10
+ class BackendError < Exception
11
+ end
12
+
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ require 'rails'
2
+ require 'acts_as_subscription'
3
+
4
+ module ActsAsSubscription
5
+ class Railtie < Rails::Railtie # :nodoc:
6
+ initializer :'acts_as_subscription.hook' do
7
+ ActiveSupport.on_load(:active_record) do
8
+ ActiveRecord::Base.send(:include, ActsAsSubscription::Subscription)
9
+ ActiveRecord::Base.send(:include, ActsAsSubscription::SubscriptionPlan)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,258 @@
1
+ require 'acts_as_subscription/backend'
2
+
3
+ module ActsAsSubscription
4
+ module Subscription # :nodoc:
5
+ extend ActiveSupport::Concern
6
+
7
+ # Class methods for an ActsAsSubscription::Subscription instance.
8
+ #
9
+ # Handles validation of all subscription-related information, such as :
10
+ #
11
+ # * customer_code
12
+ # * email
13
+ # * first_name
14
+ # * last_name
15
+ # * zip_code
16
+ # * cc_expiration_month
17
+ # * cc_expiration_year
18
+ # * cc_verification_value
19
+ # * cc_number
20
+ #
21
+ # These class methods should be included in ActiveRecord::Base to provide
22
+ # subscription validation support.
23
+ module ClassMethods
24
+
25
+ # Main entry point - any subclass of ActiveRecord::Base can include the
26
+ # _acts_as_subscription_ method to activate subscription functionality.
27
+ #
28
+ # _args_ should be a hash containing the subscription backend account and product information :
29
+ # * <tt>backend</tt> : Name of the recurring billing service being used. Current choices are
30
+ # <tt>:chargify</tt>, <tt>:cheddar_getter</tt> and <tt>:recurly</tt>.
31
+ # * <tt>user</tt> : Username used to log into the billling service API.
32
+ # * <tt>password</tt> : Password used to log into the billling service API.
33
+ # * <tt>product_code</tt> : Code of your product on the backend service.
34
+ #
35
+ # There are three ways to initialize the backend :
36
+ # 1. Pass the parameters to the _acts_as_subscription_ method from within a model :
37
+ #
38
+ # class Subscription < ActiveRecord::Base
39
+ # acts_as_subscription :backend => :cheddar_getter,
40
+ # :user => 'my_user',
41
+ # :password => 'my_pass',
42
+ # :product_code => 'my_product'
43
+ # end
44
+ #
45
+ # 2. Set the params directly via the Backend module :
46
+ # ActsAsSubscription::Subscription::Backend.config = {
47
+ # :backend => :cheddar_getter,
48
+ # :user => 'my_user',
49
+ # :password => 'my_pass',
50
+ # :product_code => 'my_product'
51
+ # }
52
+ #
53
+ # 3. Add a <tt>config/subscription.yml</tt> file to your rails project :
54
+ #
55
+ # development:
56
+ # backend: 'cheddar_getter'
57
+ # user: 'my_user'
58
+ # password: 'my_pass'
59
+ # product_code: 'my_product'
60
+ def acts_as_subscription(*args)
61
+
62
+ ActsAsSubscription::Subscription::Backend.config = *args
63
+ ActsAsSubscription::Subscription::Backend.initialize
64
+
65
+ validates :customer_code,
66
+ :presence => true
67
+
68
+ validates :email,
69
+ :presence => true
70
+
71
+ validates :first_name,
72
+ :presence => true
73
+
74
+ validates :last_name,
75
+ :presence => true
76
+
77
+ validates :plan_code,
78
+ :presence => true
79
+
80
+ validates :zip_code,
81
+ :presence => true,
82
+ :if => :require_zip_code?
83
+
84
+ validates :cc_expiration_month,
85
+ :presence => true,
86
+ :unless => :is_free_plan?
87
+
88
+ validates :cc_expiration_year,
89
+ :presence => true,
90
+ :unless => :is_free_plan?
91
+
92
+ validates :cc_verification_value,
93
+ :presence => true,
94
+ :if => :require_verification_value?
95
+
96
+ validates :cc_number,
97
+ :presence => true,
98
+ :unless => :is_free_plan?
99
+
100
+ validates_associated :credit_card,
101
+ :unless => :is_free_plan?
102
+
103
+ before_validation :update_credit_card
104
+
105
+ before_validation :assign_customer_code,
106
+ :on => :create
107
+
108
+ validate :validate_credit_card
109
+
110
+ attr_accessible :cc_expiration_month,
111
+ :cc_expiration_year,
112
+ :cc_number,
113
+ :cc_verification_value,
114
+ :customer_code,
115
+ :email,
116
+ :first_name,
117
+ :last_name,
118
+ :plan_code,
119
+ :zip_code
120
+
121
+ # Removed this for the time being, since we often want to perform this before_save
122
+ # on a different model (such as User), if the forms are nested. Having before_save
123
+ # on both models causes quite a few problems. This before_save must be specified
124
+ # by hand in the implementing model.
125
+ #before_save :backend_save
126
+
127
+ self.send(:include, ActsAsSubscription::Subscription::SubscriptionInstanceMethods)
128
+
129
+ end
130
+ end
131
+
132
+ # Instance methods for an ActsAsSubscription::Subscription instance, which use ActiveMerchant
133
+ # to validate credit card information.
134
+ #
135
+ # These instance methods should be included in ActiveRecord::Base to provide
136
+ # subscription validation support.
137
+ module SubscriptionInstanceMethods
138
+
139
+ # Used as an interface to ActiveMerchant for credit card validation.
140
+ attr_reader :credit_card
141
+
142
+ # Allows entry of a complete credit card number, even though we only store the last 4
143
+ # digits internally.
144
+ attr_accessor :cc_number
145
+
146
+ # Returns true if the current plan is free, false otherwise.
147
+ def is_free_plan?
148
+ # If no plan-code is given, assume this is a paid plan to be on the safe side.
149
+ return false unless self.plan_code
150
+
151
+ return (self.plan_code =~ /free/i) != nil
152
+ end
153
+
154
+ # Instantiates an instance of ActiveMerchant::Billing::CreditCard if one hasn't already
155
+ # been created.
156
+ def credit_card
157
+ self[:credit_card] ||= instantiate_credit_card
158
+ end
159
+
160
+ def backend_save
161
+ p "+++++++++++++++++++++++ saving +++++++++++++++++++++++"
162
+ if self.new_record?
163
+ result = ActsAsSubscription::Subscription::Backend.instance.create_subscription(self)
164
+ else
165
+ result = ActsAsSubscription::Subscription::Backend.instance.update_subscription(self)
166
+ end
167
+
168
+ # No way of knowing what fields the errors are for for some backends - just add a top-level error.
169
+ unless result == true
170
+ self.errors.add :base, result
171
+ return false
172
+ end
173
+
174
+ return true
175
+ end
176
+
177
+ # This can be overridden in the implementing model, to turn off zip-code validation.
178
+ def require_zip_code?
179
+ return self.is_free_plan? == false
180
+ end
181
+
182
+ # This can be overridden in the implementing model, to turn off verification-value validation.
183
+ def require_verification_value?
184
+ return self.is_free_plan? == false
185
+ end
186
+
187
+ protected
188
+
189
+ # Assigns a unique UUID for the customer.
190
+ def assign_customer_code
191
+ self.customer_code ||= UUIDTools::UUID.random_create.to_s
192
+ end
193
+
194
+ # Updates the ActiveMerchant::Billing::CreditCard instance with the information
195
+ # provided by the user, prior to validation.
196
+ def update_credit_card
197
+ self.credit_card.first_name = self.first_name
198
+ self.credit_card.last_name = self.last_name
199
+ self.credit_card.month = self.cc_expiration_month
200
+ self.credit_card.year = self.cc_expiration_year
201
+ self.credit_card.verification_value = self.cc_verification_value
202
+ self.credit_card.number = self.cc_number
203
+ self.cc_last_digits = self.credit_card.last_digits
204
+ end
205
+
206
+ # Creates an instance of ActiveMerchant::Billing::CreditCard, and adds a couple
207
+ # of utility methods to make it compatible with ActiveRecord.
208
+ def instantiate_credit_card(attributes = {})
209
+ ActiveMerchant::Billing::CreditCard.require_verification_value = require_verification_value?
210
+
211
+ credit_card = ActiveMerchant::Billing::CreditCard.new(attributes)
212
+
213
+ def credit_card.new_record?
214
+ true
215
+ end
216
+
217
+ def credit_card.persisted?
218
+ false
219
+ end
220
+
221
+ credit_card
222
+ end
223
+
224
+ # Returns true if the credit card information provided is valid, or false otherwise.
225
+ def validate_credit_card
226
+ return if is_free_plan?
227
+
228
+ unless credit_card.valid?
229
+ credit_card.errors.each do |field, messages|
230
+ messages.each do |message|
231
+ # Only add the error if one doesn't already exist - don't need to report
232
+ # 'credit card is invalid' etc if it is already blank.
233
+ errors.add("cc_#{field}".to_sym, message) unless errors["cc_#{field}".to_sym].length > 0
234
+ end
235
+ end
236
+ end
237
+ end
238
+
239
+ end
240
+
241
+ end
242
+ end
243
+
244
+
245
+
246
+ # Need to monkey-patch this since validation complains and errors that it is returning nil.
247
+ module ActionView # :nodoc:
248
+ module Helpers # :nodoc:
249
+ module ActiveModelInstanceTag # :nodoc:
250
+ def error_message
251
+ # Original :
252
+ # object.errors[@method_name]
253
+ # Patched :
254
+ object.errors[@method_name] || []
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,93 @@
1
+ require 'acts_as_subscription/backend'
2
+
3
+ module ActsAsSubscription
4
+ module SubscriptionPlan # :nodoc:
5
+ extend ActiveSupport::Concern
6
+
7
+ # Class methods for an ActsAsSubscription::SubscriptionPlan instance.
8
+ #
9
+ # Handles validation of all subscription_plan-related information, such as :
10
+ #
11
+ # * code
12
+ # * name
13
+ # * description
14
+ # * price
15
+ #
16
+ # These class methods should be included in ActiveRecord::Base to provide
17
+ # subscription_plan validation support.
18
+ module ClassMethods
19
+
20
+ # Main entry point - any subclass of ActiveRecord::Base can include the
21
+ # _acts_as_subscription_plan_ method to activate subscription_plan
22
+ # functionality.
23
+ def acts_as_subscription_plan
24
+
25
+ validates :code,
26
+ :presence => true,
27
+ :uniqueness => {:case_sensitive => false}
28
+
29
+ validates :name,
30
+ :presence => true
31
+
32
+ validates :billing_frequency,
33
+ :presence => true
34
+
35
+ validates :recurring_charge,
36
+ :presence => true,
37
+ :format => { :with => /^\d+??(?:\.\d{0,2})?$/ },
38
+ :numericality => {:greater_than_or_equal_to => 0}
39
+
40
+ attr_accessible :code,
41
+ :name,
42
+ :description,
43
+ :billing_frequency,
44
+ :recurring_charge,
45
+ :active
46
+
47
+ # Synchronizes the local SubscriptionPlan database with the remote backend. This method
48
+ # will try to update existing plans with new data if possible (based on the :code of
49
+ # the plan).
50
+ #
51
+ # It will *NOT* however delete any plans locally that have been removed from
52
+ # the reote system. It is the responsibilty of the developer to remove local copies
53
+ # themselves if a plan is removed from the remote subscription backend. For safety
54
+ # it seems more sensible not to automate the removal of plans.
55
+ def self.sync!
56
+ plans = ActsAsSubscription::Subscription::Backend.plans
57
+ plans.each do |plan|
58
+ # Try to find an existing plan with this code.
59
+ existing = self.find_by_code(plan[:code])
60
+
61
+ if existing
62
+ existing.update_attributes(plan)
63
+ else
64
+ existing = self.new(plan)
65
+ end
66
+
67
+ existing.save
68
+ end
69
+ end
70
+
71
+ # Returns an array of the current plans, suitable for using with a select
72
+ # or radio input within a form. This is provided more as an example, and
73
+ # should be over-ridden in your implementing class if you want to do
74
+ # currency formatting etc.
75
+ def form_options
76
+ plans = self.find_all_by_active(true)
77
+ result = []
78
+ plans.each do |plan|
79
+ label = plan.name
80
+ if plan.recurring_charge > 0
81
+ label = "#{plan.name} ($#{plan.recurring_charge})"
82
+ end
83
+ result << [label, plan.code]
84
+ end
85
+
86
+ return result
87
+ end
88
+
89
+ end
90
+ end
91
+
92
+ end
93
+ end
@@ -0,0 +1,7 @@
1
+ require File.join(File.dirname(__FILE__), 'acts_as_subscription/railtie.rb')
2
+
3
+ module ActsAsSubscription # :nodoc:
4
+ autoload :Subscription, File.join(File.dirname(__FILE__), 'acts_as_subscription/subscription')
5
+ autoload :SubscriptionPlan, File.join(File.dirname(__FILE__), 'acts_as_subscription/subscription_plan')
6
+ end
7
+