freemium 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (90) hide show
  1. data/.gitignore +53 -0
  2. data/Gemfile +4 -0
  3. data/Gemfile.lock +121 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.rdoc +67 -0
  6. data/Rakefile +23 -0
  7. data/autotest/discover.rb +2 -0
  8. data/config/locales/en.yml +2 -0
  9. data/freemium.gemspec +28 -0
  10. data/lib/freemium/address.rb +18 -0
  11. data/lib/freemium/coupon.rb +38 -0
  12. data/lib/freemium/coupon_redemption.rb +48 -0
  13. data/lib/freemium/credit_card.rb +273 -0
  14. data/lib/freemium/feature_set.rb +45 -0
  15. data/lib/freemium/gateways/base.rb +65 -0
  16. data/lib/freemium/gateways/brain_tree.rb +175 -0
  17. data/lib/freemium/gateways/test.rb +34 -0
  18. data/lib/freemium/manual_billing.rb +73 -0
  19. data/lib/freemium/railtie.tb +7 -0
  20. data/lib/freemium/rates.rb +33 -0
  21. data/lib/freemium/recurring_billing.rb +59 -0
  22. data/lib/freemium/response.rb +24 -0
  23. data/lib/freemium/subscription.rb +350 -0
  24. data/lib/freemium/subscription_change.rb +20 -0
  25. data/lib/freemium/subscription_mailer/admin_report.rhtml +4 -0
  26. data/lib/freemium/subscription_mailer/expiration_notice.rhtml +1 -0
  27. data/lib/freemium/subscription_mailer/expiration_warning.rhtml +1 -0
  28. data/lib/freemium/subscription_mailer/invoice.text.plain.erb +5 -0
  29. data/lib/freemium/subscription_mailer.rb +36 -0
  30. data/lib/freemium/subscription_plan.rb +32 -0
  31. data/lib/freemium/transaction.rb +15 -0
  32. data/lib/freemium/version.rb +3 -0
  33. data/lib/freemium.rb +75 -0
  34. data/lib/generators/active_record/freemium_generator.rb +28 -0
  35. data/lib/generators/active_record/templates/migrations/account_transactions.rb +17 -0
  36. data/lib/generators/active_record/templates/migrations/coupon_redemptions.rb +18 -0
  37. data/lib/generators/active_record/templates/migrations/coupons.rb +28 -0
  38. data/lib/generators/active_record/templates/migrations/credit_cards.rb +14 -0
  39. data/lib/generators/active_record/templates/migrations/subscription_changes.rb +18 -0
  40. data/lib/generators/active_record/templates/migrations/subscription_plans.rb +14 -0
  41. data/lib/generators/active_record/templates/migrations/subscriptions.rb +30 -0
  42. data/lib/generators/active_record/templates/models/account_transaction.rb +3 -0
  43. data/lib/generators/active_record/templates/models/coupon.rb +3 -0
  44. data/lib/generators/active_record/templates/models/coupon_redemption.rb +3 -0
  45. data/lib/generators/active_record/templates/models/credit_card.rb +3 -0
  46. data/lib/generators/active_record/templates/models/subscription.rb +3 -0
  47. data/lib/generators/active_record/templates/models/subscription_change.rb +3 -0
  48. data/lib/generators/active_record/templates/models/subscription_plan.rb +3 -0
  49. data/lib/generators/freemium/freemium_generator.rb +15 -0
  50. data/lib/generators/freemium/install_generator.rb +28 -0
  51. data/lib/generators/freemium/orm_helpers.rb +27 -0
  52. data/lib/generators/templates/freemium.rb +43 -0
  53. data/lib/generators/templates/freemium_feature_sets.yml +5 -0
  54. data/spec/dummy/Rakefile +7 -0
  55. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  56. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  57. data/spec/dummy/app/models/models.rb +32 -0
  58. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  59. data/spec/dummy/config/application.rb +45 -0
  60. data/spec/dummy/config/boot.rb +10 -0
  61. data/spec/dummy/config/database.yml +22 -0
  62. data/spec/dummy/config/environment.rb +5 -0
  63. data/spec/dummy/config/environments/development.rb +26 -0
  64. data/spec/dummy/config/environments/production.rb +49 -0
  65. data/spec/dummy/config/environments/test.rb +35 -0
  66. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  67. data/spec/dummy/config/initializers/inflections.rb +10 -0
  68. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  69. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  70. data/spec/dummy/config/initializers/session_store.rb +8 -0
  71. data/spec/dummy/config/locales/en.yml +5 -0
  72. data/spec/dummy/config/routes.rb +58 -0
  73. data/spec/dummy/config.ru +4 -0
  74. data/spec/dummy/db/schema.rb +92 -0
  75. data/spec/dummy/script/rails +6 -0
  76. data/spec/fixtures/credit_cards.yml +11 -0
  77. data/spec/fixtures/subscription_plans.yml +18 -0
  78. data/spec/fixtures/subscriptions.yml +29 -0
  79. data/spec/fixtures/users.yml +16 -0
  80. data/spec/freemium_feature_sets.yml +9 -0
  81. data/spec/freemium_spec.rb +4 -0
  82. data/spec/models/coupon_redemption_spec.rb +235 -0
  83. data/spec/models/credit_card_spec.rb +114 -0
  84. data/spec/models/manual_billing_spec.rb +174 -0
  85. data/spec/models/recurring_billing_spec.rb +92 -0
  86. data/spec/models/subscription_plan_spec.rb +44 -0
  87. data/spec/models/subscription_spec.rb +386 -0
  88. data/spec/spec_helper.rb +38 -0
  89. data/spec/support/helpers.rb +21 -0
  90. metadata +298 -0
