solidus_subscriptions 1.0.0.rc1

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.
Files changed (204) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +41 -0
  3. data/.gem_release.yml +5 -0
  4. data/.github/stale.yml +17 -0
  5. data/.github_changelog_generator +2 -0
  6. data/.gitignore +20 -0
  7. data/.rspec +2 -0
  8. data/.rubocop.yml +12 -0
  9. data/.rubocop_todo.yml +86 -0
  10. data/CHANGELOG.md +191 -0
  11. data/Gemfile +33 -0
  12. data/LICENSE +26 -0
  13. data/README.md +221 -0
  14. data/Rakefile +6 -0
  15. data/app/assets/javascripts/spree/backend/solidus_subscriptions.js +1 -0
  16. data/app/assets/javascripts/spree/backend/solidus_subscriptions/edit_subscription_payment.js +32 -0
  17. data/app/controllers/solidus_subscriptions/api/v1/base_controller.rb +13 -0
  18. data/app/controllers/solidus_subscriptions/api/v1/line_items_controller.rb +48 -0
  19. data/app/controllers/solidus_subscriptions/api/v1/subscriptions_controller.rb +100 -0
  20. data/app/controllers/spree/admin/installments_controller.rb +25 -0
  21. data/app/controllers/spree/admin/subscription_events_controller.rb +37 -0
  22. data/app/controllers/spree/admin/subscription_orders_controller.rb +35 -0
  23. data/app/controllers/spree/admin/subscriptions_controller.rb +100 -0
  24. data/app/controllers/spree/admin/users/subscriptions_controller.rb +17 -0
  25. data/app/decorators/controllers/solidus_subscriptions/spree/api/line_items_controller/create_subscription_line_items.rb +36 -0
  26. data/app/decorators/controllers/solidus_subscriptions/spree/orders_controller/create_subscription_line_items.rb +35 -0
  27. data/app/decorators/models/solidus_subscriptions/spree/line_item/subscription_line_items_association.rb +26 -0
  28. data/app/decorators/models/solidus_subscriptions/spree/order/after_create.rb +19 -0
  29. data/app/decorators/models/solidus_subscriptions/spree/order/finalize_creates_subscriptions.rb +23 -0
  30. data/app/decorators/models/solidus_subscriptions/spree/order/installment_details_association.rb +15 -0
  31. data/app/decorators/models/solidus_subscriptions/spree/order/subscription_association.rb +15 -0
  32. data/app/decorators/models/solidus_subscriptions/spree/order/subscription_line_items_association.rb +19 -0
  33. data/app/decorators/models/solidus_subscriptions/spree/product/delegate_subscribable.rb +17 -0
  34. data/app/decorators/models/solidus_subscriptions/spree/user/have_many_subscriptions.rb +30 -0
  35. data/app/decorators/models/solidus_subscriptions/spree/variant/auto_delete_from_subscriptions.rb +20 -0
  36. data/app/decorators/models/solidus_subscriptions/spree/variant/variant_pretty_name.rb +17 -0
  37. data/app/decorators/models/solidus_subscriptions/spree/wallet_payment_source/report_default_change_to_subscriptions.rb +28 -0
  38. data/app/jobs/solidus_subscriptions/process_installment_job.rb +13 -0
  39. data/app/jobs/solidus_subscriptions/process_subscription_job.rb +31 -0
  40. data/app/models/solidus_subscriptions/installment.rb +135 -0
  41. data/app/models/solidus_subscriptions/installment_detail.rb +28 -0
  42. data/app/models/solidus_subscriptions/interval.rb +26 -0
  43. data/app/models/solidus_subscriptions/line_item.rb +42 -0
  44. data/app/models/solidus_subscriptions/promotion/rules/subscription_creation_order.rb +44 -0
  45. data/app/models/solidus_subscriptions/promotion/rules/subscription_installment_order.rb +31 -0
  46. data/app/models/solidus_subscriptions/subscription.rb +392 -0
  47. data/app/models/solidus_subscriptions/subscription_event.rb +11 -0
  48. data/app/overrides/views/admin_subscribable_product_checkbox.rb +8 -0
  49. data/app/overrides/views/admin_subscribable_variant_checkbox.rb +8 -0
  50. data/app/overrides/views/admin_subscriptions_menu_link.rb +10 -0
  51. data/app/overrides/views/admin_users_subscriptions_tab.rb +8 -0
  52. data/app/overrides/views/subscription_line_item_fields.rb +8 -0
  53. data/app/subscribers/solidus_subscriptions/churn_buster_subscriber.rb +39 -0
  54. data/app/subscribers/solidus_subscriptions/event_storage_subscriber.rb +64 -0
  55. data/app/views/spree/admin/installments/_state_pill.html.erb +8 -0
  56. data/app/views/spree/admin/installments/index.html.erb +42 -0
  57. data/app/views/spree/admin/products/_subscribable_checkbox.html.erb +8 -0
  58. data/app/views/spree/admin/promotions/rules/_subscription_creation_order.html.erb +0 -0
  59. data/app/views/spree/admin/promotions/rules/_subscription_installment_order.html.erb +0 -0
  60. data/app/views/spree/admin/shared/_subscription_actions.html.erb +35 -0
  61. data/app/views/spree/admin/shared/_subscription_breadcrumbs.html.erb +4 -0
  62. data/app/views/spree/admin/shared/_subscription_sidebar.html.erb +18 -0
  63. data/app/views/spree/admin/shared/_subscription_tab.html.erb +3 -0
  64. data/app/views/spree/admin/shared/_subscription_tabs.html.erb +18 -0
  65. data/app/views/spree/admin/subscription_events/_state_pill.html.erb +8 -0
  66. data/app/views/spree/admin/subscription_events/index.html.erb +42 -0
  67. data/app/views/spree/admin/subscription_orders/index.html.erb +93 -0
  68. data/app/views/spree/admin/subscriptions/_form.html.erb +150 -0
  69. data/app/views/spree/admin/subscriptions/_processing_state_pill.html.erb +9 -0
  70. data/app/views/spree/admin/subscriptions/_state_pill.html.erb +10 -0
  71. data/app/views/spree/admin/subscriptions/edit.html.erb +10 -0
  72. data/app/views/spree/admin/subscriptions/index.html.erb +176 -0
  73. data/app/views/spree/admin/subscriptions/new.html.erb +5 -0
  74. data/app/views/spree/admin/users/_subscription_tab.html.erb +5 -0
  75. data/app/views/spree/admin/users/subscriptions/index.html.erb +44 -0
  76. data/app/views/spree/admin/variants/_subscribable_checkbox.html.erb +6 -0
  77. data/app/views/spree/frontend/products/_subscription_line_item_fields.html.erb +30 -0
  78. data/bin/console +17 -0
  79. data/bin/rails +7 -0
  80. data/bin/rails-engine +13 -0
  81. data/bin/rails-sandbox +16 -0
  82. data/bin/rake +7 -0
  83. data/bin/sandbox +86 -0
  84. data/bin/setup +8 -0
  85. data/config/initializers/permission_sets.rb +11 -0
  86. data/config/initializers/subscribers.rb +9 -0
  87. data/config/locales/en.yml +129 -0
  88. data/config/routes.rb +34 -0
  89. data/db/migrate/20160825164850_create_solidus_subscriptions_subscriptions.rb +11 -0
  90. data/db/migrate/20160825173548_create_solidus_subscriptions_line_items.rb +17 -0
  91. data/db/migrate/20160825202248_create_solidus_subscriptions_installments.rb +23 -0
  92. data/db/migrate/20160825211202_create_solidus_subscriptions_installment_details.rb +22 -0
  93. data/db/migrate/20160825214240_add_subscribable_to_spree_variants.rb +5 -0
  94. data/db/migrate/20160829201653_change_subscription_line_items_installments_to_max_installments.rb +5 -0
  95. data/db/migrate/20160902220242_remove_state_from_solidus_susbscriptions_installment_details.rb +5 -0
  96. data/db/migrate/20160902220604_add_successful_to_solidus_subscriptions_installment_details.rb +5 -0
  97. data/db/migrate/20160902221218_add_message_to_solidus_subscriptions_installment_details.rb +5 -0
  98. data/db/migrate/20160922164101_add_interval_length_and_units_to_subscription_line_items.rb +8 -0
  99. data/db/migrate/20161006191003_add_skip_count_to_solidus_subscriptions_subscriptions.rb +5 -0
  100. data/db/migrate/20161006191127_add_successive_skip_count_to_solidus_subscriptions_subscriptions.rb +5 -0
  101. data/db/migrate/20161014212649_allow_spree_line_item_id_to_be_null.rb +5 -0
  102. data/db/migrate/20161017155749_add_order_id_to_solidus_subscriptions_installment_details.rb +6 -0
  103. data/db/migrate/20161017175509_remove_order_id_from_solidus_subscriptions_installments.rb +5 -0
  104. data/db/migrate/20161017201944_add_subscription_order_to_spree_orders.rb +5 -0
  105. data/db/migrate/20161221155142_add_store_to_solidus_subscriptions_subscriptions.rb +6 -0
  106. data/db/migrate/20161223152905_add_address_id_to_solidus_subscriptions_subscriptions.rb +7 -0
  107. data/db/migrate/20170106224713_change_line_item_max_installments_to_end_date.rb +6 -0
  108. data/db/migrate/20170111224458_change_subscription_actionable_date_to_datetime.rb +5 -0
  109. data/db/migrate/20170111232801_change_inteval_actionable_date_to_datetime.rb +5 -0
  110. data/db/migrate/20170112012407_add_config_options_to_subscriptions.rb +7 -0
  111. data/db/migrate/20200617102749_add_billing_address_to_subscriptions.rb +11 -0
  112. data/db/migrate/20200617155042_add_payment_source_to_subscriptions.rb +6 -0
  113. data/db/migrate/20200618092951_add_payment_method_to_subscriptions.rb +11 -0
  114. data/db/migrate/20200730101242_create_solidus_subscriptions_subscription_events.rb +22 -0
  115. data/db/migrate/20200917072152_add_subscription_reference_to_orders.rb +11 -0
  116. data/db/migrate/20201007140032_add_guest_token_to_subscriptions.rb +6 -0
  117. data/db/migrate/20201123171026_change_actionable_date_to_date.rb +15 -0
  118. data/db/migrate/20210205140422_add_currency_to_subscription.rb +5 -0
  119. data/db/migrate/20210323165714_update_promotion_rule_names.rb +22 -0
  120. data/lib/generators/solidus_subscriptions/install/install_generator.rb +32 -0
  121. data/lib/generators/solidus_subscriptions/install/templates/initializer.rb +99 -0
  122. data/lib/solidus_subscriptions.rb +49 -0
  123. data/lib/solidus_subscriptions/checkout.rb +74 -0
  124. data/lib/solidus_subscriptions/churn_buster/client.rb +48 -0
  125. data/lib/solidus_subscriptions/churn_buster/order_serializer.rb +19 -0
  126. data/lib/solidus_subscriptions/churn_buster/serializer.rb +23 -0
  127. data/lib/solidus_subscriptions/churn_buster/subscription_customer_serializer.rb +28 -0
  128. data/lib/solidus_subscriptions/churn_buster/subscription_payment_method_serializer.rb +37 -0
  129. data/lib/solidus_subscriptions/churn_buster/subscription_serializer.rb +17 -0
  130. data/lib/solidus_subscriptions/configuration.rb +84 -0
  131. data/lib/solidus_subscriptions/dispatcher/base.rb +18 -0
  132. data/lib/solidus_subscriptions/dispatcher/failure_dispatcher.rb +13 -0
  133. data/lib/solidus_subscriptions/dispatcher/out_of_stock_dispatcher.rb +11 -0
  134. data/lib/solidus_subscriptions/dispatcher/payment_failed_dispatcher.rb +19 -0
  135. data/lib/solidus_subscriptions/dispatcher/success_dispatcher.rb +17 -0
  136. data/lib/solidus_subscriptions/engine.rb +56 -0
  137. data/lib/solidus_subscriptions/permission_sets/default_customer.rb +19 -0
  138. data/lib/solidus_subscriptions/permission_sets/subscription_management.rb +12 -0
  139. data/lib/solidus_subscriptions/permitted_attributes.rb +20 -0
  140. data/lib/solidus_subscriptions/processor.rb +17 -0
  141. data/lib/solidus_subscriptions/subscription_generator.rb +77 -0
  142. data/lib/solidus_subscriptions/subscription_line_item_builder.rb +23 -0
  143. data/lib/solidus_subscriptions/testing_support/factories/installment_detail_factory.rb +11 -0
  144. data/lib/solidus_subscriptions/testing_support/factories/installment_factory.rb +29 -0
  145. data/lib/solidus_subscriptions/testing_support/factories/line_item_factory.rb +20 -0
  146. data/lib/solidus_subscriptions/testing_support/factories/spree/line_item_factory.rb +17 -0
  147. data/lib/solidus_subscriptions/testing_support/factories/spree/order_factory.rb +17 -0
  148. data/lib/solidus_subscriptions/testing_support/factories/spree_modification_factory.rb +10 -0
  149. data/lib/solidus_subscriptions/testing_support/factories/subscription_event_factory.rb +8 -0
  150. data/lib/solidus_subscriptions/testing_support/factories/subscription_factory.rb +56 -0
  151. data/lib/solidus_subscriptions/version.rb +5 -0
  152. data/lib/tasks/process_subscriptions.rake +8 -0
  153. data/reference/solidus_subscriptions.v1.yaml +290 -0
  154. data/solidus_subscriptions.gemspec +47 -0
  155. data/spec/controllers/spree/admin/subscriptions_controller_spec.rb +202 -0
  156. data/spec/controllers/spree/api/line_items_controller_spec.rb +103 -0
  157. data/spec/controllers/spree/api/orders_controller_spec.rb +57 -0
  158. data/spec/controllers/spree/api/users_controller_spec.rb +48 -0
  159. data/spec/decorators/controllers/solidus_subscriptions/spree/orders_controller/create_subscription_line_items_spec.rb +80 -0
  160. data/spec/decorators/models/solidus_subscriptions/spree/line_item/subscription_line_items_association_spec.rb +10 -0
  161. data/spec/decorators/models/solidus_subscriptions/spree/order/finalize_creates_subscriptions_spec.rb +32 -0
  162. data/spec/decorators/models/solidus_subscriptions/spree/order/installment_details_association_spec.rb +9 -0
  163. data/spec/decorators/models/solidus_subscriptions/spree/order/subscription_line_items_association_spec.rb +9 -0
  164. data/spec/decorators/models/solidus_subscriptions/spree/user/have_many_subscriptions_spec.rb +22 -0
  165. data/spec/decorators/models/solidus_subscriptions/spree/variant/auto_delete_from_subscriptions_spec.rb +25 -0
  166. data/spec/features/admin/subscription_orders_spec.rb +35 -0
  167. data/spec/features/admin/subscriptions_spec.rb +63 -0
  168. data/spec/features/admin_users_subscription_tabs_spec.rb +61 -0
  169. data/spec/fixtures/cassettes/churn_buster.yml +229 -0
  170. data/spec/jobs/solidus_subscriptions/process_installment_job_spec.rb +38 -0
  171. data/spec/jobs/solidus_subscriptions/process_subscription_job_spec.rb +83 -0
  172. data/spec/lib/solidus_subscriptions/checkout_spec.rb +125 -0
  173. data/spec/lib/solidus_subscriptions/churn_buster/client_spec.rb +59 -0
  174. data/spec/lib/solidus_subscriptions/dispatcher/failure_dispatcher_spec.rb +29 -0
  175. data/spec/lib/solidus_subscriptions/dispatcher/out_of_stock_dispatcher_spec.rb +15 -0
  176. data/spec/lib/solidus_subscriptions/dispatcher/payment_failed_dispatcher_spec.rb +44 -0
  177. data/spec/lib/solidus_subscriptions/dispatcher/success_dispatcher_spec.rb +30 -0
  178. data/spec/lib/solidus_subscriptions/permission_sets/default_customer_spec.rb +95 -0
  179. data/spec/lib/solidus_subscriptions/permission_sets/subscription_management_spec.rb +26 -0
  180. data/spec/lib/solidus_subscriptions/processor_spec.rb +34 -0
  181. data/spec/lib/solidus_subscriptions/promotion/rules/subscription_creation_order_spec.rb +57 -0
  182. data/spec/lib/solidus_subscriptions/promotion/rules/subscription_installment_order_spec.rb +39 -0
  183. data/spec/lib/solidus_subscriptions/subscription_generator_spec.rb +83 -0
  184. data/spec/lib/solidus_subscriptions_spec.rb +30 -0
  185. data/spec/models/solidus_subscriptions/installment_detail_spec.rb +23 -0
  186. data/spec/models/solidus_subscriptions/installment_spec.rb +201 -0
  187. data/spec/models/solidus_subscriptions/line_item_spec.rb +29 -0
  188. data/spec/models/solidus_subscriptions/subscription_spec.rb +814 -0
  189. data/spec/models/spree/variant_spec.rb +16 -0
  190. data/spec/models/spree/wallet_payment_source_spec.rb +20 -0
  191. data/spec/requests/api/v1/line_items_spec.rb +116 -0
  192. data/spec/requests/api/v1/subscriptions_spec.rb +255 -0
  193. data/spec/spec_helper.rb +30 -0
  194. data/spec/subscribers/solidus_subscriptions/churn_buster_subscriber_spec.rb +76 -0
  195. data/spec/support/active_model_mocks.rb +1 -0
  196. data/spec/support/cancancan.rb +1 -0
  197. data/spec/support/factories.rb +1 -0
  198. data/spec/support/helpers/checkout_infrastructure.rb +18 -0
  199. data/spec/support/helpers/config.rb +13 -0
  200. data/spec/support/shoulda.rb +7 -0
  201. data/spec/support/timecop.rb +1 -0
  202. data/spec/support/vcr.rb +10 -0
  203. data/spec/support/version_cake.rb +8 -0
  204. metadata +498 -0
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusSubscriptions
4
+ module Spree
5
+ module Variant
6
+ module AutoDeleteFromSubscriptions
7
+ def self.prepended(base)
8
+ base.after_discard(:remove_from_subscriptions)
9
+ base.after_destroy(:remove_from_subscriptions)
10
+ end
11
+
12
+ def remove_from_subscriptions
13
+ SolidusSubscriptions::LineItem.where(subscribable: self).delete_all
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ Spree::Variant.prepend(SolidusSubscriptions::Spree::Variant::AutoDeleteFromSubscriptions)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusSubscriptions
4
+ module Spree
5
+ module Variant
6
+ module VariantPrettyName
7
+ def pretty_name
8
+ name = product.name
9
+ name += " - #{options_text}" if options_text.present?
10
+ name
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ Spree::Variant.prepend(SolidusSubscriptions::Spree::Variant::VariantPrettyName)
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusSubscriptions
4
+ module Spree
5
+ module WalletPaymentSource
6
+ module ReportDefaultChangeToSubscriptions
7
+ def self.prepended(base)
8
+ base.after_save :report_default_change_to_subscriptions
9
+ end
10
+
11
+ private
12
+
13
+ def report_default_change_to_subscriptions
14
+ return if !previous_changes.key?('default') || !default?
15
+
16
+ user.subscriptions.with_default_payment_source.each do |subscription|
17
+ ::Spree::Event.fire(
18
+ 'solidus_subscriptions.subscription_payment_method_changed',
19
+ subscription: subscription,
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ Spree::WalletPaymentSource.prepend(SolidusSubscriptions::Spree::WalletPaymentSource::ReportDefaultChangeToSubscriptions)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusSubscriptions
4
+ class ProcessInstallmentJob < ApplicationJob
5
+ queue_as { SolidusSubscriptions.configuration.processing_queue }
6
+
7
+ def perform(installment)
8
+ Checkout.new(installment).process
9
+ rescue StandardError => e
10
+ SolidusSubscriptions.configuration.processing_error_handler&.call(e)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusSubscriptions
4
+ class ProcessSubscriptionJob < ApplicationJob
5
+ queue_as { SolidusSubscriptions.configuration.processing_queue }
6
+
7
+ def perform(subscription)
8
+ ActiveRecord::Base.transaction do
9
+ if SolidusSubscriptions.configuration.clear_past_installments
10
+ subscription.installments.unfulfilled.actionable.each do |installment|
11
+ installment.update!(actionable_date: nil)
12
+ end
13
+ end
14
+
15
+ if subscription.actionable?
16
+ subscription.successive_skip_count = 0
17
+ subscription.advance_actionable_date
18
+
19
+ subscription.installments.create!(actionable_date: Time.zone.now)
20
+ end
21
+
22
+ subscription.cancel! if subscription.pending_cancellation?
23
+ subscription.deactivate! if subscription.can_be_deactivated?
24
+
25
+ subscription.installments.actionable.find_each do |installment|
26
+ SolidusSubscriptions::ProcessInstallmentJob.perform_later(installment)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class represents a single iteration of a subscription. It is fulfilled
4
+ # by a completed order and maintains an association which tracks all attempts
5
+ # successful or otherwise at fulfilling this installment
6
+ module SolidusSubscriptions
7
+ class Installment < ApplicationRecord
8
+ has_many :details, class_name: 'SolidusSubscriptions::InstallmentDetail'
9
+ belongs_to(
10
+ :subscription,
11
+ class_name: 'SolidusSubscriptions::Subscription',
12
+ inverse_of: :installments,
13
+ )
14
+
15
+ validates :subscription, presence: true
16
+
17
+ scope :fulfilled, (lambda do
18
+ joins(:details).where(InstallmentDetail.table_name => { success: true }).distinct
19
+ end)
20
+
21
+ scope :unfulfilled, (lambda do
22
+ where.not(id: Installment.fulfilled).distinct
23
+ end)
24
+
25
+ scope :with_active_subscription, (lambda do
26
+ joins(:subscription).where.not(Subscription.table_name => { state: "canceled" })
27
+ end)
28
+
29
+ scope :actionable, (lambda do
30
+ unfulfilled.where("#{table_name}.actionable_date <= ?", Time.zone.today)
31
+ end)
32
+
33
+ # Mark this installment as out of stock.
34
+ #
35
+ # @return [SolidusSubscriptions::InstallmentDetail] The record of the failed
36
+ # processing attempt
37
+ def out_of_stock
38
+ advance_actionable_date!
39
+
40
+ details.create!(
41
+ success: false,
42
+ message: I18n.t('solidus_subscriptions.installment_details.out_of_stock')
43
+ )
44
+ end
45
+
46
+ # Mark this installment as a success
47
+ #
48
+ # @param order [Spree::Order] The order generated for this processing
49
+ # attempt
50
+ #
51
+ # @return [SolidusSubscriptions::InstallmentDetail] The record of the
52
+ # successful processing attempt
53
+ def success!(order)
54
+ update!(actionable_date: nil)
55
+
56
+ details.create!(
57
+ success: true,
58
+ order: order,
59
+ message: I18n.t('solidus_subscriptions.installment_details.success')
60
+ )
61
+ end
62
+
63
+ # Mark this installment as a failure
64
+ #
65
+ # @param order [Spree::Order] The order generated for this processing
66
+ # attempt
67
+ #
68
+ # @return [SolidusSubscriptions::InstallmentDetail] The record of the
69
+ # failed processing attempt
70
+ def failed!(order)
71
+ advance_actionable_date!
72
+
73
+ details.create!(
74
+ success: false,
75
+ order: order,
76
+ message: I18n.t('solidus_subscriptions.installment_details.failed')
77
+ )
78
+ end
79
+
80
+ # Does this installment still need to be fulfilled by a completed order
81
+ #
82
+ # @return [Boolean]
83
+ def unfulfilled?
84
+ !fulfilled?
85
+ end
86
+
87
+ # Had this installment been fulfilled by a completed order
88
+ #
89
+ # @return [Boolean]
90
+ def fulfilled?
91
+ details.exists?(success: true)
92
+ end
93
+
94
+ # Returns the state of this fulfillment
95
+ #
96
+ # @return [Symbol] :fulfilled/:unfulfilled
97
+ def state
98
+ fulfilled? ? :fulfilled : :unfulfilled
99
+ end
100
+
101
+ # Mark this installment as having a failed payment
102
+ #
103
+ # @param order [Spree::Order] The order generated for this processing
104
+ # attempt
105
+ #
106
+ # @return [SolidusSubscriptions::InstallmentDetail] The record of the
107
+ # failed processing attempt
108
+ def payment_failed!(order)
109
+ details.create!(
110
+ success: false,
111
+ order: order,
112
+ message: I18n.t('solidus_subscriptions.installment_details.payment_failed')
113
+ )
114
+
115
+ if subscription.maximum_reprocessing_time_reached? && !subscription.canceled?
116
+ subscription.force_cancel!
117
+ update!(actionable_date: nil)
118
+ else
119
+ advance_actionable_date!
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def advance_actionable_date!
126
+ update!(actionable_date: next_actionable_date)
127
+ end
128
+
129
+ def next_actionable_date
130
+ return if SolidusSubscriptions.configuration.reprocessing_interval.nil?
131
+
132
+ (DateTime.current + SolidusSubscriptions.configuration.reprocessing_interval).beginning_of_minute
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class represents a single attempt to fulfill an installment. It will
4
+ # indicate the result of that attempt.
5
+ module SolidusSubscriptions
6
+ class InstallmentDetail < ApplicationRecord
7
+ belongs_to(
8
+ :installment,
9
+ class_name: 'SolidusSubscriptions::Installment',
10
+ inverse_of: :details
11
+ )
12
+
13
+ belongs_to(:order, class_name: '::Spree::Order', optional: true)
14
+
15
+ validates :installment, presence: true
16
+ alias_attribute :successful, :success
17
+
18
+ scope :succeeded, -> { where success: true }
19
+ scope :failed, -> { where success: false }
20
+
21
+ # Was the attempt at fulfilling this installment a failure?
22
+ #
23
+ # @return [Boolean]
24
+ def failed?
25
+ !success
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module is intended to be included into any active record
4
+ # model which needs to be aware of how intervals are stored and
5
+ # calculated in the db.
6
+ #
7
+ # Base models must have the following fields: interval_length (integer) and interval_units (integer)
8
+ module SolidusSubscriptions
9
+ module Interval
10
+ def self.included(base)
11
+ base.enum interval_units: {
12
+ day: 0,
13
+ week: 1,
14
+ month: 2,
15
+ year: 3
16
+ }
17
+ end
18
+
19
+ # Calculates the number of seconds in the interval.
20
+ #
21
+ # @return [Integer] The number of seconds.
22
+ def interval
23
+ ActiveSupport::Duration.new(interval_length, { interval_units.pluralize.to_sym => interval_length })
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The LineItem class is responsible for associating Line items to subscriptions. # It tracks the following values:
4
+ #
5
+ # [Spree::LineItem] :spree_line_item The spree object which created this instance
6
+ #
7
+ # [SolidusSubscription::Subscription] :subscription The object responsible for
8
+ # grouping all information needed to create new subscription orders together
9
+ #
10
+ # [Integer] :subscribable_id The id of the object to be added to new subscription
11
+ # orders when they are placed
12
+ #
13
+ # [Integer] :quantity How many units of the subscribable should be included in
14
+ # future orders
15
+ #
16
+ # [Integer] :interval How often subscription orders should be placed
17
+ #
18
+ # [Integer] :installments How many subscription orders should be placed
19
+ module SolidusSubscriptions
20
+ class LineItem < ApplicationRecord
21
+ include Interval
22
+
23
+ belongs_to(
24
+ :spree_line_item,
25
+ class_name: '::Spree::LineItem',
26
+ inverse_of: :subscription_line_items,
27
+ optional: true,
28
+ )
29
+ has_one :order, through: :spree_line_item, class_name: '::Spree::Order'
30
+ belongs_to(
31
+ :subscription,
32
+ class_name: 'SolidusSubscriptions::Subscription',
33
+ inverse_of: :line_items,
34
+ optional: true
35
+ )
36
+ belongs_to :subscribable, class_name: "::#{SolidusSubscriptions.configuration.subscribable_class}"
37
+
38
+ validates :subscribable_id, presence: true
39
+ validates :quantity, numericality: { greater_than: 0 }
40
+ validates :interval_length, numericality: { greater_than: 0 }, unless: -> { subscription }
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusSubscriptions
4
+ module Promotion
5
+ module Rules
6
+ class SubscriptionCreationOrder < ::Spree::PromotionRule
7
+ # Promotion can be applied to an entire order. Will only be true
8
+ # for Spree::Order
9
+ #
10
+ # @param promotable [Object] Any object which could have this
11
+ # promotion rule applied to it.
12
+ #
13
+ # @return [Boolean]
14
+ def applicable?(promotable)
15
+ promotable.is_a? ::Spree::Order
16
+ end
17
+
18
+ # An order is eligible if it contains a line item with an associates
19
+ # subscription_line_item. This rule applies to order and so its eligibility
20
+ # will always be considered against an order. Will only return true for
21
+ # orders containing Spree::Line item with associated subscription_line_items
22
+ #
23
+ # @param order [Spree::Order] The order which could have this rule applied
24
+ # to it.
25
+ #
26
+ # @return [Boolean]
27
+ def eligible?(order, **_options)
28
+ order.subscription_line_items.any?
29
+ end
30
+
31
+ # Certain actions create adjustments on line items. In this case, only
32
+ # line items with associated subscription_line_items are eligible to be
33
+ # adjusted. Will only return true # if :line_item has an associated
34
+ # subscription.
35
+ #
36
+ # @param line_item [Spree::LineItem] The line item which could be adjusted
37
+ # by the promotion.
38
+ def actionable?(line_item)
39
+ line_item.subscription_line_items.present?
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusSubscriptions
4
+ module Promotion
5
+ module Rules
6
+ class SubscriptionInstallmentOrder < ::Spree::PromotionRule
7
+ # Promotion can be applied to an entire order. Will only be true
8
+ # for Spree::Order
9
+ #
10
+ # @param promotable [Object] Any object which could have this
11
+ # promotion rule applied to it.
12
+ #
13
+ # @return [Boolean]
14
+ def applicable?(promotable)
15
+ promotable.is_a? ::Spree::Order
16
+ end
17
+
18
+ # An order is eligible if it fulfills a subscription Installment. Will only
19
+ # return true if the order fulfills one or more Installments
20
+ #
21
+ # @param order [Spree::Order] The order which could have this rule applied
22
+ # to it.
23
+ #
24
+ # @return [Boolean]
25
+ def eligible?(order, **_options)
26
+ order.subscription_order?
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The subscription class is responsible for grouping together the
4
+ # information required for the system to place a subscriptions order on
5
+ # behalf of a specific user.
6
+ module SolidusSubscriptions
7
+ class Subscription < ApplicationRecord
8
+ include Interval
9
+
10
+ PROCESSING_STATES = [:pending, :failed, :success].freeze
11
+
12
+ belongs_to :user, class_name: "::#{::Spree.user_class}"
13
+ has_many :line_items, class_name: 'SolidusSubscriptions::LineItem', inverse_of: :subscription
14
+ has_many :installments, class_name: 'SolidusSubscriptions::Installment'
15
+ has_many :installment_details, class_name: 'SolidusSubscriptions::InstallmentDetail', through: :installments, source: :details
16
+ has_many :events, class_name: 'SolidusSubscriptions::SubscriptionEvent'
17
+ has_many :orders, class_name: '::Spree::Order', inverse_of: :subscription
18
+ belongs_to :store, class_name: '::Spree::Store'
19
+ belongs_to :shipping_address, class_name: '::Spree::Address', optional: true
20
+ belongs_to :billing_address, class_name: '::Spree::Address', optional: true
21
+ belongs_to :payment_method, class_name: '::Spree::PaymentMethod', optional: true
22
+ belongs_to :payment_source, polymorphic: true, optional: true
23
+
24
+ validates :user, presence: true
25
+ validates :skip_count, :successive_skip_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
26
+ validates :interval_length, numericality: { greater_than: 0 }
27
+ validates :payment_method, presence: true, if: -> { payment_source }
28
+ validates :payment_source, presence: true, if: -> { payment_method&.source_required? }
29
+ validates :currency, inclusion: { in: ::Money::Currency.all.map(&:iso_code) }
30
+
31
+ validate :validate_payment_source_ownership
32
+
33
+ accepts_nested_attributes_for :shipping_address
34
+ accepts_nested_attributes_for :billing_address
35
+ accepts_nested_attributes_for :line_items, allow_destroy: true, reject_if: ->(p) { p[:quantity].blank? }
36
+
37
+ before_validation :set_payment_method
38
+ before_validation :set_currency
39
+ before_create :generate_guest_token
40
+ after_create :emit_event_for_creation
41
+ before_update :update_actionable_date_if_interval_changed
42
+ after_update :emit_events_for_update
43
+
44
+ # Find all subscriptions that are "actionable"; that is, ones that have an
45
+ # actionable_date in the past and are not invalid or canceled.
46
+ scope :actionable, (lambda do
47
+ where("#{table_name}.actionable_date <= ?", Time.zone.today).
48
+ where.not(state: ["canceled", "inactive"])
49
+ end)
50
+
51
+ # Find subscriptions based on their processing state. This state is not a
52
+ # model attribute.
53
+ #
54
+ # @param state [Symbol] One of :pending, :success, or failed
55
+ #
56
+ # pending: New subscriptions, never been processed
57
+ # failed: Subscriptions which failed to be processed on the last attempt
58
+ # success: Subscriptions which were successfully processed on the last attempt
59
+ scope :in_processing_state, (lambda do |state|
60
+ case state.to_sym
61
+ when :success
62
+ fulfilled.joins(:installments)
63
+ when :failed
64
+ fulfilled_ids = fulfilled.pluck(:id)
65
+ where.not(id: fulfilled_ids)
66
+ when :pending
67
+ includes(:installments).where(solidus_subscriptions_installments: { id: nil })
68
+ else
69
+ raise ArgumentError, "state must be one of: :success, :failed, :pending"
70
+ end
71
+ end)
72
+
73
+ scope :fulfilled, (lambda do
74
+ unfulfilled_ids = unfulfilled.pluck(:id)
75
+ where.not(id: unfulfilled_ids)
76
+ end)
77
+
78
+ scope :unfulfilled, (lambda do
79
+ joins(:installments).merge(Installment.unfulfilled)
80
+ end)
81
+
82
+ scope :with_default_payment_source, (lambda do
83
+ where(payment_method: nil, payment_source: nil)
84
+ end)
85
+
86
+ def self.ransackable_scopes(_auth_object = nil)
87
+ [:in_processing_state]
88
+ end
89
+
90
+ def self.processing_states
91
+ PROCESSING_STATES
92
+ end
93
+
94
+ # The subscription state determines the behaviours around when it is
95
+ # processed. Here is a brief description of the states and how they affect
96
+ # the subscription.
97
+ #
98
+ # [active] Default state when created. Subscription can be processed
99
+ # [canceled] The user has ended their subscription. Subscription will not
100
+ # be processed.
101
+ # [pending_cancellation] The user has ended their subscription, but the
102
+ # conditions for canceling the subscription have not been met. Subscription
103
+ # will continue to be processed until the subscription is canceled and
104
+ # the conditions are met.
105
+ # [inactive] The number of installments has been fulfilled. The subscription
106
+ # will no longer be processed
107
+ state_machine :state, initial: :active do
108
+ event :cancel do
109
+ transition [:active, :pending_cancellation] => :canceled,
110
+ if: ->(subscription) { subscription.can_be_canceled? }
111
+
112
+ transition active: :pending_cancellation
113
+ end
114
+
115
+ event :force_cancel do
116
+ transition [:active, :pending_cancellation] => :canceled
117
+ transition inactive: :inactive
118
+ transition canceled: :canceled
119
+ end
120
+
121
+ after_transition to: :canceled, do: :advance_actionable_date
122
+
123
+ event :deactivate do
124
+ transition active: :inactive,
125
+ if: ->(subscription) { subscription.can_be_deactivated? }
126
+ end
127
+
128
+ event :activate do
129
+ transition any - [:active] => :active
130
+ end
131
+
132
+ after_transition to: :active, do: :advance_actionable_date
133
+ after_transition do: :emit_event_for_transition
134
+ end
135
+
136
+ # This method determines if a subscription may be canceled. Canceled
137
+ # subcriptions will not be processed. By default subscriptions may always be
138
+ # canceled. If this method is overridden to return false, the subscription
139
+ # will be moved to the :pending_cancellation state until it is canceled
140
+ # again and this condition is true.
141
+ #
142
+ # USE CASE: Subscriptions can only be canceled more than 10 days before they
143
+ # are processed. Override this method to be:
144
+ #
145
+ # def can_be_canceled?
146
+ # return true if actionable_date.nil?
147
+ # (actionable_date - 10.days.from_now.to_date) > 0
148
+ # end
149
+ #
150
+ # If a user cancels this subscription less than 10 days before it will
151
+ # be processed the subscription will be bumped into the
152
+ # :pending_cancellation state instead of being canceled. Subscriptions
153
+ # pending cancellation will still be processed.
154
+ def can_be_canceled?
155
+ return true if actionable_date.nil?
156
+
157
+ cancel_by = actionable_date - SolidusSubscriptions.configuration.minimum_cancellation_notice
158
+ cancel_by.future? || cancel_by.today?
159
+ end
160
+
161
+ def skip(check_skip_limits: true)
162
+ if check_skip_limits
163
+ check_successive_skips_exceeded
164
+ check_total_skips_exceeded
165
+
166
+ return if errors.any?
167
+ end
168
+
169
+ increment(:skip_count)
170
+ increment(:successive_skip_count)
171
+ save!
172
+
173
+ advance_actionable_date.tap do
174
+ events.create!(event_type: 'subscription_skipped')
175
+ end
176
+ end
177
+
178
+ # This method determines if a subscription can be deactivated. A deactivated
179
+ # subscription will not be processed. By default a subscription can be
180
+ # deactivated if the end_date defined on
181
+ # the subscription is less than the current date
182
+ # In this case the subscription has been fulfilled and
183
+ # should not be processed again. Subscriptions without an end_date
184
+ # value cannot be deactivated.
185
+ def can_be_deactivated?
186
+ active? && end_date && actionable_date && actionable_date > end_date
187
+ end
188
+
189
+ # Get the date after the current actionable_date where this subscription
190
+ # will be actionable again
191
+ #
192
+ # @return [Date] The current actionable_date plus 1 interval. The next
193
+ # date after the current actionable_date this subscription will be
194
+ # eligible to be processed.
195
+ def next_actionable_date
196
+ return nil unless active?
197
+
198
+ new_date = actionable_date || Time.zone.today
199
+
200
+ new_date + interval
201
+ end
202
+
203
+ # Advance the actionable date to the next_actionable_date value. Will modify
204
+ # the record.
205
+ #
206
+ # @return [Date] The next date after the current actionable_date this
207
+ # subscription will be eligible to be processed.
208
+ def advance_actionable_date
209
+ update! actionable_date: next_actionable_date
210
+
211
+ actionable_date
212
+ end
213
+
214
+ # The state of the last attempt to process an installment associated to
215
+ # this subscription
216
+ #
217
+ # @return [String] pending if the no installments have been processed,
218
+ # failed if the last installment has not been fulfilled and, success
219
+ # if the last installment was fulfilled.
220
+ def processing_state
221
+ return 'pending' if installments.empty?
222
+
223
+ installments.last.fulfilled? ? 'success' : 'failed'
224
+ end
225
+
226
+ def payment_method_to_use
227
+ payment_method || user.wallet.default_wallet_payment_source&.payment_source&.payment_method
228
+ end
229
+
230
+ def payment_source_to_use
231
+ if payment_method
232
+ payment_source
233
+ else
234
+ user.wallet.default_wallet_payment_source&.payment_source
235
+ end
236
+ end
237
+
238
+ def shipping_address_to_use
239
+ shipping_address || user.ship_address
240
+ end
241
+
242
+ def billing_address_to_use
243
+ billing_address || user.bill_address
244
+ end
245
+
246
+ def failing_since
247
+ failing_details = installment_details.failed.order('solidus_subscriptions_installment_details.created_at ASC')
248
+
249
+ last_successful_detail = installment_details
250
+ .succeeded
251
+ .order('solidus_subscriptions_installment_details.created_at DESC')
252
+ .first
253
+ if last_successful_detail
254
+ failing_details = failing_details.where(
255
+ 'solidus_subscriptions_installment_details.created_at > ?',
256
+ last_successful_detail.created_at,
257
+ )
258
+ end
259
+
260
+ first_failing_detail = failing_details.first
261
+
262
+ first_failing_detail&.created_at
263
+ end
264
+
265
+ def maximum_reprocessing_time_reached?
266
+ return false unless SolidusSubscriptions.configuration.maximum_reprocessing_time
267
+ return false unless failing_since
268
+
269
+ Time.zone.now > (failing_since + SolidusSubscriptions.configuration.maximum_reprocessing_time)
270
+ end
271
+
272
+ def actionable?
273
+ actionable_date && actionable_date <= Time.zone.today && ["canceled", "inactive"].exclude?(state)
274
+ end
275
+
276
+ private
277
+
278
+ def validate_payment_source_ownership
279
+ return if payment_source.blank?
280
+
281
+ if payment_source.respond_to?(:user_id) &&
282
+ payment_source.user_id != user_id
283
+ errors.add(:payment_source, :not_owned_by_user)
284
+ end
285
+ end
286
+
287
+ def check_successive_skips_exceeded
288
+ return unless SolidusSubscriptions.configuration.maximum_successive_skips
289
+
290
+ if successive_skip_count >= SolidusSubscriptions.configuration.maximum_successive_skips
291
+ errors.add(:successive_skip_count, :exceeded)
292
+ end
293
+ end
294
+
295
+ def check_total_skips_exceeded
296
+ return unless SolidusSubscriptions.configuration.maximum_total_skips
297
+
298
+ if skip_count >= SolidusSubscriptions.configuration.maximum_total_skips
299
+ errors.add(:skip_count, :exceeded)
300
+ end
301
+ end
302
+
303
+ def update_actionable_date_if_interval_changed
304
+ if persisted? && (interval_length_previously_changed? || interval_units_previously_changed?)
305
+ base_date = if installments.any?
306
+ installments.last.created_at
307
+ else
308
+ created_at
309
+ end
310
+
311
+ new_date = interval.since(base_date)
312
+
313
+ if new_date < Time.zone.now
314
+ # if the chosen base time plus the new interval is in the past, set
315
+ # the actionable_date to be now to avoid confusion and possible
316
+ # mis-processing.
317
+ new_date = Time.zone.now
318
+ end
319
+
320
+ self.actionable_date = new_date
321
+ end
322
+ end
323
+
324
+ def set_payment_method
325
+ if payment_source
326
+ self.payment_method = payment_source.payment_method
327
+ end
328
+ end
329
+
330
+ def set_currency
331
+ self.currency ||= ::Spree::Config[:currency]
332
+ end
333
+
334
+ def generate_guest_token
335
+ self.guest_token ||= loop do
336
+ random_token = SecureRandom.urlsafe_base64(nil, false)
337
+ break random_token unless self.class.exists?(guest_token: random_token)
338
+ end
339
+ end
340
+
341
+ def emit_event_for_creation
342
+ ::Spree::Event.fire(
343
+ 'solidus_subscriptions.subscription_created',
344
+ subscription: self,
345
+ )
346
+ end
347
+
348
+ def emit_event_for_transition
349
+ event_type = {
350
+ active: 'subscription_activated',
351
+ canceled: 'subscription_canceled',
352
+ pending_cancellation: 'subscription_canceled',
353
+ inactive: 'subscription_ended',
354
+ }[state.to_sym]
355
+
356
+ ::Spree::Event.fire(
357
+ "solidus_subscriptions.#{event_type}",
358
+ subscription: self,
359
+ )
360
+ end
361
+
362
+ def emit_events_for_update
363
+ if previous_changes.key?('interval_length') || previous_changes.key?('interval_units')
364
+ ::Spree::Event.fire(
365
+ 'solidus_subscriptions.subscription_frequency_changed',
366
+ subscription: self,
367
+ )
368
+ end
369
+
370
+ if previous_changes.key?('shipping_address_id')
371
+ ::Spree::Event.fire(
372
+ 'solidus_subscriptions.subscription_shipping_address_changed',
373
+ subscription: self,
374
+ )
375
+ end
376
+
377
+ if previous_changes.key?('billing_address_id')
378
+ ::Spree::Event.fire(
379
+ 'solidus_subscriptions.subscription_billing_address_changed',
380
+ subscription: self,
381
+ )
382
+ end
383
+
384
+ if previous_changes.key?('payment_source_id') || previous_changes.key?('payment_source_type') || previous_changes.key?('payment_method_id')
385
+ ::Spree::Event.fire(
386
+ 'solidus_subscriptions.subscription_payment_method_changed',
387
+ subscription: self,
388
+ )
389
+ end
390
+ end
391
+ end
392
+ end