solidus_legacy_promotions 4.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (179) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +345 -0
  3. data/Rakefile +43 -0
  4. data/app/assets/config/solidus_legacy_promotions/manifest.js +2 -0
  5. data/app/assets/javascripts/spree/backend/edit_promotion.js +7 -0
  6. data/app/assets/javascripts/spree/backend/promotions/activation.js +26 -0
  7. data/app/assets/javascripts/spree/backend/promotions.js +35 -0
  8. data/app/assets/javascripts/spree/backend/templates/promotions/calculators/fields/tiered_flat_rate.hbs +23 -0
  9. data/app/assets/javascripts/spree/backend/templates/promotions/calculators/fields/tiered_percent.hbs +23 -0
  10. data/app/assets/javascripts/spree/backend/templates/promotions/rules/option_values.hbs +12 -0
  11. data/app/assets/javascripts/spree/backend/views/calculators/tiered.js +54 -0
  12. data/app/assets/javascripts/spree/backend/views/order/details_adjustments.js +43 -0
  13. data/app/assets/javascripts/spree/backend/views/promotions/option_values_rule.js +79 -0
  14. data/app/assets/javascripts/spree/backend/views/promotions.js +3 -0
  15. data/app/assets/stylesheets/solidus_legacy_promotions/promotions/_edit.scss +138 -0
  16. data/app/assets/stylesheets/solidus_legacy_promotions/promotions.scss +3 -0
  17. data/app/assets/stylesheets/spree/backend/sections/_adjustments.scss +3 -0
  18. data/app/decorators/solidus_legacy_promotions/controllers/solidus_admin/solidus_admin_adjustments_controller_decorator.rb +20 -0
  19. data/app/decorators/solidus_legacy_promotions/lib/spree_order_state_machine_decorator.rb +18 -0
  20. data/app/decorators/solidus_legacy_promotions/models/spree_adjustment_decorator.rb +76 -0
  21. data/app/decorators/solidus_legacy_promotions/models/spree_calculator_returns_default_refund_amount_decorator.rb +13 -0
  22. data/app/decorators/solidus_legacy_promotions/models/spree_line_item_decorator.rb +11 -0
  23. data/app/decorators/solidus_legacy_promotions/models/spree_order_decorator.rb +28 -0
  24. data/app/decorators/solidus_legacy_promotions/models/spree_order_updater_decorator.rb +40 -0
  25. data/app/decorators/solidus_legacy_promotions/models/spree_product_decorator.rb +16 -0
  26. data/app/decorators/solidus_legacy_promotions/models/spree_promotion_code_batch_decorator.rb +16 -0
  27. data/app/decorators/solidus_legacy_promotions/models/spree_shipment_decorator.rb +13 -0
  28. data/app/helpers/spree/admin/promotions_helper.rb +15 -0
  29. data/app/helpers/spree/promotion_rules_helper.rb +12 -0
  30. data/app/jobs/spree/promotion_code_batch_job.rb +26 -0
  31. data/app/mailers/spree/promotion_code_batch_mailer.rb +15 -0
  32. data/app/models/spree/calculator/distributed_amount.rb +33 -0
  33. data/app/models/spree/calculator/flat_percent_item_total.rb +23 -0
  34. data/app/models/spree/calculator/flexi_rate.rb +22 -0
  35. data/app/models/spree/calculator/percent_on_line_item.rb +13 -0
  36. data/app/models/spree/calculator/tiered_flat_rate.rb +52 -0
  37. data/app/models/spree/calculator/tiered_percent.rb +62 -0
  38. data/app/models/spree/order_contents.rb +36 -0
  39. data/app/models/spree/order_promotion.rb +27 -0
  40. data/app/models/spree/permission_sets/promotion_display.rb +25 -0
  41. data/app/models/spree/permission_sets/promotion_management.rb +25 -0
  42. data/app/models/spree/promotion/actions/create_adjustment.rb +81 -0
  43. data/app/models/spree/promotion/actions/create_item_adjustments.rb +98 -0
  44. data/app/models/spree/promotion/actions/create_quantity_adjustments.rb +139 -0
  45. data/app/models/spree/promotion/actions/free_shipping.rb +59 -0
  46. data/app/models/spree/promotion/order_adjustments_recalculator.rb +92 -0
  47. data/app/models/spree/promotion/rules/first_order.rb +36 -0
  48. data/app/models/spree/promotion/rules/first_repeat_purchase_since.rb +36 -0
  49. data/app/models/spree/promotion/rules/item_total.rb +86 -0
  50. data/app/models/spree/promotion/rules/minimum_quantity.rb +59 -0
  51. data/app/models/spree/promotion/rules/nth_order.rb +45 -0
  52. data/app/models/spree/promotion/rules/one_use_per_user.rb +25 -0
  53. data/app/models/spree/promotion/rules/option_value.rb +50 -0
  54. data/app/models/spree/promotion/rules/product.rb +86 -0
  55. data/app/models/spree/promotion/rules/store.rb +26 -0
  56. data/app/models/spree/promotion/rules/taxon.rb +91 -0
  57. data/app/models/spree/promotion/rules/user.rb +34 -0
  58. data/app/models/spree/promotion/rules/user_logged_in.rb +20 -0
  59. data/app/models/spree/promotion/rules/user_role.rb +45 -0
  60. data/app/models/spree/promotion.rb +271 -0
  61. data/app/models/spree/promotion_action.rb +51 -0
  62. data/app/models/spree/promotion_advertiser.rb +10 -0
  63. data/app/models/spree/promotion_category.rb +8 -0
  64. data/app/models/spree/promotion_chooser.rb +34 -0
  65. data/app/models/spree/promotion_code/batch_builder.rb +68 -0
  66. data/app/models/spree/promotion_code.rb +54 -0
  67. data/app/models/spree/promotion_code_batch.rb +18 -0
  68. data/app/models/spree/promotion_finder.rb +9 -0
  69. data/app/models/spree/promotion_handler/cart.rb +75 -0
  70. data/app/models/spree/promotion_handler/coupon.rb +125 -0
  71. data/app/models/spree/promotion_handler/page.rb +26 -0
  72. data/app/models/spree/promotion_handler/shipping.rb +61 -0
  73. data/app/models/spree/promotion_rule.rb +55 -0
  74. data/app/models/spree/promotion_rule_store.rb +10 -0
  75. data/app/models/spree/promotion_rule_taxon.rb +8 -0
  76. data/app/models/spree/promotion_rule_user.rb +10 -0
  77. data/app/subscribers/spree/order_promotion_subscriber.rb +20 -0
  78. data/app/views/spree/order_mailer/cancel_email.html.erb +45 -0
  79. data/app/views/spree/order_mailer/cancel_email.text.erb +16 -0
  80. data/app/views/spree/order_mailer/confirm_email.html.erb +84 -0
  81. data/app/views/spree/order_mailer/confirm_email.text.erb +38 -0
  82. data/app/views/spree/promotion_code_batch_mailer/promotion_code_batch_errored.text.erb +2 -0
  83. data/app/views/spree/promotion_code_batch_mailer/promotion_code_batch_finished.text.erb +2 -0
  84. data/bin/rails +13 -0
  85. data/config/locales/en.yml +101 -0
  86. data/config/locales/promotion_categories.en.yml +6 -0
  87. data/config/locales/promotions.en.yml +6 -0
  88. data/config/routes.rb +28 -0
  89. data/db/migrate/20160101010001_solidus_one_four_promotions.rb +126 -0
  90. data/db/migrate/20161017102621_create_spree_promotion_code_batch.rb +48 -0
  91. data/db/migrate/20180202190713_create_promotion_rule_stores.rb +14 -0
  92. data/db/migrate/20180328172631_add_join_characters_to_promotion_code_batch.rb +15 -0
  93. data/db/migrate/20190106184413_remove_code_from_spree_promotions.rb +46 -0
  94. data/db/migrate/20220317165036_set_promotions_with_any_policy_to_all_if_possible.rb +20 -0
  95. data/db/migrate/20230322085416_remove_match_policy_from_spree_promotion.rb +7 -0
  96. data/db/migrate/20230325132905_remove_unused_columns_from_promotion_rules.rb +10 -0
  97. data/db/migrate/20231027084517_add_order_promotions_foreign_key.rb +14 -0
  98. data/db/migrate/20240621100123_add_promotion_code_id_to_spree_adjustments.rb +10 -0
  99. data/db/migrate/20240622113334_move_adjustment_eligible_to_legacy_promotions.rb +11 -0
  100. data/lib/components/admin/solidus_admin/orders/show/adjustments/index/source/spree_promotion_action/component.rb +17 -0
  101. data/lib/components/admin/solidus_admin/promotion_categories/index/component.rb +56 -0
  102. data/lib/components/admin/solidus_admin/promotions/index/component.rb +104 -0
  103. data/lib/components/admin/solidus_admin/promotions/index/component.yml +10 -0
  104. data/lib/components/admin/solidus_legacy_promotions/orders/index/component.rb +15 -0
  105. data/lib/components/admin/solidus_legacy_promotions/orders/index/component.yml +20 -0
  106. data/lib/controllers/admin/solidus_admin/promotion_categories_controller.rb +29 -0
  107. data/lib/controllers/admin/solidus_admin/promotions_controller.rb +46 -0
  108. data/lib/controllers/backend/spree/admin/promotion_actions_controller.rb +51 -0
  109. data/lib/controllers/backend/spree/admin/promotion_categories_controller.rb +8 -0
  110. data/lib/controllers/backend/spree/admin/promotion_code_batches_controller.rb +30 -0
  111. data/lib/controllers/backend/spree/admin/promotion_codes_controller.rb +48 -0
  112. data/lib/controllers/backend/spree/admin/promotion_rules_controller.rb +60 -0
  113. data/lib/controllers/backend/spree/admin/promotions_controller.rb +66 -0
  114. data/lib/solidus_legacy_promotions/configuration.rb +115 -0
  115. data/lib/solidus_legacy_promotions/engine.rb +97 -0
  116. data/lib/solidus_legacy_promotions/migrations/promotions_with_code_handlers.rb +68 -0
  117. data/lib/solidus_legacy_promotions/testing_support/factories/calculator_factory.rb +7 -0
  118. data/lib/solidus_legacy_promotions/testing_support/factories/order_factory.rb +22 -0
  119. data/lib/solidus_legacy_promotions/testing_support/factories/order_promotion_factory.rb +8 -0
  120. data/lib/solidus_legacy_promotions/testing_support/factories/promotion_category_factory.rb +7 -0
  121. data/lib/solidus_legacy_promotions/testing_support/factories/promotion_code_factory.rb +8 -0
  122. data/lib/solidus_legacy_promotions/testing_support/factories/promotion_factory.rb +98 -0
  123. data/lib/solidus_legacy_promotions/testing_support/factory_bot.rb +28 -0
  124. data/lib/solidus_legacy_promotions.rb +28 -0
  125. data/lib/tasks/solidus_legacy_promotions/delete_ineligible_adjustments.rake +8 -0
  126. data/lib/views/backend/spree/admin/adjustments/_adjustment.html.erb +24 -0
  127. data/lib/views/backend/spree/admin/orders/_adjustments.html.erb +19 -0
  128. data/lib/views/backend/spree/admin/orders/_order_details.html.erb +32 -0
  129. data/lib/views/backend/spree/admin/orders/confirm.html.erb +59 -0
  130. data/lib/views/backend/spree/admin/promotion_actions/create.js.erb +10 -0
  131. data/lib/views/backend/spree/admin/promotion_actions/destroy.js.erb +1 -0
  132. data/lib/views/backend/spree/admin/promotion_categories/_form.html.erb +14 -0
  133. data/lib/views/backend/spree/admin/promotion_categories/edit.html.erb +10 -0
  134. data/lib/views/backend/spree/admin/promotion_categories/index.html.erb +47 -0
  135. data/lib/views/backend/spree/admin/promotion_categories/new.html.erb +10 -0
  136. data/lib/views/backend/spree/admin/promotion_code_batches/_form_fields.html.erb +22 -0
  137. data/lib/views/backend/spree/admin/promotion_code_batches/download.csv.ruby +8 -0
  138. data/lib/views/backend/spree/admin/promotion_code_batches/index.html.erb +65 -0
  139. data/lib/views/backend/spree/admin/promotion_code_batches/new.html.erb +8 -0
  140. data/lib/views/backend/spree/admin/promotion_codes/index.csv.ruby +8 -0
  141. data/lib/views/backend/spree/admin/promotion_codes/index.html.erb +32 -0
  142. data/lib/views/backend/spree/admin/promotion_codes/new.html.erb +31 -0
  143. data/lib/views/backend/spree/admin/promotion_rules/create.js.erb +8 -0
  144. data/lib/views/backend/spree/admin/promotion_rules/destroy.js.erb +3 -0
  145. data/lib/views/backend/spree/admin/promotions/_actions.html.erb +37 -0
  146. data/lib/views/backend/spree/admin/promotions/_activations_edit.html.erb +22 -0
  147. data/lib/views/backend/spree/admin/promotions/_activations_new.html.erb +43 -0
  148. data/lib/views/backend/spree/admin/promotions/_form.html.erb +67 -0
  149. data/lib/views/backend/spree/admin/promotions/_promotion_action.html.erb +13 -0
  150. data/lib/views/backend/spree/admin/promotions/_promotion_rule.html.erb +12 -0
  151. data/lib/views/backend/spree/admin/promotions/_rules.html.erb +42 -0
  152. data/lib/views/backend/spree/admin/promotions/actions/_create_adjustment.html.erb +2 -0
  153. data/lib/views/backend/spree/admin/promotions/actions/_create_item_adjustments.html.erb +6 -0
  154. data/lib/views/backend/spree/admin/promotions/actions/_create_quantity_adjustments.html.erb +10 -0
  155. data/lib/views/backend/spree/admin/promotions/actions/_free_shipping.html.erb +0 -0
  156. data/lib/views/backend/spree/admin/promotions/actions/_promotion_calculators_with_custom_fields.html.erb +29 -0
  157. data/lib/views/backend/spree/admin/promotions/calculators/_default_fields.html.erb +6 -0
  158. data/lib/views/backend/spree/admin/promotions/calculators/distributed_amount/_fields.html.erb +56 -0
  159. data/lib/views/backend/spree/admin/promotions/calculators/flat_rate/_fields.html.erb +6 -0
  160. data/lib/views/backend/spree/admin/promotions/calculators/tiered_flat_rate/_fields.html.erb +30 -0
  161. data/lib/views/backend/spree/admin/promotions/calculators/tiered_percent/_fields.html.erb +30 -0
  162. data/lib/views/backend/spree/admin/promotions/edit.html.erb +40 -0
  163. data/lib/views/backend/spree/admin/promotions/index.html.erb +124 -0
  164. data/lib/views/backend/spree/admin/promotions/new.html.erb +14 -0
  165. data/lib/views/backend/spree/admin/promotions/rules/_first_order.html.erb +0 -0
  166. data/lib/views/backend/spree/admin/promotions/rules/_first_repeat_purchase_since.html.erb +13 -0
  167. data/lib/views/backend/spree/admin/promotions/rules/_item_total.html.erb +14 -0
  168. data/lib/views/backend/spree/admin/promotions/rules/_minimum_quantity.html.erb +5 -0
  169. data/lib/views/backend/spree/admin/promotions/rules/_nth_order.html.erb +12 -0
  170. data/lib/views/backend/spree/admin/promotions/rules/_one_use_per_user.html.erb +0 -0
  171. data/lib/views/backend/spree/admin/promotions/rules/_option_value.html.erb +13 -0
  172. data/lib/views/backend/spree/admin/promotions/rules/_product.html.erb +15 -0
  173. data/lib/views/backend/spree/admin/promotions/rules/_store.html.erb +6 -0
  174. data/lib/views/backend/spree/admin/promotions/rules/_taxon.html.erb +9 -0
  175. data/lib/views/backend/spree/admin/promotions/rules/_user.html.erb +4 -0
  176. data/lib/views/backend/spree/admin/promotions/rules/_user_logged_in.html.erb +0 -0
  177. data/lib/views/backend/spree/admin/promotions/rules/_user_role.html.erb +12 -0
  178. data/solidus_legacy_promotions.gemspec +29 -0
  179. metadata +262 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e9d7efd633c012b29dd74c0586dac9c26b1a39c90e2ba9fb956843913cf00cf5