@@ -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,59 @@
1
+ module Freemium
2
+ # adds recurring billing functionality to the Subscription class
3
+ module RecurringBilling
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+ end
7
+
8
+ module ClassMethods
9
+ # the process you should run periodically
10
+ def run_billing
11
+ # first, synchronize transactions
12
+ transactions = process_transactions
13
+
14
+ # then, set expiration for any subscriptions that didn't process
15
+ find_expirable.each(&:expire_after_grace!)
16
+ # then, actually expire any subscriptions whose time has come
17
+ expire
18
+
19
+ # send the activity report
20
+ Freemium.mailer.deliver_admin_report(
21
+ transactions
22
+ ) if Freemium.admin_report_recipients && !new_transactions.empty?
23
+ end
24
+
25
+ protected
26
+
27
+ # retrieves all transactions posted after the last known transaction
28
+ #
29
+ # please note how this works: it calculates the maximum last_transaction_at
30
+ # value and only retrieves transactions after that. so be careful that you
31
+ # don't accidentally update the last_transaction_at field for some subset
32
+ # of subscriptions, and leave the others behind!
33
+ def new_transactions
34
+ Freemium.gateway.transactions(:after => self.maximum(:last_transaction_at))
35
+ end
36
+
37
+ # updates all subscriptions with any new transactions
38
+ def process_transactions(transactions = new_transactions)
39
+ transaction do
40
+ transactions.each do |t|
41
+ subscription = ::Subscription.find_by_billing_key(t.billing_key)
42
+ subscription.transactions << t
43
+ t.success? ? subscription.receive_payment!(t) : subscription.expire_after_grace!(t)
44
+ end
45
+ end
46
+ transactions
47
+ end
48
+
49
+ # finds all subscriptions that should have paid but didn't and need to be expired
50
+ # because of coupons we can't trust rate_cents alone and need to verify that the account is indeed paid?
51
+ def find_expirable
52
+ paid.
53
+ where(['paid_through < ?', Date.today]).
54
+ where('(expire_on IS NULL OR expire_on < paid_through)').
55
+ select { |s| s.paid? }
56
+ end
57
+ end
58
+ end
59
+ 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,350 @@
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, :class_name => "SubscriptionPlan"
15
+ belongs_to :subscribable, :polymorphic => true
16
+ belongs_to :credit_card, :dependent => :destroy, :class_name => "CreditCard"
17
+ has_many :coupon_redemptions, :conditions => "coupon_redemptions.expired_on IS NULL", :class_name => "CouponRedemption", :foreign_key => :subscription_id, :dependent => :destroy
18
+ has_many :coupons, :through => :coupon_redemptions, :conditions => "coupon_redemptions.expired_on IS NULL"
19
+
20
+ # Auditing
21
+ has_many :transactions, :class_name => "AccountTransaction", :foreign_key => :subscription_id
22
+
23
+ scope :paid, includes(:subscription_plan).where("subscription_plans.rate_cents > 0")
24
+ scope :due, lambda {
25
+ where(['paid_through <= ?', Date.today]) # could use the concept of a next retry date
26
+ }
27
+ scope :expired, lambda {
28
+ where(['expire_on >= paid_through AND expire_on <= ?', Date.today])
29
+ }
30
+
31
+ before_validation :set_paid_through
32
+ before_validation :set_started_on
33
+ before_save :store_credit_card_offsite
34
+ before_save :discard_credit_card_unless_paid
35
+ before_destroy :cancel_in_remote_system
36
+
37
+ after_create :audit_create
38
+ after_update :audit_update
39
+ after_destroy :audit_destroy
40
+
41
+ validates_presence_of :subscribable
42
+ validates_associated :subscribable
43
+ validates_presence_of :subscription_plan
44
+ validates_presence_of :paid_through, :if => :paid?
45
+ validates_presence_of :started_on
46
+ validates_presence_of :credit_card, :if => :store_credit_card?
47
+ validates_associated :credit_card#, :if => :store_credit_card?
48
+
49
+ validate :gateway_validates_credit_card
50
+ validate :coupon_exist
51
+ end
52
+ base.extend ClassMethods
53
+ end
54
+
55
+ def original_plan
56
+ @original_plan ||= ::SubscriptionPlan.find_by_id(subscription_plan_id_was) unless subscription_plan_id_was.nil?
57
+ end
58
+
59
+ def gateway
60
+ Freemium.gateway
61
+ end
62
+
63
+
64
+ protected
65
+
66
+ ##
67
+ ## Validations
68
+ ##
69
+
70
+ def gateway_validates_credit_card
71
+ if credit_card && credit_card.changed? && credit_card.valid?
72
+ response = gateway.validate(credit_card, credit_card.address)
73
+ unless response.success?
74
+ errors.add(:base, "Credit card could not be validated: #{response.message}")
75
+ end
76
+ end
77
+ end
78
+
79
+ ##
80
+ ## Callbacks
81
+ ##
82
+
83
+ def set_paid_through
84
+ if subscription_plan_id_changed? && !paid_through_changed?
85
+ if paid?
86
+ if new_record?
87
+ # paid + new subscription = in free trial
88
+ self.paid_through = Date.today + Freemium.days_free_trial
89
+ self.in_trial = true
90
+ elsif !self.in_trial? && self.original_plan && self.original_plan.paid?
91
+ # paid + not in trial + not new subscription + original sub was paid = calculate and credit for remaining value
92
+ value = self.remaining_value(original_plan)
93
+ self.paid_through = Date.today
94
+ self.credit(value)
95
+ else
96
+ # otherwise payment is due today
97
+ self.paid_through = Date.today
98
+ self.in_trial = false
99
+ end
100
+ else
101
+ # free plans don't pay
102
+ self.paid_through = nil
103
+ end
104
+ end
105
+ true
106
+ end
107
+
108
+ def set_started_on
109
+ self.started_on = Date.today if subscription_plan_id_changed?
110
+ end
111
+
112
+ # Simple assignment of a credit card. Note that this may not be
113
+ # useful for your particular situation, especially if you need
114
+ # to simultaneously set up automated recurrences.
115
+ #
116
+ # Because of the third-party interaction with the gateway, you
117
+ # need to be careful to only use this method when you expect to
118
+ # be able to save the record successfully. Otherwise you may end
119
+ # up storing a credit card in the gateway and then losing the key.
120
+ #
121
+ # NOTE: Support for updating an address could easily be added
122
+ # with an "address" property on the credit card.
123
+ def store_credit_card_offsite
124
+ if credit_card && credit_card.changed? && credit_card.valid?
125
+ response = billing_key ? gateway.update(billing_key, credit_card, credit_card.address) : gateway.store(credit_card, credit_card.address)
126
+ raise Freemium::CreditCardStorageError.new(response.message) unless response.success?
127
+ self.billing_key = response.billing_key
128
+ self.expire_on = nil
129
+ self.credit_card.reload # to prevent needless subsequent store() calls
130
+ end
131
+ end
132
+
133
+ def discard_credit_card_unless_paid
134
+ unless store_credit_card?
135
+ destroy_credit_card
136
+ end
137
+ end
138
+
139
+ def destroy_credit_card
140
+ credit_card.destroy if credit_card
141
+ cancel_in_remote_system
142
+ end
143
+
144
+ def cancel_in_remote_system
145
+ if billing_key
146
+ gateway.cancel(self.billing_key)
147
+ self.billing_key = nil
148
+ end
149
+ end
150
+
151
+ ##
152
+ ## Callbacks :: Auditing
153
+ ##
154
+
155
+ def audit_create
156
+ ::SubscriptionChange.create(:reason => "new",
157
+ :subscribable => self.subscribable,
158
+ :new_subscription_plan_id => self.subscription_plan_id,
159
+ :new_rate => self.rate,
160
+ :original_rate => Money.empty)
161
+ end
162
+
163
+ def audit_update
164
+ if self.subscription_plan_id_changed?
165
+ return if self.original_plan.nil?
166
+ reason = self.original_plan.rate > self.subscription_plan.rate ? (self.expired? ? "expiration" : "downgrade") : "upgrade"
167
+ ::SubscriptionChange.create(:reason => reason,
168
+ :subscribable => self.subscribable,
169
+ :original_subscription_plan_id => self.original_plan.id,
170
+ :original_rate => self.rate(:plan => self.original_plan),
171
+ :new_subscription_plan_id => self.subscription_plan.id,
172
+ :new_rate => self.rate)
173
+ end
174
+ end
175
+
176
+ def audit_destroy
177
+ ::SubscriptionChange.create(:reason => "cancellation",
178
+ :subscribable => self.subscribable,
179
+ :original_subscription_plan_id => self.subscription_plan_id,
180
+ :original_rate => self.rate,
181
+ :new_rate => Money.empty)
182
+ end
183
+
184
+ public
185
+
186
+ ##
187
+ ## Class Methods
188
+ ##
189
+
190
+ module ClassMethods
191
+ # expires all subscriptions that have been pastdue for too long (accounting for grace)
192
+ def expire
193
+ self.expired.select{|s| s.paid?}.each(&:expire!)
194
+ end
195
+ end
196
+
197
+ ##
198
+ ## Rate
199
+ ##
200
+
201
+ def rate(options = {})
202
+ options = {:date => Date.today, :plan => self.subscription_plan}.merge(options)
203
+
204
+ return nil unless options[:plan]
205
+ value = options[:plan].rate
206
+ value = self.coupon(options[:date]).discount(value) if self.coupon(options[:date])
207
+ value
208
+ end
209
+
210
+ def paid?
211
+ return false unless rate
212
+ rate.cents > 0
213
+ end
214
+
215
+ # Allow for more complex logic to decide if a card should be stored
216
+ def store_credit_card?
217
+ paid?
218
+ end
219
+
220
+ ##
221
+ ## Coupon Redemption
222
+ ##
223
+
224
+ def coupon_key=(coupon_key)
225
+ @coupon_key = coupon_key ? coupon_key.downcase : nil
226
+ self.coupon = ::Coupon.find_by_redemption_key(@coupon_key) unless @coupon_key.blank?
227
+ end
228
+
229
+ def coupon_exist
230
+ self.errors.add :coupon, "could not be found for '#{@coupon_key}'" if !@coupon_key.blank? && ::Coupon.find_by_redemption_key(@coupon_key).nil?
231
+ end
232
+
233
+ def coupon=(coupon)
234
+ if coupon
235
+ s = ::CouponRedemption.new(:subscription => self, :coupon => coupon)
236
+ coupon_redemptions << s
237
+ end
238
+ end
239
+
240
+ def coupon(date = Date.today)
241
+ coupon_redemption(date).coupon rescue nil
242
+ end
243
+
244
+ def coupon_redemption(date = Date.today)
245
+ return nil if coupon_redemptions.empty?
246
+ active_coupons = coupon_redemptions.select{|c| c.active?(date)}
247
+ return nil if active_coupons.empty?
248
+ active_coupons.sort_by{|c| c.coupon.discount_percentage }.reverse.first
249
+ end
250
+
251
+ ##
252
+ ## Remaining Time
253
+ ##
254
+
255
+ # returns the value of the time between now and paid_through.
256
+ # will optionally interpret the time according to a certain subscription plan.
257
+ def remaining_value(plan = self.subscription_plan)
258
+ self.daily_rate(:plan => plan) * remaining_days
259
+ end
260
+
261
+ # if paid through today, returns zero
262
+ def remaining_days
263
+ (self.paid_through - Date.today)
264
+ end
265
+
266
+ ##
267
+ ## Grace Period
268
+ ##
269
+
270
+ # if under grace through today, returns zero
271
+ def remaining_days_of_grace
272
+ (self.expire_on - Date.today - 1).to_i
273
+ end
274
+
275
+ def in_grace?
276
+ remaining_days < 0 and not expired?
277
+ end
278
+
279
+ ##
280
+ ## Expiration
281
+ ##
282
+
283
+ # sets the expiration for the subscription based on today and the configured grace period.
284
+ def expire_after_grace!(transaction = nil)
285
+ return unless self.expire_on.nil? # You only set this once subsequent failed transactions shouldn't affect expiration
286
+ self.expire_on = [Date.today, paid_through].max + Freemium.days_grace
287
+ transaction.message = "now set to expire on #{self.expire_on}" if transaction
288
+ Freemium.mailer.expiration_warning(self).deliver
289
+ transaction.save! if transaction
290
+ save!
291
+ end
292
+
293
+ # sends an expiration email, then downgrades to a free plan
294
+ def expire!
295
+ Freemium.mailer.expiration_notice(self).deliver
296
+ # downgrade to a free plan
297
+ self.expire_on = Date.today
298
+ self.subscription_plan = Freemium.expired_plan if Freemium.expired_plan
299
+ self.destroy_credit_card
300
+ self.save!
301
+ end
302
+
303
+ def expired?
304
+ expire_on and expire_on <= Date.today
305
+ end
306
+
307
+ ##
308
+ ## Receiving More Money
309
+ ##
310
+
311
+ # receives payment and saves the record
312
+ def receive_payment!(transaction)
313
+ receive_payment(transaction)
314
+ transaction.save!
315
+ self.save!
316
+ end
317
+
318
+ # extends the paid_through period according to how much money was received.
319
+ # when possible, avoids the days-per-month problem by checking if the money
320
+ # received is a multiple of the plan's rate.
321
+ #
322
+ # really, i expect the case where the received payment does not match the
323
+ # subscription plan's rate to be very much an edge case.
324
+ def receive_payment(transaction)
325
+ self.credit(transaction.amount)
326
+ self.save!
327
+ transaction.subscription.reload # reloaded to that the paid_through date is correct
328
+ transaction.message = "now paid through #{self.paid_through}"
329
+
330
+ begin
331
+ Freemium.mailer.invoice(transaction).deliver
332
+ rescue => e
333
+ transaction.message = "error sending invoice: #{e}"
334
+ end
335
+ end
336
+
337
+ def credit(amount)
338
+ self.paid_through = if amount.cents % rate.cents == 0
339
+ self.paid_through + (amount.cents / rate.cents).months
340
+ else
341
+ self.paid_through + (amount.cents / daily_rate.cents).days
342
+ end
343
+
344
+ # if they've paid again, then reset expiration
345
+ self.expire_on = nil
346
+ self.in_trial = false
347
+ end
348
+
349
+ end
350
+ end
@@ -0,0 +1,20 @@
1
+ module Freemium
2
+ module SubscriptionChange
3
+
4
+ def self.included(base)
5
+ base.class_eval do
6
+ belongs_to :subscribable, :polymorphic => true
7
+
8
+ belongs_to :original_subscription_plan, :class_name => "SubscriptionPlan"
9
+ belongs_to :new_subscription_plan, :class_name => "SubscriptionPlan"
10
+
11
+ composed_of :new_rate, :class_name => 'Money', :mapping => [ %w(new_rate_cents cents) ], :allow_nil => true
12
+ composed_of :original_rate, :class_name => 'Money', :mapping => [ %w(original_rate_cents cents) ], :allow_nil => true
13
+
14
+ validates_presence_of :reason
15
+ validates_inclusion_of :reason, :in => %w(new upgrade downgrade expiration cancellation)
16
+ end
17
+ end
18
+
19
+ end
20
+ end
@@ -0,0 +1,4 @@
1
+ <% @transactions.each do |subscription, events| %>
2
+ subscription #<%= subscription.id %> (billing key <%= subscription.billing_key %>)
3
+ <%= events.collect{|e| "- #{e.to_s}"}.join("\n") %>
4
+ <% end %>
@@ -0,0 +1 @@
1
+ Your subscription has expired.
@@ -0,0 +1 @@
1
+ We were unable to process your payment, and your subscription is set to expire in <%= @subscription.remaining_days_of_grace %> days. Please contact us to correct this.
@@ -0,0 +1,5 @@
1
+ Thanks for paying!
2
+
3
+ Your Plan: <%= @subscription.subscription_plan.name %> (<%= @subscription.subscription_plan.rate %> / month)
4
+ Paid: <%= @amount %>
5
+ Paid Through: <%= @subscription.paid_through.to_s %>
@@ -0,0 +1,36 @@
1
+ class SubscriptionMailer < ActionMailer::Base
2
+ prepend_view_path(File.dirname(__FILE__))
3
+
4
+ default :from => 'billing@example.com',
5
+ :return_path => 'no-reply@example.com'
6
+
7
+ def invoice(transaction)
8
+ @amount = transaction.amount
9
+ @subscription = transaction.subscription
10
+ mail(:to => transaction.subscription.subscribable.email,
11
+ :bcc => Freemium.admin_report_recipients,
12
+ :subject => "Your invoice")
13
+ end
14
+
15
+ def expiration_warning(subscription)
16
+ @subscription = subscription
17
+ mail(:to => subscription.subscribable.email,
18
+ :bcc => Freemium.admin_report_recipients,
19
+ :subject => "Your subscription is set to expire")
20
+ end
21
+
22
+ def expiration_notice(subscription)
23
+ @subscription = subscription
24
+ mail(:to => subscription.subscribable.email,
25
+ :bcc => Freemium.admin_report_recipients,
26
+ :subject => "Your subscription has expired")
27
+ end
28
+
29
+ def admin_report(transactions)
30
+ @amount_charged = transactions.select{|t| t.success?}.collect{|t| t.amount}.sum
31
+ @transactions = transactions
32
+ @amount_charged = @amount_charged
33
+ mail(:to => Freemium.admin_report_recipients,
34
+ :subject => "Billing report (#{@amount_charged} charged)")
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ # == Attributes
2
+ # subscriptions: all subscriptions for the plan
3
+ # rate_cents: how much this plan costs, in cents
4
+ # rate: how much this plan costs, in Money
5
+ # yearly: whether this plan cycles yearly or monthly
6
+ #
7
+ module Freemium
8
+ module SubscriptionPlan
9
+ include Rates
10
+
11
+ def self.included(base)
12
+ base.class_eval do
13
+ # yes, subscriptions.subscription_plan_id may not be null, but
14
+ # this at least makes the delete not happen if there are any active.
15
+ has_many :subscriptions, :dependent => :nullify, :class_name => "Subscription", :foreign_key => :subscription_plan_id
16
+ has_and_belongs_to_many :coupons, :class_name => "SubscriptionPlan",
17
+ :join_table => :coupons_subscription_plans, :foreign_key => :subscription_plan_id, :association_foreign_key => :coupon_id
18
+
19
+ composed_of :rate, :class_name => 'Money', :mapping => [ %w(rate_cents cents) ], :allow_nil => true
20
+
21
+ validates_uniqueness_of :redemption_key, :allow_nil => true, :allow_blank => true
22
+ validates_presence_of :name
23
+ validates_presence_of :rate_cents
24
+ end
25
+ end
26
+
27
+ def features
28
+ Freemium::FeatureSet.find(self.feature_set_id)
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,15 @@
1
+ module Freemium
2
+ module Transaction
3
+
4
+ def self.included(base)
5
+ base.class_eval do
6
+ scope :since, lambda { |time| where(["created_at >= ?", time]) }
7
+
8
+ belongs_to :subscription, :class_name => "Subscription"
9
+
10
+ composed_of :amount, :class_name => 'Money', :mapping => [ %w(amount_cents cents) ], :allow_nil => true
11
+ end
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module Freemium
2
+ VERSION = "0.0.1"
3
+ end
data/lib/freemium.rb ADDED
@@ -0,0 +1,75 @@
1
+ require 'rails'
2
+
3
+ require 'money'
4
+ require "freemium/version"
5
+ require 'freemium/address'
6
+ require 'freemium/coupon'
7
+ require 'freemium/coupon_redemption'
8
+ require 'freemium/credit_card'
9
+ require 'freemium/feature_set'
10
+ require 'freemium/manual_billing'
11
+ require 'freemium/rates'
12
+ require 'freemium/recurring_billing'
13
+ require 'freemium/response'
14
+ require 'freemium/subscription'
15
+ require 'freemium/subscription_change'
16
+ require 'freemium/subscription_plan'
17
+ require 'freemium/transaction'
18
+ require 'freemium/gateways/base'
19
+ require 'freemium/gateways/brain_tree'
20
+ require 'freemium/gateways/test'
21
+ require 'freemium/subscription_mailer'
22
+
23
+ module Freemium
24
+ class CreditCardStorageError < RuntimeError; end
25
+
26
+ # Lets you configure which ActionMailer class contains appropriate
27
+ # mailings for invoices, expiration warnings, and expiration notices.
28
+ # You'll probably want to create your own, based on lib/subscription_mailer.rb.
29
+ mattr_accessor :mailer
30
+ @@mailer = SubscriptionMailer
31
+
32
+ # The gateway of choice. Default gateway is a stubbed testing gateway.
33
+ mattr_accessor :gateway
34
+ @@gateway = Freemium::Gateways::Test.new
35
+
36
+ # You need to specify whether Freemium or your gateway's ARB module will control
37
+ # the billing process. If your gateway's ARB controls the billing process, then
38
+ # Freemium will simply try and keep up-to-date on transactions.
39
+ def self.billing_handler=(val)
40
+ case val
41
+ when :manual then ::Subscription.send(:include, Freemium::ManualBilling)
42
+ when :gateway then ::Subscription.send(:include, Freemium::RecurringBilling)
43
+ else
44
+ raise "unknown billing_handler: #{val}"
45
+ end
46
+ end
47
+
48
+ # How many days to keep an account active after it fails to pay.
49
+ mattr_accessor :days_grace
50
+ @@days_grace = 3
51
+
52
+ # How many days in an initial free trial?
53
+ mattr_accessor :days_free_trial
54
+ @@days_free_trial = 0
55
+
56
+ # What plan to assign to subscriptions that have expired. May be nil.
57
+ mattr_writer :expired_plan
58
+ def self.expired_plan
59
+ @@expired_plan ||= (::SubscriptionPlan.find_by_redemption_key(expired_plan_key.to_s) if expired_plan_key)
60
+ end
61
+
62
+ # It's easier to assign a plan by it's key (so you don't get errors before you run migrations)
63
+ # we will reset subscription_plan when we change the key
64
+ mattr_reader :expired_plan_key
65
+ def self.expired_plan_key=(key)
66
+ @@expired_plan_key = key
67
+ @@expired_plan = nil
68
+ end
69
+
70
+ # If you want to receive admin reports, enter an email (or list of emails) here.
71
+ # These will be bcc'd on all SubscriptionMailer emails, and will also receive the
72
+ # admin activity report.
73
+ mattr_accessor :admin_report_recipients
74
+
75
+ end