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
|
# 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? }
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
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
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
|