solidus_subscriptions 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
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