# == Attributes # subscribable: the model in your system that has the subscription. probably a User. # subscription_plan: which service plan this subscription is for. affects how payment is interpreted. # paid_through: when the subscription currently expires, assuming no further payment. for manual billing, this also determines when the next payment is due. # billing_key: the id for this user in the remote billing gateway. may not exist if user is on a free plan. # last_transaction_at: when the last gateway transaction was for this account. this is used by your gateway to find "new" transactions. # module Freemium module Subscription include Rates def self.included(base) base.class_eval do belongs_to :subscription_plan belongs_to :subscribable, polymorphic: true belongs_to :credit_card, dependent: :destroy has_many :coupon_redemptions, conditions: "coupon_redemptions.expired_on IS NULL", dependent: :destroy has_many :coupons, conditions: "coupon_redemptions.expired_on IS NULL", through: :coupon_redemptions has_many :transactions scope :paid, -> { includes(:subscription_plan).where("subscription_plans.rate_cents > 0") } scope :due, -> { where('paid_through <= ?', Date.today) } scope :expired, -> { where('expire_on >= paid_through AND expire_on <= ?', Date.today) } before_save :remove_coupon_if_no_longer_applies before_save :set_paid_through before_save :set_started_on before_save :store_credit_card_offsite before_save :discard_credit_card_unless_paid before_destroy :cancel_in_remote_system after_create :audit_create after_update :audit_update after_destroy :audit_destroy validates_presence_of :subscribable validates_associated :subscribable validates_presence_of :subscription_plan validates_presence_of :credit_card, :if => :store_credit_card? validates_associated :credit_card validate :gateway_validates_credit_card validate :coupon_exist end base.extend ClassMethods end def original_plan @original_plan ||= ::SubscriptionPlan.find_by_id(subscription_plan_id_was) unless subscription_plan_id_was.nil? end def gateway Freemium.configuration.gateway end protected ## ## Validations ## def gateway_validates_credit_card if credit_card && credit_card.changed? && credit_card.valid? response = gateway.validate(credit_card) unless response.success? errors.add(:base, "Credit card could not be validated: #{response.message}") end end end ## ## Callbacks ## def set_paid_through(force = false) if subscription_plan_id_changed? && (!paid_through_changed? || force) if paid? if new_record? # paid + new subscription = in free trial self.paid_through = Date.today + Freemium.configuration.days_free_trial self.in_trial = true elsif !self.in_trial? && self.original_plan && self.original_plan.paid? # paid + not in trial + not new subscription + original sub was paid = calculate and credit for remaining value value = self.remaining_value(original_plan) self.paid_through = Date.today self.credit(value) else # otherwise payment is due today self.paid_through = Date.today self.in_trial = false end else # free plans don't pay self.paid_through = nil end end true end def set_started_on self.started_on = Date.today if subscription_plan_id_changed? end def discard_credit_card_unless_paid unless store_credit_card? destroy_credit_card end end def destroy_credit_card credit_card.destroy if credit_card cancel_in_remote_system end def cancel_in_remote_system if billing_key gateway.cancel(self.billing_key) self.billing_key = nil end end ## ## Callbacks :: Auditing ## def audit_create ::SubscriptionChange.create(:reason => "new", :subscribable => self.subscribable, :new_subscription_plan_id => self.subscription_plan_id, :new_rate => self.rate, :original_rate => Money.empty) end def audit_update if self.subscription_plan_id_changed? return if self.original_plan.nil? reason = self.original_plan.rate > self.subscription_plan.rate ? (self.expired? ? "expiration" : "downgrade") : "upgrade" ::SubscriptionChange.create(:reason => reason, :subscribable => self.subscribable, :original_subscription_plan_id => self.original_plan.id, :original_rate => self.rate(:plan => self.original_plan), :new_subscription_plan_id => self.subscription_plan.id, :new_rate => self.rate) end end def remove_coupon_if_no_longer_applies return unless self.subscription_plan_id_changed? return unless self.coupon if !self.coupon.applies_to_plan?(self.subscription_plan) self.coupon_redemption.expire!(Date.yesterday) self.coupon_redemptions.reload self.set_paid_through(true) end end def audit_destroy ::SubscriptionChange.create(:reason => "cancellation", :subscribable => self.subscribable, :original_subscription_plan_id => self.subscription_plan_id, :original_rate => self.rate, :new_rate => Money.empty) end public ## ## Class Methods ## module ClassMethods # expires all subscriptions that have been pastdue for too long (accounting for grace) def find_expired self.expired.select{ |s| s.paid? } end def find_billable self.paid.due.select { |s| s.paid? } end end ## ## Rate ## def rate(options = {}) options = {:date => Date.today, :plan => self.subscription_plan}.merge(options) return nil unless options[:plan] value = options[:plan].rate value = self.coupon(options[:date]).discount(value) if self.coupon(options[:date]) value end # is this a paid plan? def paid? return false unless rate rate.cents > 0 end # Allow for more complex logic to decide if a card should be stored def store_credit_card? paid? end ## ## Coupon Redemption ## def coupon_key=(coupon_key) @coupon_key = coupon_key ? coupon_key.downcase : nil self.coupon = ::Coupon.find_by_redemption_key(@coupon_key) unless @coupon_key.blank? end def coupon_exist if !@coupon_key.blank? && ::Coupon.find_by_redemption_key(@coupon_key).nil? self.errors.add :coupon, "could not be found for '#{@coupon_key}'" end end def coupon=(coupon) if coupon s = ::CouponRedemption.new(:subscription => self, :coupon => coupon) coupon_redemptions << s end end def coupon(date = Date.today) coupon_redemption(date).coupon rescue nil end def coupon_redemption(date = Date.today) return nil if coupon_redemptions.empty? active_coupons = coupon_redemptions.select{|c| c.active?(date)} return nil if active_coupons.empty? active_coupons.sort_by{|c| c.coupon.discount_percentage }.reverse.first end ## ## Remaining Time ## # returns the value of the time between now and paid_through. # will optionally interpret the time according to a certain subscription plan. def remaining_value(plan = self.subscription_plan) self.daily_rate(:plan => plan) * remaining_days end # if paid through today, returns zero def remaining_days if self.paid_through (self.paid_through - Date.today) else 0 end end ## ## Grace Period ## # if under grace through today, returns zero def remaining_days_of_grace (self.expire_on - Date.today - 1).to_i end def in_grace? remaining_days < 0 and not expired? end ## ## Expiration ## # sets the expiration for the subscription based on today and the configured grace period. def expire_after_grace!(transaction = nil) return unless self.expire_on.nil? # You only set this once subsequent failed transactions shouldn't affect expiration self.expire_on = [Date.today, paid_through].max + Freemium.configuration.days_grace transaction.message = "now set to expire on #{self.expire_on}" if transaction Freemium.configuration.mailer.expiration_warning(self).deliver transaction.save! if transaction save! end # sends an expiration email, then downgrades to a free plan def expire! Freemium.configuration.mailer.expiration_notice(self).deliver # downgrade to a free plan self.expire_on = Date.today self.subscription_plan = Freemium.configuration.expired_plan if Freemium.configuration.expired_plan self.destroy_credit_card self.save! end def expired? expire_on and expire_on <= Date.today end ## ## Receiving More Money ## # receives payment and saves the record def receive_payment!(transaction) receive_payment(transaction) transaction.save! self.save! end # extends the paid_through period according to how much money was received. # when possible, avoids the days-per-month problem by checking if the money # received is a multiple of the plan's rate. # # really, i expect the case where the received payment does not match the # subscription plan's rate to be very much an edge case. def receive_payment(transaction) self.credit(transaction.amount) self.save! transaction.subscription.reload # reloaded to that the paid_through date is correct transaction.message = "now paid through #{self.paid_through}" begin Freemium.configuration.mailer.invoice(transaction).deliver rescue => e transaction.message = "error sending invoice: #{e}" end end def credit(amount) self.paid_through = if amount.cents % rate.cents == 0 self.paid_through + (amount.cents / rate.cents).months else self.paid_through + (amount.cents / daily_rate.cents).days end # if they've paid again, then reset expiration self.expire_on = nil self.in_trial = false end # Override if you need to charge something different than the rate (ex: yearly billing option) def installment_amount(options = {}) self.rate(options) end # charges this subscription. # assumes, of course, that this module is mixed in to the Subscription model def charge! # Save the transaction immediately @transaction = gateway.charge(billing_key, self.installment_amount) self.transactions << @transaction self.last_transaction_at = Time.now # TODO this could probably now be inferred from the list of transactions self.last_transaction_success = @transaction.success? self.save(:validate => false) begin if @transaction.success? receive_payment!(@transaction) elsif !@transaction.subscription.in_grace? expire_after_grace!(@transaction) end rescue end @transaction end def store_credit_card_offsite if credit_card && credit_card.changed? && credit_card.valid? response = billing_key ? gateway.update(billing_key, credit_card) : gateway.store(credit_card) raise Freemium::CreditCardStorageError.new(response.message) unless response.success? self.billing_key = response.billing_key self.expire_on = nil if last_transaction_success self.credit_card.reload # to prevent needless subsequent store() calls end end end end