solidus_subscriptions-alpha 0.0.1

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 (97) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +26 -0
  3. data/README.md +128 -0
  4. data/Rakefile +28 -0
  5. data/app/assets/javascripts/spree/backend/solidus_subscriptions.js +2 -0
  6. data/app/assets/javascripts/spree/frontend/solidus_subscriptions.js +2 -0
  7. data/app/assets/stylesheets/spree/backend/solidus_subscriptions.css +4 -0
  8. data/app/assets/stylesheets/spree/frontend/solidus_subscriptions.css +4 -0
  9. data/app/controllers/solidus_subscriptions/api/v1/line_items_controller.rb +35 -0
  10. data/app/controllers/solidus_subscriptions/api/v1/subscriptions_controller.rb +44 -0
  11. data/app/controllers/spree/admin/subscriptions_controller.rb +66 -0
  12. data/app/decorators/spree/controllers/api/line_items/create_subscription_line_items.rb +28 -0
  13. data/app/decorators/spree/controllers/orders/create_subscription_line_items.rb +33 -0
  14. data/app/decorators/spree/line_items/subscription_line_items_association.rb +22 -0
  15. data/app/decorators/spree/orders/after_create.rb +15 -0
  16. data/app/decorators/spree/orders/finalize_creates_subscriptions.rb +19 -0
  17. data/app/decorators/spree/orders/subscription_line_items_association.rb +15 -0
  18. data/app/decorators/spree/users/have_many_subscriptions.rb +18 -0
  19. data/app/decorators/spree/variant_pretty_name.rb +13 -0
  20. data/app/jobs/solidus_subscriptions/process_installments_job.rb +22 -0
  21. data/app/models/solidus_subscriptions/checkout.rb +137 -0
  22. data/app/models/solidus_subscriptions/dispatcher.rb +32 -0
  23. data/app/models/solidus_subscriptions/failure_dispatcher.rb +19 -0
  24. data/app/models/solidus_subscriptions/installment.rb +126 -0
  25. data/app/models/solidus_subscriptions/installment_detail.rb +23 -0
  26. data/app/models/solidus_subscriptions/interval.rb +24 -0
  27. data/app/models/solidus_subscriptions/line_item.rb +98 -0
  28. data/app/models/solidus_subscriptions/line_item_builder.rb +44 -0
  29. data/app/models/solidus_subscriptions/order_builder.rb +40 -0
  30. data/app/models/solidus_subscriptions/out_of_stock_dispatcher.rb +19 -0
  31. data/app/models/solidus_subscriptions/payment_failed_dispatcher.rb +23 -0
  32. data/app/models/solidus_subscriptions/subscription.rb +217 -0
  33. data/app/models/solidus_subscriptions/subscription_generator.rb +60 -0
  34. data/app/models/solidus_subscriptions/subscription_line_item_builder.rb +21 -0
  35. data/app/models/solidus_subscriptions/subscription_order_promotion_rule.rb +25 -0
  36. data/app/models/solidus_subscriptions/subscription_promotion_rule.rb +38 -0
  37. data/app/models/solidus_subscriptions/success_dispatcher.rb +16 -0
  38. data/app/models/solidus_subscriptions/unsubscribable_error.rb +17 -0
  39. data/app/models/solidus_subscriptions/user_mismatch_error.rb +15 -0
  40. data/app/overrides/views/admin_subscribable_checkbox.rb +6 -0
  41. data/app/overrides/views/admin_subscriptions_menu_link.rb +8 -0
  42. data/app/overrides/views/subscription_line_item_fields.rb +6 -0
  43. data/app/views/spree/admin/promotions/rules/_subscription_order_promotion_rule.html.erb +0 -0
  44. data/app/views/spree/admin/promotions/rules/_subscription_promotion_rule.html.erb +0 -0
  45. data/app/views/spree/admin/shared/_no_objects_found.html.erb +4 -0
  46. data/app/views/spree/admin/shared/_subscription_tab.html.erb +3 -0
  47. data/app/views/spree/admin/solidus_subscriptions/subscriptions/_subscription.html.erb +66 -0
  48. data/app/views/spree/admin/subscriptions/_form.html.erb +81 -0
  49. data/app/views/spree/admin/subscriptions/_legacy_form.html.erb +81 -0
  50. data/app/views/spree/admin/subscriptions/_legacy_sidebar.html.erb +28 -0
  51. data/app/views/spree/admin/subscriptions/edit.html.erb +21 -0
  52. data/app/views/spree/admin/subscriptions/index.html.erb +119 -0
  53. data/app/views/spree/admin/subscriptions/new.html.erb +9 -0
  54. data/app/views/spree/admin/variants/_subscribable_checkbox.html.erb +6 -0
  55. data/app/views/spree/frontend/products/_subscription_line_item_fields.html.erb +30 -0
  56. data/config/locales/en.yml +91 -0
  57. data/config/routes.rb +25 -0
  58. data/db/migrate/20160825164850_create_solidus_subscriptions_subscriptions.rb +11 -0
  59. data/db/migrate/20160825173548_create_solidus_subscriptions_line_items.rb +17 -0
  60. data/db/migrate/20160825202248_create_solidus_subscriptions_installments.rb +23 -0
  61. data/db/migrate/20160825211202_create_solidus_subscriptions_installment_details.rb +22 -0
  62. data/db/migrate/20160825214240_add_subscribable_to_spree_variants.rb +5 -0
  63. data/db/migrate/20160829201653_change_subscription_line_items_installments_to_max_installments.rb +5 -0
  64. data/db/migrate/20160902220242_remove_state_from_solidus_susbscriptions_installment_details.rb +5 -0
  65. data/db/migrate/20160902220604_add_successful_to_solidus_subscriptions_installment_details.rb +5 -0
  66. data/db/migrate/20160902221218_add_message_to_solidus_subscriptions_installment_details.rb +5 -0
  67. data/db/migrate/20160922164101_add_interval_length_and_units_to_subscription_line_items.rb +8 -0
  68. data/db/migrate/20161006191003_add_skip_count_to_solidus_subscriptions_subscriptions.rb +5 -0
  69. data/db/migrate/20161006191127_add_successive_skip_count_to_solidus_subscriptions_subscriptions.rb +5 -0
  70. data/db/migrate/20161014212649_allow_spree_line_item_id_to_be_null.rb +5 -0
  71. data/db/migrate/20161017155749_add_order_id_to_solidus_subscriptions_installment_details.rb +6 -0
  72. data/db/migrate/20161017175509_remove_order_id_from_solidus_subscriptions_installments.rb +6 -0
  73. data/db/migrate/20161017201944_add_subscription_order_to_spree_orders.rb +5 -0
  74. data/db/migrate/20161221155142_add_store_to_solidus_subscriptions_subscriptions.rb +6 -0
  75. data/db/migrate/20161223152905_add_address_id_to_solidus_subscriptions_subscriptions.rb +7 -0
  76. data/db/migrate/20170106224713_change_line_item_max_installments_to_end_date.rb +6 -0
  77. data/db/migrate/20170111224458_change_subscription_actionable_date_to_datetime.rb +5 -0
  78. data/db/migrate/20170111232801_change_inteval_actionable_date_to_datetime.rb +5 -0
  79. data/db/migrate/20170112012407_add_config_options_to_subscriptions.rb +7 -0
  80. data/lib/generators/solidus_subscriptions/install/install_generator.rb +30 -0
  81. data/lib/solidus_subscriptions.rb +6 -0
  82. data/lib/solidus_subscriptions/ability.rb +19 -0
  83. data/lib/solidus_subscriptions/config.rb +97 -0
  84. data/lib/solidus_subscriptions/engine.rb +56 -0
  85. data/lib/solidus_subscriptions/permitted_attributes.rb +36 -0
  86. data/lib/solidus_subscriptions/processor.rb +108 -0
  87. data/lib/solidus_subscriptions/testing_support/factories.rb +5 -0
  88. data/lib/solidus_subscriptions/testing_support/factories/installment_detail_factory.rb +7 -0
  89. data/lib/solidus_subscriptions/testing_support/factories/installment_factory.rb +21 -0
  90. data/lib/solidus_subscriptions/testing_support/factories/line_item_factory.rb +18 -0
  91. data/lib/solidus_subscriptions/testing_support/factories/spree/line_item_factory.rb +17 -0
  92. data/lib/solidus_subscriptions/testing_support/factories/spree/order_factory.rb +18 -0
  93. data/lib/solidus_subscriptions/testing_support/factories/spree_modification_factory.rb +8 -0
  94. data/lib/solidus_subscriptions/testing_support/factories/subscription_factory.rb +43 -0
  95. data/lib/solidus_subscriptions/version.rb +3 -0
  96. data/lib/tasks/process_subscriptions.rake +6 -0
  97. metadata +460 -0
