freemium 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.
- data/.gitignore +53 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +121 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +67 -0
- data/Rakefile +23 -0
- data/autotest/discover.rb +2 -0
- data/config/locales/en.yml +2 -0
- data/freemium.gemspec +28 -0
- data/lib/freemium/address.rb +18 -0
- data/lib/freemium/coupon.rb +38 -0
- data/lib/freemium/coupon_redemption.rb +48 -0
- data/lib/freemium/credit_card.rb +273 -0
- data/lib/freemium/feature_set.rb +45 -0
- data/lib/freemium/gateways/base.rb +65 -0
- data/lib/freemium/gateways/brain_tree.rb +175 -0
- data/lib/freemium/gateways/test.rb +34 -0
- data/lib/freemium/manual_billing.rb +73 -0
- data/lib/freemium/railtie.tb +7 -0
- data/lib/freemium/rates.rb +33 -0
- data/lib/freemium/recurring_billing.rb +59 -0
- data/lib/freemium/response.rb +24 -0
- data/lib/freemium/subscription.rb +350 -0
- data/lib/freemium/subscription_change.rb +20 -0
- data/lib/freemium/subscription_mailer/admin_report.rhtml +4 -0
- data/lib/freemium/subscription_mailer/expiration_notice.rhtml +1 -0
- data/lib/freemium/subscription_mailer/expiration_warning.rhtml +1 -0
- data/lib/freemium/subscription_mailer/invoice.text.plain.erb +5 -0
- data/lib/freemium/subscription_mailer.rb +36 -0
- data/lib/freemium/subscription_plan.rb +32 -0
- data/lib/freemium/transaction.rb +15 -0
- data/lib/freemium/version.rb +3 -0
- data/lib/freemium.rb +75 -0
- data/lib/generators/active_record/freemium_generator.rb +28 -0
- data/lib/generators/active_record/templates/migrations/account_transactions.rb +17 -0
- data/lib/generators/active_record/templates/migrations/coupon_redemptions.rb +18 -0
- data/lib/generators/active_record/templates/migrations/coupons.rb +28 -0
- data/lib/generators/active_record/templates/migrations/credit_cards.rb +14 -0
- data/lib/generators/active_record/templates/migrations/subscription_changes.rb +18 -0
- data/lib/generators/active_record/templates/migrations/subscription_plans.rb +14 -0
- data/lib/generators/active_record/templates/migrations/subscriptions.rb +30 -0
- data/lib/generators/active_record/templates/models/account_transaction.rb +3 -0
- data/lib/generators/active_record/templates/models/coupon.rb +3 -0
- data/lib/generators/active_record/templates/models/coupon_redemption.rb +3 -0
- data/lib/generators/active_record/templates/models/credit_card.rb +3 -0
- data/lib/generators/active_record/templates/models/subscription.rb +3 -0
- data/lib/generators/active_record/templates/models/subscription_change.rb +3 -0
- data/lib/generators/active_record/templates/models/subscription_plan.rb +3 -0
- data/lib/generators/freemium/freemium_generator.rb +15 -0
- data/lib/generators/freemium/install_generator.rb +28 -0
- data/lib/generators/freemium/orm_helpers.rb +27 -0
- data/lib/generators/templates/freemium.rb +43 -0
- data/lib/generators/templates/freemium_feature_sets.yml +5 -0
- data/spec/dummy/Rakefile +7 -0
- data/spec/dummy/app/controllers/application_controller.rb +3 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/models/models.rb +32 -0
- data/spec/dummy/app/views/layouts/application.html.erb +14 -0
- data/spec/dummy/config/application.rb +45 -0
- data/spec/dummy/config/boot.rb +10 -0
- data/spec/dummy/config/database.yml +22 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +26 -0
- data/spec/dummy/config/environments/production.rb +49 -0
- data/spec/dummy/config/environments/test.rb +35 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/inflections.rb +10 -0
- data/spec/dummy/config/initializers/mime_types.rb +5 -0
- data/spec/dummy/config/initializers/secret_token.rb +7 -0
- data/spec/dummy/config/initializers/session_store.rb +8 -0
- data/spec/dummy/config/locales/en.yml +5 -0
- data/spec/dummy/config/routes.rb +58 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/db/schema.rb +92 -0
- data/spec/dummy/script/rails +6 -0
- data/spec/fixtures/credit_cards.yml +11 -0
- data/spec/fixtures/subscription_plans.yml +18 -0
- data/spec/fixtures/subscriptions.yml +29 -0
- data/spec/fixtures/users.yml +16 -0
- data/spec/freemium_feature_sets.yml +9 -0
- data/spec/freemium_spec.rb +4 -0
- data/spec/models/coupon_redemption_spec.rb +235 -0
- data/spec/models/credit_card_spec.rb +114 -0
- data/spec/models/manual_billing_spec.rb +174 -0
- data/spec/models/recurring_billing_spec.rb +92 -0
- data/spec/models/subscription_plan_spec.rb +44 -0
- data/spec/models/subscription_spec.rb +386 -0
- data/spec/spec_helper.rb +38 -0
- data/spec/support/helpers.rb +21 -0
- metadata +298 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
module Freemium
|
|
2
|
+
module CreditCard
|
|
3
|
+
|
|
4
|
+
CARD_COMPANIES = {
|
|
5
|
+
'visa' => /^4\d{12}(\d{3})?$/,
|
|
6
|
+
'master' => /^(5[1-5]\d{4}|677189)\d{10}$/,
|
|
7
|
+
'discover' => /^(6011|65\d{2})\d{12}$/,
|
|
8
|
+
'american_express' => /^3[47]\d{13}$/,
|
|
9
|
+
'diners_club' => /^3(0[0-5]|[68]\d)\d{11}$/,
|
|
10
|
+
'jcb' => /^3528\d{12}$/,
|
|
11
|
+
'switch' => /^6759\d{12}(\d{2,3})?$/,
|
|
12
|
+
'solo' => /^6767\d{12}(\d{2,3})?$/,
|
|
13
|
+
'dankort' => /^5019\d{12}$/,
|
|
14
|
+
'maestro' => /^(5[06-8]|6\d)\d{10,17}$/,
|
|
15
|
+
'forbrugsforeningen' => /^600722\d{10}$/,
|
|
16
|
+
'laser' => /^(6304[89]\d{11}(\d{2,3})?|670695\d{13})$/
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def self.included(base)
|
|
20
|
+
base.class_eval do
|
|
21
|
+
# Essential attributes for a valid, non-bogus creditcards
|
|
22
|
+
attr_accessor :number, :month, :year, :first_name, :last_name
|
|
23
|
+
|
|
24
|
+
# Required for Switch / Solo cards
|
|
25
|
+
attr_accessor :start_month, :start_year, :issue_number
|
|
26
|
+
|
|
27
|
+
# Optional verification_value (CVV, CVV2 etc). Gateways will try their best to
|
|
28
|
+
# run validation on the passed in value if it is supplied
|
|
29
|
+
attr_accessor :verification_value
|
|
30
|
+
|
|
31
|
+
attr_accessible :number, :month, :year, :first_name, :last_name, :start_month, :start_year, :issue_number, :verification_value, :card_type, :zip_code
|
|
32
|
+
|
|
33
|
+
has_one :subscription, :class_name => "Subscription"
|
|
34
|
+
|
|
35
|
+
before_validation :sanitize_data, :if => :changed?
|
|
36
|
+
|
|
37
|
+
validate :validate_card
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
base.extend(ClassMethods)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
## Callbacks
|
|
45
|
+
##
|
|
46
|
+
|
|
47
|
+
protected
|
|
48
|
+
|
|
49
|
+
def sanitize_data #:nodoc:
|
|
50
|
+
self.month = month.to_i
|
|
51
|
+
self.year = year.to_i
|
|
52
|
+
self.number = number.to_s.gsub(/[^\d]/, "")
|
|
53
|
+
self.card_type.downcase! if card_type.respond_to?(:downcase)
|
|
54
|
+
self.card_type = self.class.card_type?(number) if card_type.blank?
|
|
55
|
+
self.display_number = display_number
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
public
|
|
59
|
+
|
|
60
|
+
##
|
|
61
|
+
## Class Methods
|
|
62
|
+
##
|
|
63
|
+
|
|
64
|
+
module ClassMethods
|
|
65
|
+
# Returns true if it validates. Optionally, you can pass a card type as an argument and
|
|
66
|
+
# make sure it is of the correct type.
|
|
67
|
+
#
|
|
68
|
+
# References:
|
|
69
|
+
# - http://perl.about.com/compute/perl/library/nosearch/P073000.htm
|
|
70
|
+
# - http://www.beachnet.com/~hstiles/cardtype.html
|
|
71
|
+
def valid_number?(number)
|
|
72
|
+
valid_card_number_length?(number) &&
|
|
73
|
+
valid_checksum?(number)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Regular expressions for the known card companies.
|
|
77
|
+
#
|
|
78
|
+
# References:
|
|
79
|
+
# - http://en.wikipedia.org/wiki/Credit_card_number
|
|
80
|
+
# - http://www.barclaycardbusiness.co.uk/information_zone/processing/bin_rules.html
|
|
81
|
+
def card_companies
|
|
82
|
+
CARD_COMPANIES
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns a string containing the type of card from the list of known information below.
|
|
86
|
+
# Need to check the cards in a particular order, as there is some overlap of the allowable ranges
|
|
87
|
+
#--
|
|
88
|
+
# TODO Refactor this method. We basically need to tighten up the Maestro Regexp.
|
|
89
|
+
#
|
|
90
|
+
# Right now the Maestro regexp overlaps with the MasterCard regexp (IIRC). If we can tighten
|
|
91
|
+
# things up, we can boil this whole thing down to something like...
|
|
92
|
+
#
|
|
93
|
+
# def type?(number)
|
|
94
|
+
# return 'visa' if valid_test_mode_card_number?(number)
|
|
95
|
+
# card_companies.find([nil]) { |type, regexp| number =~ regexp }.first.dup
|
|
96
|
+
# end
|
|
97
|
+
#
|
|
98
|
+
def card_type?(number)
|
|
99
|
+
card_companies.reject { |c,p| c == 'maestro' }.each do |company, pattern|
|
|
100
|
+
return company.dup if number =~ pattern
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
return 'maestro' if number =~ card_companies['maestro']
|
|
104
|
+
|
|
105
|
+
return nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def last_digits(number)
|
|
109
|
+
number.to_s.length <= 4 ? number : number.to_s.slice(-4..-1)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def mask(number)
|
|
113
|
+
"XXXX-XXXX-XXXX-#{last_digits(number)}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Checks to see if the calculated type matches the specified type
|
|
117
|
+
def matching_card_type?(number, card_type)
|
|
118
|
+
card_type?(number) == card_type
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def valid_card_number_length?(number) #:nodoc:
|
|
124
|
+
number.to_s.length >= 12
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Checks the validity of a card number by use of the the Luhn Algorithm.
|
|
128
|
+
# Please see http://en.wikipedia.org/wiki/Luhn_algorithm for details.
|
|
129
|
+
def valid_checksum?(number) #:nodoc:
|
|
130
|
+
sum = 0
|
|
131
|
+
for i in 0..number.length
|
|
132
|
+
weight = number[-1 * (i + 2), 1].to_i * (2 - (i % 2))
|
|
133
|
+
sum += (weight < 10) ? weight : weight - 9
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
(number[-1,1].to_i == (10 - sum % 10) % 10)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
##
|
|
142
|
+
## From ActiveMerchant::Billing::CreditCard
|
|
143
|
+
##
|
|
144
|
+
|
|
145
|
+
# Provides proxy access to an expiry date object
|
|
146
|
+
def expiration_date
|
|
147
|
+
unless self['expiration_date']
|
|
148
|
+
month_days = [nil,31,28,31,30,31,30,31,31,30,31,30,31]
|
|
149
|
+
begin
|
|
150
|
+
month_days[2] = 29 if Date.leap?(@year)
|
|
151
|
+
str = "#{month_days[@month]}/#{@month}/#{@year} 23:59:59"
|
|
152
|
+
self['expiration_date'] = Time.parse(str)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
self['expiration_date']
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def expired?
|
|
159
|
+
return false unless expiration_date
|
|
160
|
+
Time.now > expiration_date
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def name?
|
|
164
|
+
first_name? && last_name?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def first_name?
|
|
168
|
+
!@first_name.blank?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def last_name?
|
|
172
|
+
!@last_name.blank?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def name
|
|
176
|
+
"#{@first_name} #{@last_name}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Show the card number, with all but last 4 numbers replace with "X". (XXXX-XXXX-XXXX-4338)
|
|
180
|
+
def display_number
|
|
181
|
+
self['display_number'] ||= self.class.mask(number)
|
|
182
|
+
self['display_number']
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def last_digits
|
|
186
|
+
self.class.last_digits(number)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def address
|
|
190
|
+
unless @address
|
|
191
|
+
@address = Address.new
|
|
192
|
+
@address.zip = self.zip_code
|
|
193
|
+
end
|
|
194
|
+
@address
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
##
|
|
198
|
+
## Overrides
|
|
199
|
+
##
|
|
200
|
+
|
|
201
|
+
# We're overriding AR#changed? to include instance vars that aren't persisted to see if a new card is being set
|
|
202
|
+
def changed?
|
|
203
|
+
card_type_changed? || [:number, :month, :year, :first_name, :last_name, :start_month, :start_year, :issue_number, :verification_value].any? {|attr| !self.send(attr).nil?}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
##
|
|
207
|
+
## Validation
|
|
208
|
+
##
|
|
209
|
+
|
|
210
|
+
def validate_card
|
|
211
|
+
# We don't need to run validations unless it's a new record or the
|
|
212
|
+
# record has changed
|
|
213
|
+
return unless new_record? || changed?
|
|
214
|
+
|
|
215
|
+
validate_essential_attributes
|
|
216
|
+
|
|
217
|
+
# Bogus card is pretty much for testing purposes. Lets just skip these extra tests if its used
|
|
218
|
+
return if card_type == 'bogus'
|
|
219
|
+
|
|
220
|
+
validate_card_type
|
|
221
|
+
validate_card_number
|
|
222
|
+
validate_switch_or_solo_attributes
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def validate_card_number #:nodoc:
|
|
228
|
+
errors.add :number, "is not a valid credit card number" unless self.class.valid_number?(number)
|
|
229
|
+
unless errors[:number] || errors[:type]
|
|
230
|
+
errors.add :card_type, "is not the correct card type" unless self.class.matching_card_type?(number, card_type)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def validate_card_type #:nodoc:
|
|
235
|
+
errors.add :card_type, "is required" if card_type.blank?
|
|
236
|
+
errors.add :card_type, "is invalid" unless self.class.card_companies.keys.include?(card_type)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def validate_essential_attributes #:nodoc:
|
|
240
|
+
errors.add :first_name, "cannot be empty" if @first_name.blank?
|
|
241
|
+
errors.add :last_name, "cannot be empty" if @last_name.blank?
|
|
242
|
+
errors.add :month, "is not a valid month" unless valid_month?(@month)
|
|
243
|
+
errors.add :year, "expired" if expired?
|
|
244
|
+
errors.add :year, "is not a valid year" unless valid_expiration_year?(@year)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def validate_switch_or_solo_attributes #:nodoc:
|
|
248
|
+
if %w[switch solo].include?(card_type)
|
|
249
|
+
unless valid_month?(@start_month) && valid_start_year?(@start_year) || valid_issue_number?(@issue_number)
|
|
250
|
+
errors.add :start_month, "is invalid" unless valid_month?(@start_month)
|
|
251
|
+
errors.add :start_year, "is invalid" unless valid_start_year?(@start_year)
|
|
252
|
+
errors.add :issue_number, "cannot be empty" unless valid_issue_number?(@issue_number)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def valid_month?(month)
|
|
258
|
+
(1..12).include?(month)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def valid_expiration_year?(year)
|
|
262
|
+
(Time.now.year..Time.now.year + 20).include?(year)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def valid_start_year?(year)
|
|
266
|
+
year.to_s =~ /^\d{4}$/ && year.to_i > 1987
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def valid_issue_number?(number)
|
|
270
|
+
number.to_s =~ /^\d{1,2}$/
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Freemium
|
|
2
|
+
class FeatureSet
|
|
3
|
+
|
|
4
|
+
def initialize(hash = {})
|
|
5
|
+
hash.each do |key, value|
|
|
6
|
+
self.class.class_eval { attr_accessor key.intern }
|
|
7
|
+
self.send("#{key}=", value)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def method_missing(method, *args, &block)
|
|
12
|
+
# forward named routes
|
|
13
|
+
if method.to_s.include? '?'
|
|
14
|
+
send(method.to_s[0..-2], *args, &block)
|
|
15
|
+
else
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.find(id)
|
|
21
|
+
self.feature_sets[id.to_s]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
cattr_accessor :config_file
|
|
27
|
+
def self.config_file
|
|
28
|
+
@@config_file ||= File.join(RAILS_ROOT, 'config', 'freemium_feature_sets.yml')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
cattr_accessor :feature_sets
|
|
32
|
+
self.feature_sets = nil
|
|
33
|
+
|
|
34
|
+
def self.feature_sets
|
|
35
|
+
if @@feature_sets.nil?
|
|
36
|
+
@@feature_sets = {}
|
|
37
|
+
YAML::load(File.read(self.config_file)).each do |features|
|
|
38
|
+
feature_set = FeatureSet.new(features)
|
|
39
|
+
@@feature_sets[feature_set.id] = feature_set
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
@@feature_sets
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Freemium
|
|
2
|
+
module Gateways
|
|
3
|
+
class Base #:nodoc:
|
|
4
|
+
superclass_delegating_accessor :open_timeout
|
|
5
|
+
self.open_timeout = 60
|
|
6
|
+
|
|
7
|
+
superclass_delegating_accessor :read_timeout
|
|
8
|
+
self.read_timeout = 60
|
|
9
|
+
|
|
10
|
+
# cancels the subscription identified by the given billing key.
|
|
11
|
+
# this might mean removing it from the remote system, or halting the remote
|
|
12
|
+
# recurring billing.
|
|
13
|
+
#
|
|
14
|
+
# should return a Freemium::Response
|
|
15
|
+
def cancel(billing_key)
|
|
16
|
+
raise MethodNotImplemented
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# stores a credit card with the gateway.
|
|
20
|
+
# should return a Freemium::Response
|
|
21
|
+
def store(credit_card, address = nil)
|
|
22
|
+
raise MethodNotImplemented
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# updates a credit card in the gateway.
|
|
26
|
+
# should return a Freemium::Response
|
|
27
|
+
def update(billing_key, credit_card = nil, address = nil)
|
|
28
|
+
raise MethodNotImplemented
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# validates a credit card with the gateway.
|
|
32
|
+
# should return a Freemium::Response
|
|
33
|
+
def validate(credit_card, address = nil)
|
|
34
|
+
raise MethodNotImplemented
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
## Only needed to support Freemium.billing_handler = :gateway
|
|
39
|
+
##
|
|
40
|
+
|
|
41
|
+
# only needed to support an ARB module. otherwise, the manual billing process will
|
|
42
|
+
# take care of processing transaction information as it happens.
|
|
43
|
+
#
|
|
44
|
+
# concrete classes need to support these options:
|
|
45
|
+
# :billing_key : - only retrieve transactions for this specific billing key
|
|
46
|
+
# :after : - only retrieve transactions after this datetime (non-inclusive)
|
|
47
|
+
# :before : - only retrieve transactions before this datetime (non-inclusive)
|
|
48
|
+
#
|
|
49
|
+
# return value should be a collection of Freemium::Transaction objects.
|
|
50
|
+
def transactions(options = {})
|
|
51
|
+
raise MethodNotImplemented
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
## Only needed to support Freemium.billing_handler = :manual
|
|
56
|
+
##
|
|
57
|
+
|
|
58
|
+
# charges money against the given billing key.
|
|
59
|
+
# should return a Freemium::Transaction
|
|
60
|
+
def charge(billing_key, amount)
|
|
61
|
+
raise MethodNotImplemented
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'net/https'
|
|
3
|
+
module Freemium
|
|
4
|
+
module Gateways
|
|
5
|
+
# = Setup and Configuration
|
|
6
|
+
# In your config/initializers/freemium.rb, configure Freemium to use BrainTree:
|
|
7
|
+
#
|
|
8
|
+
# Freemium.gateway = Freemium::Gateways::BrainTree.new
|
|
9
|
+
# Freemium.gateway.username = "my_username"
|
|
10
|
+
# Freemium.gateway.password = "my_password"
|
|
11
|
+
#
|
|
12
|
+
# Note that if you want to use demo/password credentials when not in production mode, this is the place.
|
|
13
|
+
#
|
|
14
|
+
# = Data Structures
|
|
15
|
+
# All amounts should use the Money class (from eponymous gem).
|
|
16
|
+
# All credit cards should use Freemium::CreditCard class (currently just an alias for ActiveMerchant::Billing::CreditCard).
|
|
17
|
+
# All addresses should use Freemium::Address class.
|
|
18
|
+
#
|
|
19
|
+
# = For Testing
|
|
20
|
+
# The URL does not change. If your account is in test mode, no charges will be processed. Otherwise,
|
|
21
|
+
# configure the username and password to be "demo" and "password", respectively.
|
|
22
|
+
class BrainTree < Base
|
|
23
|
+
URL = 'https://secure.braintreepaymentgateway.com/api/transact.php'
|
|
24
|
+
attr_accessor :username, :password
|
|
25
|
+
|
|
26
|
+
# using BrainTree's recurring billing is not possible until I have their reporting API
|
|
27
|
+
#def transactions(options = {}); end
|
|
28
|
+
|
|
29
|
+
# Stores a card in SecureVault.
|
|
30
|
+
def store(credit_card, address = nil)
|
|
31
|
+
p = Post.new(URL, {
|
|
32
|
+
:username => self.username,
|
|
33
|
+
:password => self.password,
|
|
34
|
+
:customer_vault => "add_customer"
|
|
35
|
+
})
|
|
36
|
+
p.params.merge! params_for_credit_card(credit_card)
|
|
37
|
+
p.params.merge! params_for_address(address) if address
|
|
38
|
+
p.commit(open_timeout, read_timeout)
|
|
39
|
+
return p.response
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Updates a card in SecureVault.
|
|
43
|
+
def update(vault_id, credit_card = nil, address = nil)
|
|
44
|
+
p = Post.new(URL, {
|
|
45
|
+
:username => self.username,
|
|
46
|
+
:password => self.password,
|
|
47
|
+
:customer_vault => "update_customer",
|
|
48
|
+
:customer_vault_id => vault_id
|
|
49
|
+
})
|
|
50
|
+
p.params.merge! params_for_credit_card(credit_card) if credit_card
|
|
51
|
+
p.params.merge! params_for_address(address) if address
|
|
52
|
+
p.commit(open_timeout, read_timeout)
|
|
53
|
+
return p.response
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Manually charges a card in SecureVault. Called automatically as part of manual billing process.
|
|
57
|
+
def charge(vault_id, amount)
|
|
58
|
+
p = Post.new(URL, {
|
|
59
|
+
:username => self.username,
|
|
60
|
+
:password => self.password,
|
|
61
|
+
:customer_vault_id => vault_id,
|
|
62
|
+
:amount => sprintf("%.2f", amount.cents.to_f / 100)
|
|
63
|
+
})
|
|
64
|
+
p.commit(open_timeout, read_timeout)
|
|
65
|
+
transaction = AccountTransaction.new(:billing_key => vault_id, :amount => amount, :success => p.response.success?)
|
|
66
|
+
transaction.response = p.response if transaction.respond_to?(:response=)
|
|
67
|
+
return transaction
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Removes a card from SecureVault. Called automatically when the subscription expires.
|
|
71
|
+
def cancel(vault_id)
|
|
72
|
+
p = Post.new(URL, {
|
|
73
|
+
:username => self.username,
|
|
74
|
+
:password => self.password,
|
|
75
|
+
:customer_vault => 'delete_customer',
|
|
76
|
+
:customer_vault_id => vault_id
|
|
77
|
+
})
|
|
78
|
+
p.commit(open_timeout, read_timeout)
|
|
79
|
+
return p.response
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Validates the card.
|
|
83
|
+
def validate(credit_card, address = nil)
|
|
84
|
+
# Assume we validated if we're using a demo account
|
|
85
|
+
return Freemium::Response.new(true) if self.username == 'demo'
|
|
86
|
+
|
|
87
|
+
p = Post.new(URL, {
|
|
88
|
+
:username => self.username,
|
|
89
|
+
:password => self.password,
|
|
90
|
+
:type => 'validate'
|
|
91
|
+
})
|
|
92
|
+
p.params.merge! params_for_credit_card(credit_card)
|
|
93
|
+
if address
|
|
94
|
+
p.params.merge! params_for_address(address)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
p.commit(open_timeout, read_timeout)
|
|
98
|
+
return p.response
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
protected
|
|
102
|
+
def params_for_credit_card(card)
|
|
103
|
+
params = {
|
|
104
|
+
:payment => 'creditcard',
|
|
105
|
+
:firstname => card.first_name,
|
|
106
|
+
:lastname => card.last_name,
|
|
107
|
+
:ccnumber => card.number,
|
|
108
|
+
:ccv => card.verification_value,
|
|
109
|
+
:ccexp => ["%.2i" % card.month, ("%.4i" % card.year)[-2..-1]].join # MMYY
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def params_for_address(address)
|
|
114
|
+
params = {
|
|
115
|
+
:email => address.email,
|
|
116
|
+
:address1 => address.address1,
|
|
117
|
+
:address2 => address.address2,
|
|
118
|
+
:city => address.city,
|
|
119
|
+
:state => address.state, # TODO: two-digit code!
|
|
120
|
+
:zip => address.zip,
|
|
121
|
+
:country => address.country, # TODO: two digit code! (ISO-3166)
|
|
122
|
+
:phone => address.phone_number,
|
|
123
|
+
:ipaddress => address.ip_address
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class Post
|
|
128
|
+
attr_accessor :url
|
|
129
|
+
attr_accessor :params
|
|
130
|
+
attr_reader :response
|
|
131
|
+
|
|
132
|
+
def initialize(url, params = {})
|
|
133
|
+
self.url = url
|
|
134
|
+
self.params = params
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def commit(open_timeout, read_timeout)
|
|
138
|
+
data = parse(post(open_timeout, read_timeout))
|
|
139
|
+
# from BT API: 1 means approved, 2 means declined, 3 means error
|
|
140
|
+
success = data['response'].to_i == 1
|
|
141
|
+
@response = Freemium::Response.new(success, data)
|
|
142
|
+
@response.billing_key = data['customer_vault_id']
|
|
143
|
+
@response.message = data['responsetext']
|
|
144
|
+
return self
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
protected
|
|
148
|
+
|
|
149
|
+
# BrainTree returns a body of parameters in GET query format, so convert that into a simple hash.
|
|
150
|
+
def parse(data)
|
|
151
|
+
{}.tap do |results|
|
|
152
|
+
data.split('&').each do |pair|
|
|
153
|
+
key, value = pair.split('=', 2).collect { |v| CGI::unescape(v) }
|
|
154
|
+
results[key] = value
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# cf. ActiveMerchant's PostsData module.
|
|
160
|
+
def post(open_timeout, read_timeout)
|
|
161
|
+
uri = URI.parse(self.url)
|
|
162
|
+
|
|
163
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
164
|
+
http.open_timeout = open_timeout
|
|
165
|
+
http.read_timeout = read_timeout
|
|
166
|
+
http.use_ssl = true
|
|
167
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
168
|
+
|
|
169
|
+
data = self.params.collect { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
|
|
170
|
+
http.post(uri.request_uri, data).body
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Freemium
|
|
2
|
+
module Gateways
|
|
3
|
+
class Test < Base
|
|
4
|
+
def transactions(options = {})
|
|
5
|
+
options
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def charge(*args)
|
|
9
|
+
args
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def store(*args)
|
|
13
|
+
response = Freemium::Response.new(true)
|
|
14
|
+
response.billing_key = Time.now.to_i.to_s
|
|
15
|
+
response
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def update(billing_key, *args)
|
|
19
|
+
response = Freemium::Response.new(true)
|
|
20
|
+
response.billing_key = billing_key
|
|
21
|
+
response
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cancel(*args)
|
|
25
|
+
args
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate(*args)
|
|
29
|
+
response = Freemium::Response.new(true)
|
|
30
|
+
response
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Freemium
|
|
2
|
+
# adds manual billing functionality to the Subscription class
|
|
3
|
+
module ManualBilling
|
|
4
|
+
def self.included(base)
|
|
5
|
+
base.extend ClassMethods
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Override if you need to charge something different than the rate (ex: yearly billing option)
|
|
9
|
+
def installment_amount(options = {})
|
|
10
|
+
self.rate(options)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# charges this subscription.
|
|
14
|
+
# assumes, of course, that this module is mixed in to the Subscription model
|
|
15
|
+
def charge!
|
|
16
|
+
# Save the transaction immediately
|
|
17
|
+
|
|
18
|
+
@transaction = gateway.charge(billing_key, self.installment_amount)
|
|
19
|
+
self.transactions << @transaction
|
|
20
|
+
self.last_transaction_at = Time.now # TODO this could probably now be inferred from the list of transactions
|
|
21
|
+
self.last_transaction_success = @transaction.success?
|
|
22
|
+
|
|
23
|
+
self.save(:validate => false)
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
if @transaction.success?
|
|
27
|
+
receive_payment!(@transaction)
|
|
28
|
+
elsif !@transaction.subscription.in_grace?
|
|
29
|
+
expire_after_grace!(@transaction)
|
|
30
|
+
end
|
|
31
|
+
rescue
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@transaction
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def store_credit_card_offsite
|
|
38
|
+
if credit_card && credit_card.changed? && credit_card.valid?
|
|
39
|
+
response = billing_key ? gateway.update(billing_key, credit_card, credit_card.address) : gateway.store(credit_card, credit_card.address)
|
|
40
|
+
raise Freemium::CreditCardStorageError.new(response.message) unless response.success?
|
|
41
|
+
self.billing_key = response.billing_key
|
|
42
|
+
self.expire_on = nil if last_transaction_success
|
|
43
|
+
self.credit_card.reload # to prevent needless subsequent store() calls
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
module ClassMethods
|
|
48
|
+
# the process you should run periodically
|
|
49
|
+
def run_billing
|
|
50
|
+
# charge all billable subscriptions
|
|
51
|
+
@transactions = find_billable.collect{|b| b.charge!}
|
|
52
|
+
# actually expire any subscriptions whose time has come
|
|
53
|
+
expire
|
|
54
|
+
|
|
55
|
+
# send the activity report
|
|
56
|
+
Freemium.mailer.deliver_admin_report(
|
|
57
|
+
@transactions # Add in transactions
|
|
58
|
+
) if Freemium.admin_report_recipients && !@transactions.empty?
|
|
59
|
+
|
|
60
|
+
@transactions
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
protected
|
|
64
|
+
|
|
65
|
+
# a subscription is due on the last day it's paid through. so this finds all
|
|
66
|
+
# subscriptions that expire the day *after* the given date.
|
|
67
|
+
# because of coupons we can't trust rate_cents alone and need to verify that the account is indeed paid?
|
|
68
|
+
def find_billable
|
|
69
|
+
self.paid.due.select{|s| s.paid?}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|