freemium-ajb 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- data/.coveralls.yml +1 -0
- data/.gitignore +27 -0
- data/.rspec +1 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +6 -0
- data/Gemfile +4 -0
- data/LICENSE.md +20 -0
- data/README.md +1 -0
- data/app/mailers/freemium_mailer.rb +36 -0
- data/app/views/subscription_mailer/admin_report.text.erb +4 -0
- data/app/views/subscription_mailer/expiration_notice.text.erb +1 -0
- data/app/views/subscription_mailer/expiration_warning.text.erb +1 -0
- data/app/views/subscription_mailer/invoice.text.erb +5 -0
- data/freemium.gemspec +29 -0
- data/lib/freemium.rb +25 -0
- data/lib/freemium/configuration.rb +27 -0
- data/lib/freemium/coupon.rb +37 -0
- data/lib/freemium/coupon_redemption.rb +59 -0
- data/lib/freemium/credit_card.rb +222 -0
- data/lib/freemium/engine.rb +7 -0
- data/lib/freemium/gateways/base.rb +65 -0
- data/lib/freemium/gateways/brain_tree.rb +175 -0
- data/lib/freemium/gateways/test.rb +36 -0
- data/lib/freemium/rates.rb +33 -0
- data/lib/freemium/response.rb +24 -0
- data/lib/freemium/subscription.rb +384 -0
- data/lib/freemium/subscription_change.rb +20 -0
- data/lib/freemium/subscription_plan.rb +26 -0
- data/lib/freemium/testing/app/controllers/application_controller.rb +7 -0
- data/lib/freemium/testing/application.rb +46 -0
- data/lib/freemium/testing/config/database.yml +11 -0
- data/lib/freemium/testing/config/routes.rb +3 -0
- data/lib/freemium/transaction.rb +15 -0
- data/lib/freemium/version.rb +3 -0
- data/lib/generators/freemium/install/install_generator.rb +58 -0
- data/lib/generators/freemium/install/templates/db/migrate/create_coupon_redemptions.rb +18 -0
- data/lib/generators/freemium/install/templates/db/migrate/create_coupons.rb +28 -0
- data/lib/generators/freemium/install/templates/db/migrate/create_credit_cards.rb +14 -0
- data/lib/generators/freemium/install/templates/db/migrate/create_subscription_changes.rb +21 -0
- data/lib/generators/freemium/install/templates/db/migrate/create_subscription_plans.rb +14 -0
- data/lib/generators/freemium/install/templates/db/migrate/create_subscriptions.rb +31 -0
- data/lib/generators/freemium/install/templates/db/migrate/create_transactions.rb +17 -0
- data/lib/generators/freemium/install/templates/freemium.rb +16 -0
- data/lib/generators/freemium/install/templates/models/coupon.rb +3 -0
- data/lib/generators/freemium/install/templates/models/coupon_redemption.rb +3 -0
- data/lib/generators/freemium/install/templates/models/credit_card.rb +3 -0
- data/lib/generators/freemium/install/templates/models/subscription.rb +3 -0
- data/lib/generators/freemium/install/templates/models/subscription_change.rb +3 -0
- data/lib/generators/freemium/install/templates/models/subscription_plan.rb +3 -0
- data/lib/generators/freemium/install/templates/models/transaction.rb +3 -0
- data/lib/generators/views/USAGE +3 -0
- data/lib/generators/views/views_generator.rb +39 -0
- data/lib/tasks/freemium.rake +17 -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/mailers/mailers.rb +1 -0
- data/spec/dummy/app/models/models.rb +31 -0
- data/spec/dummy/app/views/layouts/application.html.erb +14 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/config/application.rb +45 -0
- data/spec/dummy/config/boot.rb +10 -0
- data/spec/dummy/config/database.yml +21 -0
- data/spec/dummy/config/environment.rb +6 -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 +36 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/freemium.rb +16 -0
- data/spec/dummy/config/initializers/inflections.rb +10 -0
- data/spec/dummy/config/initializers/mem_db.rb +12 -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/db/schema.rb +90 -0
- data/spec/dummy/script/rails +6 -0
- data/spec/fixtures/credit_cards.yml +11 -0
- data/spec/fixtures/subscription_plans.yml +15 -0
- data/spec/fixtures/subscriptions.yml +28 -0
- data/spec/fixtures/users.yml +16 -0
- data/spec/lib/tasks/run_billing_rake_spec.rb +14 -0
- data/spec/models/coupon_redemption_spec.rb +287 -0
- data/spec/models/credit_card_spec.rb +124 -0
- data/spec/models/manual_billing_spec.rb +165 -0
- data/spec/models/subscription_plan_spec.rb +46 -0
- data/spec/models/subscription_spec.rb +386 -0
- data/spec/spec_helper.rb +19 -0
- data/spec/support/helpers.rb +18 -0
- data/spec/support/shared_contexts/rake.rb +19 -0
- metadata +270 -0
@@ -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,36 @@
|
|
1
|
+
module Freemium
|
2
|
+
module Gateways
|
3
|
+
class Test < Base
|
4
|
+
attr_accessor :username, :password
|
5
|
+
|
6
|
+
def transactions(options = {})
|
7
|
+
options
|
8
|
+
end
|
9
|
+
|
10
|
+
def charge(*args)
|
11
|
+
args
|
12
|
+
end
|
13
|
+
|
14
|
+
def store(*args)
|
15
|
+
response = Freemium::Response.new(true)
|
16
|
+
response.billing_key = Time.now.to_i.to_s
|
17
|
+
response
|
18
|
+
end
|
19
|
+
|
20
|
+
def update(billing_key, *args)
|
21
|
+
response = Freemium::Response.new(true)
|
22
|
+
response.billing_key = billing_key
|
23
|
+
response
|
24
|
+
end
|
25
|
+
|
26
|
+
def cancel(*args)
|
27
|
+
args
|
28
|
+
end
|
29
|
+
|
30
|
+
def validate(*args)
|
31
|
+
response = Freemium::Response.new(true)
|
32
|
+
response
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Freemium
|
2
|
+
module Rates
|
3
|
+
|
4
|
+
# returns the daily cost of this plan.
|
5
|
+
def daily_rate(options = {})
|
6
|
+
yearly_rate(options) / 365
|
7
|
+
end
|
8
|
+
|
9
|
+
# returns the yearly cost of this plan.
|
10
|
+
def yearly_rate(options = {})
|
11
|
+
begin
|
12
|
+
rate(options) * 12
|
13
|
+
rescue
|
14
|
+
rate * 12
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# returns the monthly cost of this plan.
|
19
|
+
def monthly_rate(options = {})
|
20
|
+
begin
|
21
|
+
rate(options)
|
22
|
+
rescue
|
23
|
+
rate
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def paid?
|
28
|
+
return false unless rate
|
29
|
+
rate.cents > 0
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Freemium
|
2
|
+
# used to encapsulate the success/failure/details of a response from some gateway.
|
3
|
+
# intended to be independent of the details of communication (e.g. Freemium::Gateways::BrainTree::Post).
|
4
|
+
class Response
|
5
|
+
# a gateway-specific hash of raw data related to the request.
|
6
|
+
attr_reader :raw_data
|
7
|
+
# may contain a description of the response. should contain an explanation if the response was not a success.
|
8
|
+
attr_accessor :message
|
9
|
+
# the related billing key, if appropriate
|
10
|
+
attr_accessor :billing_key
|
11
|
+
|
12
|
+
def initialize(success, raw_data = {})
|
13
|
+
@success, @raw_data = success, raw_data
|
14
|
+
end
|
15
|
+
|
16
|
+
def success?
|
17
|
+
@success
|
18
|
+
end
|
19
|
+
|
20
|
+
def [](key)
|
21
|
+
raw_data[key]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,384 @@
|
|
1
|
+
# == Attributes
|
2
|
+
# subscribable: the model in your system that has the subscription. probably a User.
|
3
|
+
# subscription_plan: which service plan this subscription is for. affects how payment is interpreted.
|
4
|
+
# paid_through: when the subscription currently expires, assuming no further payment. for manual billing, this also determines when the next payment is due.
|
5
|
+
# billing_key: the id for this user in the remote billing gateway. may not exist if user is on a free plan.
|
6
|
+
# last_transaction_at: when the last gateway transaction was for this account. this is used by your gateway to find "new" transactions.
|
7
|
+
#
|
8
|
+
module Freemium
|
9
|
+
module Subscription
|
10
|
+
include Rates
|
11
|
+
|
12
|
+
def self.included(base)
|
13
|
+
base.class_eval do
|
14
|
+
belongs_to :subscription_plan
|
15
|
+
belongs_to :subscribable, polymorphic: true
|
16
|
+
|
17
|
+
belongs_to :credit_card, dependent: :destroy
|
18
|
+
has_many :coupon_redemptions, conditions: "coupon_redemptions.expired_on IS NULL", dependent: :destroy
|
19
|
+
has_many :coupons, conditions: "coupon_redemptions.expired_on IS NULL", through: :coupon_redemptions
|
20
|
+
|
21
|
+
has_many :transactions
|
22
|
+
|
23
|
+
scope :paid, -> { includes(:subscription_plan).where("subscription_plans.rate_cents > 0") }
|
24
|
+
scope :due, -> { where('paid_through <= ?', Date.today) }
|
25
|
+
scope :expired, -> { where('expire_on >= paid_through AND expire_on <= ?', Date.today) }
|
26
|
+
|
27
|
+
before_save :remove_coupon_if_no_longer_applies
|
28
|
+
before_save :set_paid_through
|
29
|
+
before_save :set_started_on
|
30
|
+
before_save :store_credit_card_offsite
|
31
|
+
before_save :discard_credit_card_unless_paid
|
32
|
+
before_destroy :cancel_in_remote_system
|
33
|
+
|
34
|
+
after_create :audit_create
|
35
|
+
after_update :audit_update
|
36
|
+
after_destroy :audit_destroy
|
37
|
+
|
38
|
+
validates_presence_of :subscribable
|
39
|
+
validates_associated :subscribable
|
40
|
+
validates_presence_of :subscription_plan
|
41
|
+
validates_presence_of :credit_card, :if => :store_credit_card?
|
42
|
+
validates_associated :credit_card
|
43
|
+
|
44
|
+
validate :gateway_validates_credit_card
|
45
|
+
validate :coupon_exist
|
46
|
+
end
|
47
|
+
base.extend ClassMethods
|
48
|
+
end
|
49
|
+
|
50
|
+
def original_plan
|
51
|
+
@original_plan ||= ::SubscriptionPlan.find_by_id(subscription_plan_id_was) unless subscription_plan_id_was.nil?
|
52
|
+
end
|
53
|
+
|
54
|
+
def gateway
|
55
|
+
Freemium.configuration.gateway
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
protected
|
60
|
+
|
61
|
+
##
|
62
|
+
## Validations
|
63
|
+
##
|
64
|
+
|
65
|
+
def gateway_validates_credit_card
|
66
|
+
if credit_card && credit_card.changed? && credit_card.valid?
|
67
|
+
response = gateway.validate(credit_card)
|
68
|
+
unless response.success?
|
69
|
+
errors.add(:base, "Credit card could not be validated: #{response.message}")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
##
|
75
|
+
## Callbacks
|
76
|
+
##
|
77
|
+
|
78
|
+
def set_paid_through(force = false)
|
79
|
+
if subscription_plan_id_changed? && (!paid_through_changed? || force)
|
80
|
+
if paid?
|
81
|
+
if new_record?
|
82
|
+
# paid + new subscription = in free trial
|
83
|
+
self.paid_through = Date.today + Freemium.configuration.days_free_trial
|
84
|
+
self.in_trial = true
|
85
|
+
elsif !self.in_trial? && self.original_plan && self.original_plan.paid?
|
86
|
+
# paid + not in trial + not new subscription + original sub was paid = calculate and credit for remaining value
|
87
|
+
value = self.remaining_value(original_plan)
|
88
|
+
self.paid_through = Date.today
|
89
|
+
self.credit(value)
|
90
|
+
else
|
91
|
+
# otherwise payment is due today
|
92
|
+
self.paid_through = Date.today
|
93
|
+
self.in_trial = false
|
94
|
+
end
|
95
|
+
else
|
96
|
+
# free plans don't pay
|
97
|
+
self.paid_through = nil
|
98
|
+
end
|
99
|
+
end
|
100
|
+
true
|
101
|
+
end
|
102
|
+
|
103
|
+
def set_started_on
|
104
|
+
self.started_on = Date.today if subscription_plan_id_changed?
|
105
|
+
end
|
106
|
+
|
107
|
+
def discard_credit_card_unless_paid
|
108
|
+
unless store_credit_card?
|
109
|
+
destroy_credit_card
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def destroy_credit_card
|
114
|
+
credit_card.destroy if credit_card
|
115
|
+
cancel_in_remote_system
|
116
|
+
end
|
117
|
+
|
118
|
+
def cancel_in_remote_system
|
119
|
+
if billing_key
|
120
|
+
gateway.cancel(self.billing_key)
|
121
|
+
self.billing_key = nil
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
##
|
126
|
+
## Callbacks :: Auditing
|
127
|
+
##
|
128
|
+
|
129
|
+
def audit_create
|
130
|
+
::SubscriptionChange.create(:reason => "new",
|
131
|
+
:subscribable => self.subscribable,
|
132
|
+
:new_subscription_plan_id => self.subscription_plan_id,
|
133
|
+
:new_rate => self.rate,
|
134
|
+
:original_rate => Money.empty)
|
135
|
+
end
|
136
|
+
|
137
|
+
def audit_update
|
138
|
+
if self.subscription_plan_id_changed?
|
139
|
+
return if self.original_plan.nil?
|
140
|
+
reason = self.original_plan.rate > self.subscription_plan.rate ? (self.expired? ? "expiration" : "downgrade") : "upgrade"
|
141
|
+
::SubscriptionChange.create(:reason => reason,
|
142
|
+
:subscribable => self.subscribable,
|
143
|
+
:original_subscription_plan_id => self.original_plan.id,
|
144
|
+
:original_rate => self.rate(:plan => self.original_plan),
|
145
|
+
:new_subscription_plan_id => self.subscription_plan.id,
|
146
|
+
:new_rate => self.rate)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def remove_coupon_if_no_longer_applies
|
151
|
+
return unless self.subscription_plan_id_changed?
|
152
|
+
return unless self.coupon
|
153
|
+
|
154
|
+
if !self.coupon.applies_to_plan?(self.subscription_plan)
|
155
|
+
self.coupon_redemption.expire!(Date.yesterday)
|
156
|
+
self.coupon_redemptions.reload
|
157
|
+
self.set_paid_through(true)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def audit_destroy
|
162
|
+
::SubscriptionChange.create(:reason => "cancellation",
|
163
|
+
:subscribable => self.subscribable,
|
164
|
+
:original_subscription_plan_id => self.subscription_plan_id,
|
165
|
+
:original_rate => self.rate,
|
166
|
+
:new_rate => Money.empty)
|
167
|
+
end
|
168
|
+
|
169
|
+
public
|
170
|
+
|
171
|
+
##
|
172
|
+
## Class Methods
|
173
|
+
##
|
174
|
+
|
175
|
+
module ClassMethods
|
176
|
+
# expires all subscriptions that have been pastdue for too long (accounting for grace)
|
177
|
+
def find_expired
|
178
|
+
self.expired.select{ |s| s.paid? }
|
179
|
+
end
|
180
|
+
|
181
|
+
def find_billable
|
182
|
+
self.paid.due.select { |s| s.paid? }
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
##
|
187
|
+
## Rate
|
188
|
+
##
|
189
|
+
|
190
|
+
def rate(options = {})
|
191
|
+
options = {:date => Date.today, :plan => self.subscription_plan}.merge(options)
|
192
|
+
|
193
|
+
return nil unless options[:plan]
|
194
|
+
value = options[:plan].rate
|
195
|
+
value = self.coupon(options[:date]).discount(value) if self.coupon(options[:date])
|
196
|
+
value
|
197
|
+
end
|
198
|
+
|
199
|
+
# is this a paid plan?
|
200
|
+
def paid?
|
201
|
+
return false unless rate
|
202
|
+
rate.cents > 0
|
203
|
+
end
|
204
|
+
|
205
|
+
# Allow for more complex logic to decide if a card should be stored
|
206
|
+
def store_credit_card?
|
207
|
+
paid?
|
208
|
+
end
|
209
|
+
|
210
|
+
##
|
211
|
+
## Coupon Redemption
|
212
|
+
##
|
213
|
+
|
214
|
+
def coupon_key=(coupon_key)
|
215
|
+
@coupon_key = coupon_key ? coupon_key.downcase : nil
|
216
|
+
self.coupon = ::Coupon.find_by_redemption_key(@coupon_key) unless @coupon_key.blank?
|
217
|
+
end
|
218
|
+
|
219
|
+
def coupon_exist
|
220
|
+
if !@coupon_key.blank? && ::Coupon.find_by_redemption_key(@coupon_key).nil?
|
221
|
+
self.errors.add :coupon, "could not be found for '#{@coupon_key}'"
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
def coupon=(coupon)
|
226
|
+
if coupon
|
227
|
+
s = ::CouponRedemption.new(:subscription => self, :coupon => coupon)
|
228
|
+
coupon_redemptions << s
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def coupon(date = Date.today)
|
233
|
+
coupon_redemption(date).coupon rescue nil
|
234
|
+
end
|
235
|
+
|
236
|
+
def coupon_redemption(date = Date.today)
|
237
|
+
return nil if coupon_redemptions.empty?
|
238
|
+
active_coupons = coupon_redemptions.select{|c| c.active?(date)}
|
239
|
+
return nil if active_coupons.empty?
|
240
|
+
active_coupons.sort_by{|c| c.coupon.discount_percentage }.reverse.first
|
241
|
+
end
|
242
|
+
|
243
|
+
##
|
244
|
+
## Remaining Time
|
245
|
+
##
|
246
|
+
|
247
|
+
# returns the value of the time between now and paid_through.
|
248
|
+
# will optionally interpret the time according to a certain subscription plan.
|
249
|
+
def remaining_value(plan = self.subscription_plan)
|
250
|
+
self.daily_rate(:plan => plan) * remaining_days
|
251
|
+
end
|
252
|
+
|
253
|
+
# if paid through today, returns zero
|
254
|
+
def remaining_days
|
255
|
+
if self.paid_through
|
256
|
+
(self.paid_through - Date.today)
|
257
|
+
else
|
258
|
+
0
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
##
|
263
|
+
## Grace Period
|
264
|
+
##
|
265
|
+
|
266
|
+
# if under grace through today, returns zero
|
267
|
+
def remaining_days_of_grace
|
268
|
+
(self.expire_on - Date.today - 1).to_i
|
269
|
+
end
|
270
|
+
|
271
|
+
def in_grace?
|
272
|
+
remaining_days < 0 and not expired?
|
273
|
+
end
|
274
|
+
|
275
|
+
##
|
276
|
+
## Expiration
|
277
|
+
##
|
278
|
+
|
279
|
+
# sets the expiration for the subscription based on today and the configured grace period.
|
280
|
+
def expire_after_grace!(transaction = nil)
|
281
|
+
return unless self.expire_on.nil? # You only set this once subsequent failed transactions shouldn't affect expiration
|
282
|
+
self.expire_on = [Date.today, paid_through].max + Freemium.configuration.days_grace
|
283
|
+
transaction.message = "now set to expire on #{self.expire_on}" if transaction
|
284
|
+
Freemium.configuration.mailer.expiration_warning(self).deliver
|
285
|
+
transaction.save! if transaction
|
286
|
+
save!
|
287
|
+
end
|
288
|
+
|
289
|
+
# sends an expiration email, then downgrades to a free plan
|
290
|
+
def expire!
|
291
|
+
Freemium.configuration.mailer.expiration_notice(self).deliver
|
292
|
+
# downgrade to a free plan
|
293
|
+
self.expire_on = Date.today
|
294
|
+
self.subscription_plan = Freemium.configuration.expired_plan if Freemium.configuration.expired_plan
|
295
|
+
self.destroy_credit_card
|
296
|
+
self.save!
|
297
|
+
end
|
298
|
+
|
299
|
+
def expired?
|
300
|
+
expire_on and expire_on <= Date.today
|
301
|
+
end
|
302
|
+
|
303
|
+
##
|
304
|
+
## Receiving More Money
|
305
|
+
##
|
306
|
+
|
307
|
+
# receives payment and saves the record
|
308
|
+
def receive_payment!(transaction)
|
309
|
+
receive_payment(transaction)
|
310
|
+
transaction.save!
|
311
|
+
self.save!
|
312
|
+
end
|
313
|
+
|
314
|
+
# extends the paid_through period according to how much money was received.
|
315
|
+
# when possible, avoids the days-per-month problem by checking if the money
|
316
|
+
# received is a multiple of the plan's rate.
|
317
|
+
#
|
318
|
+
# really, i expect the case where the received payment does not match the
|
319
|
+
# subscription plan's rate to be very much an edge case.
|
320
|
+
def receive_payment(transaction)
|
321
|
+
self.credit(transaction.amount)
|
322
|
+
self.save!
|
323
|
+
transaction.subscription.reload # reloaded to that the paid_through date is correct
|
324
|
+
transaction.message = "now paid through #{self.paid_through}"
|
325
|
+
|
326
|
+
begin
|
327
|
+
Freemium.configuration.mailer.invoice(transaction).deliver
|
328
|
+
rescue => e
|
329
|
+
transaction.message = "error sending invoice: #{e}"
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
def credit(amount)
|
334
|
+
self.paid_through = if amount.cents % rate.cents == 0
|
335
|
+
self.paid_through + (amount.cents / rate.cents).months
|
336
|
+
else
|
337
|
+
self.paid_through + (amount.cents / daily_rate.cents).days
|
338
|
+
end
|
339
|
+
|
340
|
+
# if they've paid again, then reset expiration
|
341
|
+
self.expire_on = nil
|
342
|
+
self.in_trial = false
|
343
|
+
end
|
344
|
+
|
345
|
+
# Override if you need to charge something different than the rate (ex: yearly billing option)
|
346
|
+
def installment_amount(options = {})
|
347
|
+
self.rate(options)
|
348
|
+
end
|
349
|
+
|
350
|
+
# charges this subscription.
|
351
|
+
# assumes, of course, that this module is mixed in to the Subscription model
|
352
|
+
def charge!
|
353
|
+
# Save the transaction immediately
|
354
|
+
|
355
|
+
@transaction = gateway.charge(billing_key, self.installment_amount)
|
356
|
+
self.transactions << @transaction
|
357
|
+
self.last_transaction_at = Time.now # TODO this could probably now be inferred from the list of transactions
|
358
|
+
self.last_transaction_success = @transaction.success?
|
359
|
+
|
360
|
+
self.save(:validate => false)
|
361
|
+
|
362
|
+
begin
|
363
|
+
if @transaction.success?
|
364
|
+
receive_payment!(@transaction)
|
365
|
+
elsif !@transaction.subscription.in_grace?
|
366
|
+
expire_after_grace!(@transaction)
|
367
|
+
end
|
368
|
+
rescue
|
369
|
+
end
|
370
|
+
|
371
|
+
@transaction
|
372
|
+
end
|
373
|
+
|
374
|
+
def store_credit_card_offsite
|
375
|
+
if credit_card && credit_card.changed? && credit_card.valid?
|
376
|
+
response = billing_key ? gateway.update(billing_key, credit_card) : gateway.store(credit_card)
|
377
|
+
raise Freemium::CreditCardStorageError.new(response.message) unless response.success?
|
378
|
+
self.billing_key = response.billing_key
|
379
|
+
self.expire_on = nil if last_transaction_success
|
380
|
+
self.credit_card.reload # to prevent needless subsequent store() calls
|
381
|
+
end
|
382
|
+
end
|
383
|
+
end
|
384
|
+
end
|