summaryrefslogtreecommitdiff
path: root/app/models/solidus_subscriptions/subscription.rb
blob: 5b166819d1c7604c48dff2ffcf65ac1da6caf29f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# frozen_string_literal: true

# The subscription class is responsible for grouping together the
# information required for the system to place a subscriptions order on
# behalf of a specific user.
module SolidusSubscriptions
  class Subscription < ApplicationRecord
    include Interval

    PROCESSING_STATES = [:pending, :failed, :success].freeze

    belongs_to :user, class_name: "::#{::Spree.user_class}"
    has_many :line_items, class_name: 'SolidusSubscriptions::LineItem', inverse_of: :subscription
    has_many :installments, class_name: 'SolidusSubscriptions::Installment'
    has_many :installment_details, class_name: 'SolidusSubscriptions::InstallmentDetail', through: :installments, source: :details
    has_many :events, class_name: 'SolidusSubscriptions::SubscriptionEvent'
    has_many :orders, class_name: '::Spree::Order', inverse_of: :subscription
    belongs_to :store, class_name: '::Spree::Store'
    belongs_to :shipping_address, class_name: '::Spree::Address', optional: true
    belongs_to :billing_address, class_name: '::Spree::Address', optional: true
    belongs_to :payment_method, class_name: '::Spree::PaymentMethod', optional: true
    belongs_to :payment_source, polymorphic: true, optional: true

    validates :user, presence: true
    validates :skip_count, :successive_skip_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
    validates :interval_length, numericality: { greater_than: 0 }
    validates :payment_method, presence: true, if: -> { payment_source }
    validates :payment_source, presence: true, if: -> { payment_method&.source_required? }
    validates :currency, inclusion: { in: ::Money::Currency.all.map(&:iso_code) }

    accepts_nested_attributes_for :shipping_address
    accepts_nested_attributes_for :billing_address
    accepts_nested_attributes_for :line_items, allow_destroy: true, reject_if: ->(p) { p[:quantity].blank? }

    before_validation :set_payment_method
    before_create :generate_guest_token
    after_create :emit_event_for_creation
    before_update :update_actionable_date_if_interval_changed
    after_update :emit_events_for_update

    # Find all subscriptions that are "actionable"; that is, ones that have an
    # actionable_date in the past and are not invalid or canceled.
    scope :actionable, (lambda do
      where("#{table_name}.actionable_date <= ?", Time.zone.today).
        where.not(state: ["canceled", "inactive"])
    end)

    # Find subscriptions based on their processing state. This state is not a
    # model attribute.
    #
    # @param state [Symbol] One of :pending, :success, or failed
    #
    # pending: New subscriptions, never been processed
    # failed: Subscriptions which failed to be processed on the last attempt
    # success: Subscriptions which were successfully processed on the last attempt
    scope :in_processing_state, (lambda do |state|
      case state.to_sym
      when :success
        fulfilled.joins(:installments)
      when :failed
        fulfilled_ids = fulfilled.pluck(:id)
        where.not(id: fulfilled_ids)
      when :pending
        includes(:installments).where(solidus_subscriptions_installments: { id: nil })
      else
        raise ArgumentError, "state must be one of: :success, :failed, :pending"
      end
    end)

    scope :fulfilled, (lambda do
      unfulfilled_ids = unfulfilled.pluck(:id)
      where.not(id: unfulfilled_ids)
    end)

    scope :unfulfilled, (lambda do
      joins(:installments).merge(Installment.unfulfilled)
    end)

    scope :with_default_payment_source, (lambda do
      where(payment_method: nil, payment_source: nil)
    end)

    def self.ransackable_scopes(_auth_object = nil)
      [:in_processing_state]
    end

    def self.processing_states
      PROCESSING_STATES
    end

    # The subscription state determines the behaviours around when it is
    # processed. Here is a brief description of the states and how they affect
    # the subscription.
    #
    # [active] Default state when created. Subscription can be processed
    # [canceled] The user has ended their subscription. Subscription will not
    #   be processed.
    # [pending_cancellation] The user has ended their subscription, but the
    #   conditions for canceling the subscription have not been met. Subscription
    #   will continue to be processed until the subscription is canceled and
    #   the conditions are met.
    # [inactive] The number of installments has been fulfilled. The subscription
    #   will no longer be processed
    state_machine :state, initial: :active do
      event :cancel do
        transition [:active, :pending_cancellation] => :canceled,
                   if: ->(subscription) { subscription.can_be_canceled? }

        transition active: :pending_cancellation
      end

      event :force_cancel do
        transition [:active, :pending_cancellation] => :canceled
        transition inactive: :inactive
        transition canceled: :canceled
      end

      after_transition to: :canceled, do: :advance_actionable_date

      event :deactivate do
        transition active: :inactive,
                   if: ->(subscription) { subscription.can_be_deactivated? }
      end

      event :activate do
        transition any - [:active] => :active
      end

      after_transition to: :active, do: :advance_actionable_date
      after_transition do: :emit_event_for_transition
    end

    # This method determines if a subscription may be canceled. Canceled
    # subcriptions will not be processed. By default subscriptions may always be
    # canceled. If this method is overridden to return false, the subscription
    # will be moved to the :pending_cancellation state until it is canceled
    # again and this condition is true.
    #
    # USE CASE: Subscriptions can only be canceled more than 10 days before they
    # are processed. Override this method to be:
    #
    # def can_be_canceled?
    #   return true if actionable_date.nil?
    #   (actionable_date - 10.days.from_now.to_date) > 0
    # end
    #
    # If a user cancels this subscription less than 10 days before it will
    # be processed the subscription will be bumped into the
    # :pending_cancellation state instead of being canceled. Subscriptions
    # pending cancellation will still be processed.
    def can_be_canceled?
      return true if actionable_date.nil?

      cancel_by = actionable_date - SolidusSubscriptions.configuration.minimum_cancellation_notice
      cancel_by.future? || cancel_by.today?
    end

    def skip(check_skip_limits: true)
      if check_skip_limits
        check_successive_skips_exceeded
        check_total_skips_exceeded

        return if errors.any?
      end

      increment(:skip_count)
      increment(:successive_skip_count)
      save!

      advance_actionable_date.tap do
        events.create!(event_type: 'subscription_skipped')
      end
    end

    # This method determines if a subscription can be deactivated. A deactivated
    # subscription will not be processed. By default a subscription can be
    # deactivated if the end_date defined on
    # the subscription is less than the current date
    # In this case the subscription has been fulfilled and
    # should not be processed again. Subscriptions without an end_date
    # value cannot be deactivated.
    def can_be_deactivated?
      active? && end_date && actionable_date && actionable_date > end_date
    end

    # Get the date after the current actionable_date where this subscription
    # will be actionable again
    #
    # @return [Date] The current actionable_date plus 1 interval. The next
    #   date after the current actionable_date this subscription will be
    #   eligible to be processed.
    def next_actionable_date
      return nil unless active?

      new_date = actionable_date || Time.zone.today

      new_date + interval
    end

    # Advance the actionable date to the next_actionable_date value. Will modify
    # the record.
    #
    # @return [Date] The next date after the current actionable_date this
    # subscription will be eligible to be processed.
    def advance_actionable_date
      update! actionable_date: next_actionable_date

      actionable_date
    end

    # The state of the last attempt to process an installment associated to
    # this subscription
    #
    # @return [String] pending if the no installments have been processed,
    #   failed if the last installment has not been fulfilled and, success
    #   if the last installment was fulfilled.
    def processing_state
      return 'pending' if installments.empty?

      installments.last.fulfilled? ? 'success' : 'failed'
    end

    def payment_method_to_use
      payment_method || user.wallet.default_wallet_payment_source&.payment_source&.payment_method
    end

    def payment_source_to_use
      if payment_method
        payment_source
      else
        user.wallet.default_wallet_payment_source&.payment_source
      end
    end

    def shipping_address_to_use
      shipping_address || user.ship_address
    end

    def billing_address_to_use
      billing_address || user.bill_address
    end

    def failing_since
      failing_details = installment_details.failed.order('solidus_subscriptions_installment_details.created_at ASC')

      last_successful_detail = installment_details
                               .succeeded
                               .order('solidus_subscriptions_installment_details.created_at DESC')
                               .first
      if last_successful_detail
        failing_details = failing_details.where(
          'solidus_subscriptions_installment_details.created_at > ?',
          last_successful_detail.created_at,
        )
      end

      first_failing_detail = failing_details.first

      first_failing_detail&.created_at
    end

    def maximum_reprocessing_time_reached?
      return false unless SolidusSubscriptions.configuration.maximum_reprocessing_time
      return false unless failing_since

      Time.zone.now > (failing_since + SolidusSubscriptions.configuration.maximum_reprocessing_time)
    end

    def actionable?
      actionable_date && actionable_date <= Time.zone.today && ["canceled", "inactive"].exclude?(state)
    end

    private

    def check_successive_skips_exceeded
      return unless SolidusSubscriptions.configuration.maximum_successive_skips

      if successive_skip_count >= SolidusSubscriptions.configuration.maximum_successive_skips
        errors.add(:successive_skip_count, :exceeded)
      end
    end

    def check_total_skips_exceeded
      return unless SolidusSubscriptions.configuration.maximum_total_skips

      if skip_count >= SolidusSubscriptions.configuration.maximum_total_skips
        errors.add(:skip_count, :exceeded)
      end
    end

    def update_actionable_date_if_interval_changed
      if persisted? && (interval_length_previously_changed? || interval_units_previously_changed?)
        base_date = if installments.any?
                      installments.last.created_at
                    else
                      created_at
                    end

        new_date = interval.since(base_date)

        if new_date < Time.zone.now
          # if the chosen base time plus the new interval is in the past, set
          # the actionable_date to be now to avoid confusion and possible
          # mis-processing.
          new_date = Time.zone.now
        end

        self.actionable_date = new_date
      end
    end

    def set_payment_method
      if payment_source
        self.payment_method = payment_source.payment_method
      end
    end

    def generate_guest_token
      self.guest_token ||= loop do
        random_token = SecureRandom.urlsafe_base64(nil, false)
        break random_token unless self.class.exists?(guest_token: random_token)
      end
    end

    def emit_event_for_creation
      ::Spree::Event.fire(
        'solidus_subscriptions.subscription_created',
        subscription: self,
      )
    end

    def emit_event_for_transition
      event_type = {
        active: 'subscription_activated',
        canceled: 'subscription_canceled',
        pending_cancellation: 'subscription_canceled',
        inactive: 'subscription_ended',
      }[state.to_sym]

      ::Spree::Event.fire(
        "solidus_subscriptions.#{event_type}",
        subscription: self,
      )
    end

    def emit_events_for_update
      if previous_changes.key?('interval_length') || previous_changes.key?('interval_units')
        ::Spree::Event.fire(
          'solidus_subscriptions.subscription_frequency_changed',
          subscription: self,
        )
      end

      if previous_changes.key?('shipping_address_id')
        ::Spree::Event.fire(
          'solidus_subscriptions.subscription_shipping_address_changed',
          subscription: self,
        )
      end

      if previous_changes.key?('billing_address_id')
        ::Spree::Event.fire(
          'solidus_subscriptions.subscription_billing_address_changed',
          subscription: self,
        )
      end

      if previous_changes.key?('payment_source_id') || previous_changes.key?('payment_source_type') || previous_changes.key?('payment_method_id')
        ::Spree::Event.fire(
          'solidus_subscriptions.subscription_payment_method_changed',
          subscription: self,
        )
      end
    end
  end
end