4
+ data.tar.gz: 5ebe9bde3952c34717157d2229f34c12e79e95a43114bb459966457fa433a97f
5
+ SHA512:
6
+ metadata.gz: f8235d2a505e5c77ed0d1c1867e7fc2296ad1c130b225181225b096deb5f027bfba955fc3ef02824383bb9733956a25badbfd385b6466c478d8fd1947520e487
7
+ data.tar.gz: 9cd8c70170387dd5c8c2875bcb6a570704410eded75113d7833e816f05948d48acc592de1272661db249add575ae8ac4e0d1b758238e6f1fbae141e8a480c8c2
data/README.md ADDED
@@ -0,0 +1,345 @@
1
+ # Solidus Legacy Promotions
2
+
3
+ A Rails Engine that contains the classic Spree/Solidus promotion system, extracted from the other Solidus gems.
4
+
5
+ ## Installation
6
+
7
+ If your Gemfile contains the line `gem "solidus"`, this gem is automatically installed. If you require the individual parts of the Solidus suite, you need to add this gem to your Gemfile:
8
+
9
+ ```rb
10
+ gem "solidus_legacy_promotions"
11
+ ```
12
+
13
+ This gem is slated for deprecation, as its name implies. We're working on integrating a new implementation for promotions and shipping it later this year.
14
+
15
+ ## Architecture overview
16
+
17
+ Solidus Legacy Promotions ships with a powerful rule-based promotions system that allows you to grant flexible
18
+ discounts to your customers in many different scenarios. You can apply discounts to the entire
19
+ order, to a single line item or a set of line items, or to the shipping fees.
20
+
21
+ In order to achieve this level of flexibility, the promotions system is composed of four concepts:
22
+
23
+ * **Promotion handlers** are responsible for activating a promotion at the right step of the
24
+ customer experience.
25
+ * **Promotion rules** are responsible for checking whether an order is eligible for a promotion.
26
+ * **Promotion actions** are responsible for defining the discount(s) to be applied to eligible
27
+ orders.
28
+ * **Adjustments** are responsible for storing discount information. Promotion adjustments are
29
+ recalculated every time the order is updated, to check if their eligibility persists when the
30
+ state of the order changes. It is possible to
31
+ [customize how this recalculation behaves][how-to-use-a-custom-promotion-adjuster].
32
+
33
+ > [!NOTE]
34
+ > Adjustments go beyond promotions and apply to other concepts that modify the order amount.
35
+ > Taxes are another good example.
36
+
37
+ Let's take the example of the following promotion:
38
+
39
+ > Apply free shipping on any orders whose total is $100 USD or greater.
40
+
41
+ Here's the flow Solidus follows to apply such a promotion:
42
+
43
+ 1. When the customer enters their shipping information,
44
+ the [`Shipping`](https://github.com/solidusio/solidus/blob/64b6b6eaf902337983c487cf10dfada8dbfc5160/core/app/models/spree/promotion\_handler/shipping.rb)
45
+ promotion handler activates the promotion on the order.
46
+ 2. When activated, the promotion will perform
47
+ some [basic eligibility checks](https://github.com/solidusio/solidus/blob/64b6b6eaf902337983c487cf10dfada8dbfc5160/core/app/models/spree/promotion.rb#L149) (
48
+ e.g. usage limit, validity dates) and
49
+ then [ensure the defined promotion rules are met.](https://github.com/solidusio/solidus/blob/64b6b6eaf902337983c487cf10dfada8dbfc5160/core/app/models/spree/promotion.rb#L149)
50
+ 3. When called,
51
+ the [`ItemTotal`](https://github.com/solidusio/solidus/blob/64b6b6eaf902337983c487cf10dfada8dbfc5160/core/app/models/spree/promotion/rules/item\_total.rb)
52
+ promotion rule will ensure the order's total is $100 USD or greater.
53
+ 4. Since the order is eligible for the promotion,
54
+ the [`FreeShipping`](https://github.com/solidusio/solidus/blob/64b6b6eaf902337983c487cf10dfada8dbfc5160/core/app/models/spree/promotion/actions/free\_shipping.rb)
55
+ action is applied to the order's shipment. The action creates an adjustment that cancels the cost
56
+ of the shipment.
57
+ 5. The customer gets free shipping!
58
+
59
+ This is the architecture at a glance. As you can see, Solidus already ships with some useful
60
+ handlers, rules, and actions out of the box.
61
+
62
+ However, you're not limited to using the stock functionality. In fact, the promotions system shows
63
+ its full potential when you use it to implement your own logic. In the rest of the guide, we'll use
64
+ the promotions system to implement the following requirements:
65
+
66
+ > We want to uphold a partnership with a new payment platform by offering a 50% shipping discount
67
+ > when customers pay with it during the checkout.
68
+
69
+ In order to do this, we'll have to implement our own handler, rule, and action. Let's get to work!
70
+
71
+ ## Implementing a new handler
72
+
73
+ There's nothing special about promotion handlers: technically, they're just plain old Ruby objects
74
+ that are created and called in the right places during the checkout flow.
75
+
76
+ There is no unified API for promotion handlers, but we can take inspiration from
77
+ the [existing ones](https://github.com/solidusio/solidus/tree/64b6b6eaf902337983c487cf10dfada8dbfc5160/core/app/models/spree/promotion\_handler)
78
+ and use a similar format:
79
+
80
+ ```ruby title="app/models/amazing_store/promotion_handler/payment.rb"
81
+ # frozen_string_literal: true
82
+
83
+ module AmazingStore
84
+ module PromotionHandler
85
+ class Payment
86
+ RULES_TYPE = 'AmazingStore::Promotion::Rules::Payment'
87
+
88
+ attr_reader :order
89
+
90
+ def initialize(order)
91
+ @order = order
92
+ end
93
+
94
+ def activate
95
+ promotions.each do |promotion|
96
+ promotion.activate(order: order) if promotion.eligible?(order)
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def promotions
103
+ ::Spree::Promotion.
104
+ active.
105
+ joins(:promotion_rules).
106
+ where('promotion_rules.type' => RULES_TYPE)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ ```
112
+
113
+ Our promotion handler selects a subset of promotions with a specific rule type that we haven't yet
114
+ created. Then, it activates the eligible ones, i.e., those who obey its rules.
115
+
116
+ Remember that promotion handlers simply apply active promotions to the current order at the correct
117
+ stage of the order workflow. While other handlers might pick up our promotions, they won't be able
118
+ to activate it if they run before the payment step. With the new handler, we want to ensure that
119
+ promotions can be activated after a payment method has been selected for the order.
120
+
121
+ Let's call our handler as a callback after the checkout flow has transitioned from the `:payment`
122
+ state (see
123
+ the [section on how to customize state machines](state-machines.mdx#customizing-core-behavior)):
124
+
125
+ ```ruby title="app/overrides/amazing_store/load_payment_promotion_handler.rb"
126
+ # frozen_string_literal: true
127
+
128
+ module AmazingStore
129
+ module LoadPaymentPromotionHandler
130
+ def self.prepended(base)
131
+ base.state_machine.after_transition(from: :payment) do |order|
132
+ AmazingStore::PromotionHandler::Payment.new(order).activate
133
+ end
134
+ end
135
+
136
+ ::Spree::Order.prepend(self)
137
+ end
138
+ end
139
+ ```
140
+
141
+ ## Implementing a new rule
142
+
143
+ Now that we have our handler, let's move on and implement the promotion rule that checks whether the
144
+ customer is using the promoted payment method.
145
+
146
+ We'll allow store admins to edit which payment method carries the discount. The best way to do that
147
+ is to create a preference for the promotion rule itself:
148
+
149
+ ```ruby title="app/models/amazing_store/promotion/rules/payment.rb"
150
+ # frozen_string_literal: true
151
+
152
+ module AmazingStore
153
+ module Promotion
154
+ module Rules
155
+ class Payment < ::Spree::PromotionRule
156
+ DEFAULT_PREFERRED_PAYMENT_TYPE = 'AmazingStore::AmazingPaymentPlatform'
157
+
158
+ ALLOWED_PAYMENT_TYPES = [
159
+ DEFAULT_PREFERRED_PAYMENT_TYPE,
160
+ 'Spree::PaymentMethod::Check',
161
+ 'Spree::PaymentMethod::CreditCard'
162
+ ].freeze
163
+
164
+ preference :payment_type, :string, default: DEFAULT_PREFERRED_PAYMENT_TYPE
165
+
166
+ validates :preferred_payment_type, inclusion: {
167
+ in: ALLOWED_PAYMENT_TYPES,
168
+ allow_blank: true
169
+ }, on: :update
170
+
171
+ def applicable?(promotable)
172
+ promotable.is_a?(::Spree::Order)
173
+ end
174
+
175
+ def eligible?(order, _options = {})
176
+ order.payments.any? do |payment|
177
+ payment.payment_method.type == preferred_payment_type
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ ```
185
+
186
+ > [!CAUTION]
187
+ > You may have noticed that we allow the payment type to be blank on creation. This is because
188
+ > promotion rules are initially created without any of their preferences, so that the correct form can
189
+ > be presented to the admin when configuring the rule. If we enforced the presence of a payment type
190
+ > since the very beginning, Solidus wouldn't be able to create the promotion rule and admins would get
191
+ > an error.
192
+
193
+ Now that we have the implementation of our promotion rule, we also need to give admins a nice UI
194
+ where they can manage the rule and enter the promoted payment type. We just need to create the right
195
+ partial, where we'll have a local variable `promotion_rule` available to access the current
196
+ promotion rule instance:
197
+
198
+ ```markup title="app/views/spree/admin/promotions/rules/_payment.html.erb"
199
+ <div class="row">
200
+ <div class="col-6">
201
+ <div class="field">
202
+ <%= promotion_rule.class.human_attribute_name(:payment_type) %>
203
+ </div>
204
+ </div>
205
+ <div class="col-6">
206
+ <div class="field">
207
+ <%= select_tag "#{param_prefix}[preferred_payment_type]", options_for_select(promotion_rule.class::ALLOWED_PAYMENT_TYPES, promotion_rule.preferred_payment_type), class: 'fullwidth' %>
208
+ </div>
209
+ </div>
210
+ </div>
211
+ ```
212
+
213
+ The last step is to register our new promotion rule in an initializer:
214
+
215
+ ```ruby title="config/initializers/promotions.rb"
216
+ # ...
217
+ Rails.application.config.spree.promotions.rules << 'AmazingStore::Promotion::Rules::Payment'
218
+ ```
219
+
220
+ When you create a new promotion in the backend, we should now see the _Payment_ promotion rule. For
221
+ a better experience, we can associate a description so that it's rendered along its form:
222
+
223
+ ```yaml title="config/locales/en.yml"
224
+ en:
225
+ # ...
226
+ activerecord:
227
+ attributes:
228
+ amazing_store/promotion/rules/payment:
229
+ description: Must use the specified payment method
230
+ ```
231
+
232
+ ## Implementing a new action
233
+
234
+ Finally, let's implement the promotion action that will grant customers a 50% shipping discount. In
235
+ order to do that, we can take inspiration from the
236
+ existing [`FreeShipping`](https://github.com/solidusio/solidus/blob/master/core/app/models/spree/promotion/actions/free\_shipping.rb)
237
+ action:
238
+
239
+ ```ruby title="app/models/amazing_store/promotion/actions/half_shipping.rb"
240
+ # frozen_string_literal: true
241
+
242
+ module AmazingStore
243
+ module Promotion
244
+ module Actions
245
+ class HalfShipping < ::Spree::PromotionAction
246
+ # The `perform` method is called when an action is applied to an order or line
247
+ # item. The payload contains a lot of useful context:
248
+ # https://github.com/solidusio/solidus/blob/64b6b6eaf902337983c487cf10dfada8dbfc5160/core/app/models/spree/promotion.rb#L129
249
+ def perform(payload = {})
250
+ order = payload[:order]
251
+ promotion_code = payload[:promotion_code]
252
+
253
+ results = order.shipments.map do |shipment|
254
+ # If the shipment has already been discounted by this promotion action,
255
+ # we skip it.
256
+ next false if shipment.adjustments.where(source: self).exists?
257
+
258
+ # If not, we create an adjustment to apply a 50% discount on the shipment.
259
+ shipment.adjustments.create!(
260
+ order: shipment.order,
261
+ amount: compute_amount(shipment),
262
+ source: self,
263
+ promotion_code: promotion_code,
264
+ label: promotion.name,
265
+ )
266
+
267
+ # We return true here to mark that the shipment has been discounted.
268
+ true
269
+ end
270
+
271
+ # `perform` needs to return true if any adjustments have been applied by
272
+ # the promotion action. Otherwise, it should return false.
273
+ results.any? { |result| result == true }
274
+ end
275
+
276
+ def compute_amount(shipment)
277
+ shipment.cost * -0.5
278
+ end
279
+
280
+ # The `remove_from` method should undo any actions done by `perform`. It is
281
+ # used when an order becomes ineligible for a given promotion and the promotion
282
+ # needs to be removed.
283
+ def remove_from(order)
284
+ order.shipments.each do |shipment|
285
+ shipment.adjustments.each do |adjustment|
286
+ if adjustment.source == self
287
+ # Here, we simply remove any adjustments on the order's shipments
288
+ # created by this promotion action.
289
+ shipment.adjustments.destroy!(adjustment)
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end
296
+ end
297
+ end
298
+ ```
299
+
300
+ As you can see, there's quite a bit going on here, but hopefully, the comments help you with the
301
+ flow of the action and the purpose of the methods we implemented.
302
+
303
+ Just like rules, promotion actions can also have preferences and allow admin to define them via the
304
+ UI. However, in this case, we don't need any of that. Still, Solidus will expect a partial for the
305
+ action, so we should create an empty ERB file.
306
+
307
+ ```erb title="app/views/spree/admin/promotions/actions/_half_shipping.html.erb"
308
+ <!-- Intentionally empty -->
309
+ ```
310
+
311
+ > [!TIP]
312
+ > You can look at
313
+ ? the [`CreateQuantityAdjustments`](https://github.com/solidusio/solidus/blob/64b6b6eaf902337983c487cf10dfada8dbfc5160/core/app/models/spree/promotion/actions/create\_quantity\_adjustments.rb)
314
+ > action and
315
+ > the [corresponding view](https://github.com/solidusio/solidus/blob/64b6b6eaf902337983c487cf10dfada8dbfc5160/backend/app/views/spree/admin/promotions/actions/\_create\_quantity\_adjustments.html.erb)
316
+ for an example of actions with preferences.
317
+
318
+ Finally, we need to register our action by adding the following to an initializer:
319
+
320
+ ```ruby title="config/initializers/promotions.rb"
321
+ # ...
322
+ Rails.application.config.spree.promotions.actions << 'AmazingStore::Promotion::Actions::HalfShipping'
323
+ ```
324
+
325
+ Like before, let's add a human-friendly description:
326
+
327
+ ```yaml title="config/locales/en.yml"
328
+ en:
329
+ # ...
330
+ activerecord:
331
+ attributes:
332
+ amazing_store/promotion/actions/half_shipping:
333
+ description: Applies 50% discount in shipping
334
+ ```
335
+
336
+ Restart the server and you should now see your new promotion action!
337
+
338
+ Let's try it out!
339
+
340
+ First of all, go to the _Promotions_ section on the backend and click _New Promotion_. In this case,
341
+ it makes sense to check the _Apply to all orders_ option, as our promotion doesn't need a code. Once
342
+ the promotion has been created, add the _Payment_ rule and the _Half shipping_ action.
343
+
344
+ You can now go to the frontend and see how the shipment price is dropped by 50% if you select the
345
+ configured payment method.
data/Rakefile ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems'
4
+ require 'rake'
5
+ require 'rake/testtask'
6
+ require 'rspec/core/rake_task'
7
+ require 'spree/testing_support/dummy_app/rake_tasks'
8
+ require 'solidus_admin/testing_support/dummy_app/rake_tasks'
9
+ require 'bundler/gem_tasks'
10
+
11
+ RSpec::Core::RakeTask.new
12
+ task default: :spec
13
+
14
+ DummyApp::RakeTasks.new(
15
+ gem_root: File.dirname(__FILE__),
16
+ lib_name: 'solidus_legacy_promotions'
17
+ )
18
+
19
+ require 'yard/rake/yardoc_task'
20
+ YARD::Rake::YardocTask.new(:yard)
21
+ # The following workaround can be removed
22
+ # once https://github.com/lsegal/yard/pull/1457 is merged.
23
+ task('yard:require') { require 'yard' }
24
+ task yard: 'yard:require'
25
+
26
+ namespace :spec do
27
+ task :isolated do
28
+ spec_files = Dir['spec/**/*_spec.rb']
29
+ failed_specs =
30
+ spec_files.reject do |file|
31
+ puts "rspec #{file}"
32
+ system('rspec', file)
33
+ end
34
+
35
+ if !failed_specs.empty?
36
+ puts "Failed specs:"
37
+ puts failed_specs
38
+ exit 1
39
+ end
40
+ end
41
+ end
42
+
43
+ task test_app: 'db:reset'
@@ -0,0 +1,2 @@
1
+ //= link solidus_legacy_promotions/promotions.css
2
+ //= link spree/backend/edit_promotion.js
@@ -0,0 +1,7 @@
1
+ //= require_tree ./templates
2
+ //= require spree/backend/views/promotions.js
3
+ //= require spree/backend/views/number_with_currency.js
4
+ //= require spree/backend/views/promotions/option_values_rule
5
+ //= require spree/backend/views/calculators/tiered
6
+ //= require spree/backend/promotions
7
+ //= require spree/backend/promotions/activation
@@ -0,0 +1,26 @@
1
+ Spree.PromotionActivationView = Backbone.View.extend({
2
+ events: {
3
+ "change [name=activation_type]": "render"
4
+ },
5
+
6
+ initialize: function(){
7
+ this.render();
8
+ },
9
+
10
+ render: function(){
11
+ var activation_type = this.$("[name=activation_type]:checked").val();
12
+ this.$('[data-activation-type]').each(function(){
13
+ var selected = ($(this).data('activation-type') === activation_type);
14
+ $(this).find(':input').prop("disabled", !selected);
15
+ $(this).toggle(selected);
16
+ });
17
+ }
18
+ });
19
+
20
+ Spree.ready(function(){
21
+ if($("#js_promotion_activation").length) {
22
+ new Spree.PromotionActivationView({
23
+ el: $("#js_promotion_activation")
24
+ });
25
+ }
26
+ });
@@ -0,0 +1,35 @@
1
+ // This is called on page load and via an ajax request in
2
+ // app/views/spree/admin/promotion_actions/create.js.erb
3
+ window.initPromotionActions = function() {
4
+ $('#promotion-filters').find('.variant_autocomplete').variantAutocomplete();
5
+
6
+ $('.promo-rule-option-values').each(function() {
7
+ if (!$(this).data('has-view')) {
8
+ $(this).data('has-view', true);
9
+ new Spree.Views.Promotions.OptionValuesRule({
10
+ el: this
11
+ });
12
+ }
13
+ });
14
+
15
+ $('.js-tiered-calculator').each(function() {
16
+ if (!$(this).data('has-view')) {
17
+ $(this).data('has-view', true);
18
+ new Spree.Views.Calculators.Tiered({
19
+ el: this
20
+ });
21
+ }
22
+ });
23
+ };
24
+
25
+ Spree.ready(function() {
26
+ // Add classes to boxes when hovering over delete
27
+ $('#promotion-filters').on('mouseover', 'a.delete', function(event) {
28
+ $(this).parent().addClass('action-remove');
29
+ });
30
+ $('#promotion-filters').on('mouseout', 'a.delete', function(event) {
31
+ $(this).parent().removeClass('action-remove');
32
+ });
33
+
34
+ window.initPromotionActions();
35
+ });
@@ -0,0 +1,23 @@
1
+ <div class="fullwidth tier">
2
+ <a class="fa fa-trash remove js-remove-tier"></a>
3
+ <div class="row">
4
+ <div class="col-6">
5
+ <div class="input-group">
6
+ <div class="input-group-prepend">
7
+ <span class="input-group-text">$</span>
8
+ </div>
9
+ <input class="js-base-input form-control" type="text" value={{baseField.value}}>
10
+ </div>
11
+ </div>
12
+ <div class="col-6">
13
+ <div class="input-group">
14
+ <div class="input-group-prepend">
15
+ <span class="input-group-text">$</span>
16
+ </div>
17
+ <input class="js-value-input form-control"
18
+ name="{{valueField.name}}" type="text" value={{valueField.value}}>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ <div class="clear"></div>
23
+ </div>
@@ -0,0 +1,23 @@
1
+ <div class="fullwidth tier">
2
+ <a class="fa fa-trash remove js-remove-tier"></a>
3
+ <div class="row">
4
+ <div class="col-6">
5
+ <div class="input-group">
6
+ <div class="input-group-prepend">
7
+ <span class="input-group-text">$</span>
8
+ </div>
9
+ <input class="js-base-input form-control" type="text" value={{baseField.value}}>
10
+ </div>
11
+ </div>
12
+ <div class="col-6">
13
+ <div class="input-group">
14
+ <input class="js-value-input form-control right-align"
15
+ name="{{valueField.name}}" type="text" value={{valueField.value}}>
16
+ <div class="input-group-append">
17
+ <span class="input-group-text">%</span>
18
+ </div>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ <div class="clear"></div>
23
+ </div>
@@ -0,0 +1,12 @@
1
+ <div class="fullwidth promo-rule-option-value">
2
+ <div class="row">
3
+ <div class="col-6">
4
+ <input class="js-promo-rule-option-value-product-select fullwidth" type="hidden" value="{{ productSelect.value }}">
5
+ </div>
6
+ <div class="col-5">
7
+ <input class="js-promo-rule-option-value-option-values-select fullwidth" name="{{ paramPrefix }}[preferred_eligible_values][{{ productSelect.value }}]" type="hidden" value={{optionValuesSelect.value}}>
8
+ </div>
9
+ </div>
10
+ <a class="fa fa-trash remove js-remove-promo-rule-option-value"></a>
11
+ <div class="clear"></div>
12
+ </div>
@@ -0,0 +1,54 @@
1
+ Spree.Views.Calculators.Tiered = Backbone.View.extend({
2
+ initialize: function() {
3
+ this.calculatorName = this.$('.js-tiers').data('calculator');
4
+ this.tierFieldsTemplate = HandlebarsTemplates["promotions/calculators/fields/" + this.calculatorName];
5
+ this.originalTiers = this.$('.js-tiers').data('original-tiers');
6
+ this.formPrefix = this.$('.js-tiers').data('form-prefix');
7
+
8
+ for (var base in this.originalTiers) {
9
+ var value = this.originalTiers[base];
10
+ this.$('.js-tiers').append(
11
+ this.tierFieldsTemplate({
12
+ baseField: {
13
+ value: base
14
+ },
15
+ valueField: {
16
+ name: this.tierInputName(base),
17
+ value: value
18
+ }
19
+ })
20
+ );
21
+ }
22
+ },
23
+
24
+ events: {
25
+ 'click .js-add-tier': 'onAdd',
26
+ 'click .js-remove-tier': 'onRemove',
27
+ 'change .js-base-input': 'onChange'
28
+ },
29
+
30
+ tierInputName: function(base) {
31
+ return this.formPrefix + "[calculator_attributes][preferred_tiers][" + base + "]";
32
+ },
33
+
34
+ onAdd: function(event) {
35
+ event.preventDefault();
36
+ this.$('.js-tiers').append(
37
+ this.tierFieldsTemplate({
38
+ valueField: {
39
+ name: null
40
+ }
41
+ })
42
+ );
43
+ },
44
+
45
+ onRemove: function(event) {
46
+ event.preventDefault();
47
+ $(event.target).parents('.tier').remove();
48
+ },
49
+
50
+ onChange: function(event) {
51
+ var valueInput = $(event.target).parents('.tier').find('.js-value-input');
52
+ valueInput.attr('name', this.tierInputName($(event.target).val()));
53
+ }
54
+ });
@@ -0,0 +1,43 @@
1
+ Spree.Views.Order.DetailsAdjustments = Backbone.View.extend({
2
+ initialize: function() {
3
+ this.listenTo(this.model, "change", this.render);
4
+ this.render()
5
+ },
6
+
7
+ adjustmentTotals: function() {
8
+ var totals = {};
9
+
10
+ var collection = this.collection ? this.collection.chain() : _.chain([this.model]);
11
+ collection
12
+ .map(function(item) {
13
+ return (item.get("adjustments") || [])
14
+ .filter(function(adjustment) { return (adjustment.eligible === true); });
15
+ })
16
+ .flatten(true)
17
+ .each(function(adjustment){
18
+ var label = adjustment.label;
19
+
20
+ /* Fixme: because this is done in JS, we only have floating point math */
21
+ totals[label] = (totals[label] || 0);
22
+ totals[label] += Number(adjustment.amount);
23
+ });
24
+ return totals;
25
+ },
26
+
27
+ render: function() {
28
+ var model = this.model;
29
+ var tbody = this.$('tbody');
30
+ var adjustmentTotals = this.adjustmentTotals()
31
+
32
+ tbody.empty();
33
+ _.each(adjustmentTotals, function(amount, label) {
34
+ var html = HandlebarsTemplates["orders/details_adjustment_row"]({
35
+ label: label,
36
+ amount: Spree.formatMoney(amount, model.get("currency"))
37
+ });
38
+ tbody.append(html);
39
+ });
40
+
41
+ this.$el.toggleClass("hidden", _.isEmpty(adjustmentTotals));
42
+ }
43
+ })