freemium-ajb 0.0.4
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/.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
|