@@ -0,0 +1,15 @@
1
+ module Spree
2
+ module Orders
3
+ module AfterCreate
4
+ def ensure_line_items_present
5
+ super unless subscription_order?
6
+ end
7
+
8
+ def send_cancel_email
9
+ super unless subscription_order?
10
+ end
11
+ end
12
+
13
+ Order.prepend(AfterCreate)
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # Once an order is finalized its subscriptions line items should be converted
2
+ # into active subscritptions. This hooks into Spree::Order#finalize! and
3
+ # passes all subscription_line_items present on the order to the Subscription
4
+ # generator which will build and persist the subscriptions
5
+ module Spree
6
+ module Orders
7
+ module FinalizeCreatesSubscriptions
8
+ def finalize!
9
+ SolidusSubscriptions::SubscriptionGenerator.group(subscription_line_items).each do |line_items|
10
+ SolidusSubscriptions::SubscriptionGenerator.activate(line_items)
11
+ end
12
+
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ Spree::Order.prepend Spree::Orders::FinalizeCreatesSubscriptions
@@ -0,0 +1,15 @@
1
+ # Spree::Orders may contain many subscription_line_items. When the order is
2
+ # finalized these subscription_line_items are converted into subscritpions.
3
+ # The order needs to be able to get a list of associated subscription_line_items
4
+ # to be able to populate the full subscriptions.
5
+ module Spree
6
+ module Orders
7
+ module SubscriptionLineItemsAssociation
8
+ def self.prepended(base)
9
+ base.has_many :subscription_line_items, through: :line_items
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+ Spree::Order.prepend Spree::Orders::SubscriptionLineItemsAssociation
@@ -0,0 +1,18 @@
1
+ # Spree::Users maintain a list of the subscriptions associated with them
2
+ module Spree
3
+ module Users
4
+ module HaveManySubscritptions
5
+ def self.prepended(base)
6
+ base.has_many(
7
+ :subscriptions,
8
+ class_name: 'SolidusSubscriptions::Subscription',
9
+ foreign_key: 'user_id'
10
+ )
11
+
12
+ base.accepts_nested_attributes_for :subscriptions
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ Spree.user_class.prepend(Spree::Users::HaveManySubscritptions)
@@ -0,0 +1,13 @@
1
+ module Spree
2
+ module Variants
3
+ module VariantPrettyName
4
+ def pretty_name
5
+ name = product.name
6
+ name += " - #{options_text}" if options_text.present?
7
+ name
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ Spree::Variant.prepend Spree::Variants::VariantPrettyName
@@ -0,0 +1,22 @@
1
+ # This job is responsible for creating a consolidated installment from a
2
+ # list of installments and processing it.
3
+
4
+ module SolidusSubscriptions
5
+ class ProcessInstallmentsJob < ActiveJob::Base
6
+ queue_as Config.processing_queue
7
+
8
+ # Process a collection of installments
9
+ #
10
+ # @param installment_ids [Array<Integer>] The ids of the
11
+ # installments to be processed together and fulfilled by the same order
12
+ #
13
+ # @return [Spree::Order] The order which fulfills the list of installments
14
+ def perform(installment_ids)
15
+ return if installment_ids.empty?
16
+
17
+ installments = SolidusSubscriptions::Installment.where(id: installment_ids).
18
+ includes(subscription: [:line_items, :user])
19
+ Checkout.new(installments).process
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,137 @@
1
+ # This class takes a collection of installments and populates a new spree
2
+ # order with the correct contents based on the subscriptions associated to the
3
+ # intallments. This is to group together subscriptions being
4
+ # processed on the same day for a specific user
5
+ module SolidusSubscriptions
6
+ class Checkout
7
+ # @return [Array<Installment>] The collection of installments to be used
8
+ # when generating a new order
9
+ attr_reader :installments
10
+
11
+ delegate :user, to: :subscription
12
+
13
+ # Get a new instance of a Checkout
14
+ #
15
+ # @param installments [Array<Installment>] The collection of installments
16
+ # to be used when generating a new order
17
+ def initialize(installments)
18
+ @installments = installments
19
+ raise UserMismatchError.new(installments) if different_owners?
20
+ end
21
+
22
+ # Generate a new Spree::Order based on the information associated to the
23
+ # installments
24
+ #
25
+ # @return [Spree::Order]
26
+ def process
27
+ populate
28
+
29
+ # Installments are removed and set for future processing if they are
30
+ # out of stock. If there are no line items left there is nothing to do
31
+ return if installments.empty?
32
+
33
+ if checkout
34
+ Config.success_dispatcher_class.new(installments, order).dispatch
35
+ return order
36
+ end
37
+
38
+ # A new order will only have 1 payment that we created
39
+ if order.payments.any?(&:failed?)
40
+ Config.payment_failed_dispatcher_class.new(installments, order).dispatch
41
+ installments.clear
42
+ nil
43
+ end
44
+ ensure
45
+ # Any installments that failed to be processed will be reprocessed
46
+ unfulfilled_installments = installments.select(&:unfulfilled?)
47
+ if unfulfilled_installments.any?
48
+ Config.failure_dispatcher_class.
49
+ new(unfulfilled_installments, order).dispatch
50
+ end
51
+ end
52
+
53
+ # The order fulfilling the consolidated installment
54
+ #
55
+ # @return [Spree::Order]
56
+ def order
57
+ @order ||= Spree::Order.create(
58
+ user: user,
59
+ email: user.email,
60
+ store: subscription.store || Spree::Store.default,
61
+ subscription_order: true
62
+ )
63
+ end
64
+
65
+ private
66
+
67
+ def checkout
68
+ order.update!
69
+ apply_promotions
70
+
71
+ order.checkout_steps[0...-1].each do
72
+ order.ship_address = ship_address if order.state == "address"
73
+ create_payment if order.state == "payment"
74
+ order.next!
75
+ end
76
+
77
+ # Do this as a separate "quiet" transition so that it returns true or
78
+ # false rather than raising a failed transition error
79
+ order.complete
80
+ end
81
+
82
+ def populate
83
+ unfulfilled_installments = []
84
+
85
+ order_line_items = installments.flat_map do |installment|
86
+ line_items = installment.line_item_builder.spree_line_items
87
+
88
+ unfulfilled_installments.push(installment) if line_items.empty?
89
+
90
+ line_items
91
+ end
92
+
93
+ # Remove installments which had no stock from the active list
94
+ # They will be reprocessed later
95
+ @installments -= unfulfilled_installments
96
+ if unfulfilled_installments.any?
97
+ Config.out_of_stock_dispatcher_class.new(unfulfilled_installments).dispatch
98
+ end
99
+
100
+ return if installments.empty?
101
+ order_builder.add_line_items(order_line_items)
102
+ end
103
+
104
+ def order_builder
105
+ @order_builder ||= OrderBuilder.new(order)
106
+ end
107
+
108
+ def subscription
109
+ installments.first.subscription
110
+ end
111
+
112
+ def ship_address
113
+ subscription.shipping_address || user.ship_address
114
+ end
115
+
116
+ def active_card
117
+ user.credit_cards.default.last
118
+ end
119
+
120
+ def create_payment
121
+ order.payments.create(
122
+ source: active_card,
123
+ amount: order.total,
124
+ payment_method: Config.default_gateway
125
+ )
126
+ end
127
+
128
+ def apply_promotions
129
+ Spree::PromotionHandler::Cart.new(order).activate
130
+ order.updater.update # reload totals
131
+ end
132
+
133
+ def different_owners?
134
+ installments.map { |i| i.subscription.user }.uniq.length > 1
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,32 @@
1
+ module SolidusSubscriptions
2
+ class Dispatcher
3
+ attr_reader :installments, :order
4
+
5
+ # Get a new instance of the FailureDispatcher
6
+ #
7
+ # @param installments [Array<SolidusSubscriptions::Installment>] The
8
+ # installments which have failed to be fulfilled
9
+ #
10
+ # @return [SolidusSubscriptions::FailureDispatcher]
11
+ def initialize(installments, order = nil)
12
+ @installments = installments
13
+ @order = order
14
+ end
15
+
16
+ def dispatch
17
+ notify
18
+ end
19
+
20
+ private
21
+
22
+ def notify
23
+ Rails.logger.tagged('Event') do
24
+ Rails.logger.info message.squish.tr("\n", ' ')
25
+ end
26
+ end
27
+
28
+ def message
29
+ raise 'A message should be set in subclasses of Dispatcher'
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ # A handler for behaviour that should happen after installments are marked as
2
+ # failures
3
+ module SolidusSubscriptions
4
+ class FailureDispatcher < Dispatcher
5
+ def dispatch
6
+ order.touch :completed_at
7
+ order.cancel!
8
+ installments.each { |i| i.failed!(order) }
9
+ super
10
+ end
11
+
12
+ def message
13
+ "
14
+ Something went wrong processing installments: #{installments.map(&:id).join(', ')}.
15
+ They have been marked for reprocessing.
16
+ "
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,126 @@
1
+ # This class represents a single iteration of a subscription. It is fulfulled
2
+ # by a conmpleted order and maintains an association which tracks all attempts
3
+ # successful or othewise at fulfulling this installment
4
+ module SolidusSubscriptions
5
+ class Installment < ActiveRecord::Base
6
+ has_many :details, class_name: 'SolidusSubscriptions::InstallmentDetail'
7
+ belongs_to(
8
+ :subscription,
9
+ class_name: 'SolidusSubscriptions::Subscription',
10
+ inverse_of: :installments
11
+ )
12
+
13
+ validates :subscription, presence: true
14
+
15
+ scope :fulfilled, (lambda do
16
+ joins(:details).where(InstallmentDetail.table_name => { success: true }).distinct
17
+ end)
18
+
19
+ scope :unfulfilled, (lambda do
20
+ fulfilled_ids = fulfilled.pluck(:id)
21
+ where.not(id: fulfilled_ids).distinct
22
+ end)
23
+
24
+ scope :actionable, (lambda do
25
+ unfulfilled.where("#{table_name}.actionable_date <= ?", Time.zone.now)
26
+ end)
27
+
28
+ # Get the builder for the subscription_line_item. This will be an
29
+ # object that can generate the appropriate line item for the subscribable
30
+ # object
31
+ #
32
+ # @return [SolidusSubscriptions::LineItemBuilder]
33
+ def line_item_builder
34
+ subscription.line_item_builder
35
+ end
36
+
37
+ # Mark this installment as out of stock.
38
+ #
39
+ # @return [SolidusSubscriptions::InstallmentDetail] The record of the failed
40
+ # processing attempt
41
+ def out_of_stock
42
+ advance_actionable_date!
43
+
44
+ details.create!(
45
+ success: false,
46
+ message: I18n.t('solidus_subscriptions.installment_details.out_of_stock')
47
+ )
48
+ end
49
+
50
+ # Mark this installment as a success
51
+ #
52
+ # @param order [Spree::Order] The order generated for this processing
53
+ # attempt
54
+ #
55
+ # @return [SolidusSubscriptions::InstallmentDetail] The record of the
56
+ # successful processing attempt
57
+ def success!(order)
58
+ update!(actionable_date: nil)
59
+
60
+ details.create!(
61
+ success: true,
62
+ order: order,
63
+ message: I18n.t('solidus_subscriptions.installment_details.success')
64
+ )
65
+ end
66
+
67
+ # Mark this installment as a failure
68
+ #
69
+ # @param order [Spree::Order] The order generated for this processing
70
+ # attempt
71
+ #
72
+ # @return [SolidusSubscriptions::InstallmentDetail] The record of the
73
+ # failed processing attempt
74
+ def failed!(order)
75
+ advance_actionable_date!
76
+
77
+ details.create!(
78
+ success: false,
79
+ order: order,
80
+ message: I18n.t('solidus_subscriptions.installment_details.failed')
81
+ )
82
+ end
83
+
84
+ # Does this installment still need to be fulfilled by a completed order
85
+ #
86
+ # @return [Boolean]
87
+ def unfulfilled?
88
+ !fulfilled?
89
+ end
90
+
91
+ # Had this installment been fulfilled by a completed order
92
+ #
93
+ # @return [Boolean]
94
+ def fulfilled?
95
+ details.where(success: true).exists?
96
+ end
97
+
98
+ # Mark this installment as having a failed payment
99
+ #
100
+ # @param order [Spree::Order] The order generated for this processing
101
+ # attempt
102
+ #
103
+ # @return [SolidusSubscriptions::InstallmentDetail] The record of the
104
+ # failed processing attempt
105
+ def payment_failed!(order)
106
+ advance_actionable_date!
107
+
108
+ details.create!(
109
+ success: false,
110
+ order: order,
111
+ message: I18n.t('solidus_subscriptions.installment_details.payment_failed')
112
+ )
113
+ end
114
+
115
+ private
116
+
117
+ def advance_actionable_date!
118
+ update!(actionable_date: next_actionable_date)
119
+ end
120
+
121
+ def next_actionable_date
122
+ return if Config.reprocessing_interval.nil?
123
+ (DateTime.current + Config.reprocessing_interval).beginning_of_minute
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,23 @@
1
+ # This class represents a single attempt to fulfill an installment. It will
2
+ # indicate the result of that attept.
3
+ module SolidusSubscriptions
4
+ class InstallmentDetail < ActiveRecord::Base
5
+ belongs_to(
6
+ :installment,
7
+ class_name: 'SolidusSubscriptions::Installment',
8
+ inverse_of: :details
9
+ )
10
+
11
+ belongs_to(:order, class_name: 'Spree::Order')
12
+
13
+ validates :installment, presence: true
14
+ alias_attribute :successful, :success
15
+
16
+ # Was the attempt at fulfilling this installment a failure?
17
+ #
18
+ # @return [Boolean]
19
+ def failed?
20
+ !success
21
+ end
22
+ end
23
+ end