diff --git a/.circleci/config.yml b/.circleci/config.yml index 0dc19b64d42..7df1a37d831 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -118,6 +118,9 @@ commands: - run: name: "Run Legacy Promotion Tests" command: ./bin/build-ci legacy_promotions + - run: + name: "Run Friendly Promotion Tests" + command: ./bin/build-ci promotions - store_artifacts: path: /tmp/test-artifacts diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 1ff5c6787e6..b4ef66b1821 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -295,6 +295,7 @@ Rails/ApplicationController: Rails/ApplicationJob: Exclude: - "legacy_promotions/app/jobs/spree/promotion_code_batch_job.rb" + - "promotions/app/jobs/solidus_promotions/promotion_code_batch_job.rb" # Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). @@ -458,6 +459,8 @@ Rails/ReflectionClassName: - "core/app/models/spree/user_address.rb" - "core/app/models/spree/user_stock_location.rb" - "core/app/models/spree/wallet_payment_source.rb" + - "promotions/app/models/solidus_promotions/condition_user.rb" + - "promotions/app/models/solidus_promotions/conditions/user.rb" # Offense count: 23 # This cop supports safe autocorrection (--autocorrect). diff --git a/Gemfile b/Gemfile index 9b125f062a1..ec5e8fed53c 100644 --- a/Gemfile +++ b/Gemfile @@ -70,6 +70,15 @@ group :legacy_promotions do gem 'axe-core-capybara', '~> 4.8', require: false end +group :promotions do + gem 'solidus_promotions', path: 'promotions', require: false + gem 'solidus_admin', path: 'admin', require: false + gem 'solidus_backend', path: 'backend', require: false + gem 'axe-core-rspec', '~> 4.8', require: false + gem 'axe-core-capybara', '~> 4.8', require: false + gem 'shoulda-matchers', '~> 5.0', require: false +end + group :lint do gem 'erb-formatter', '~> 0.7', require: false gem 'rubocop', '~> 1', require: false diff --git a/bin/build-ci b/bin/build-ci index 0569fac0418..5a168248ae2 100755 --- a/bin/build-ci +++ b/bin/build-ci @@ -27,7 +27,8 @@ class Project new('backend', test_type: :teaspoon, title: "backend JS", weight: 18), new('core', weight: 266), new('sample', weight: 28), - new('legacy_promotions', weight: 63) + new('legacy_promotions', weight: 63), + new('promotions', weight: 63) ] end @@ -114,7 +115,6 @@ class Project end end - if ENV['CIRCLE_NODE_INDEX'] # Run projects on a CI node projects = Project.weighted_projects( node_total: Integer(ENV.fetch('CIRCLE_NODE_TOTAL', 1)), diff --git a/promotions/.eslintrc.json b/promotions/.eslintrc.json new file mode 100644 index 00000000000..ed30c2150bd --- /dev/null +++ b/promotions/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 2023, + "sourceType": "module" + }, + "globals": { + "Turbo": "readonly" + } +} diff --git a/promotions/.github/stale.yml b/promotions/.github/stale.yml new file mode 100644 index 00000000000..0d0b1c994dd --- /dev/null +++ b/promotions/.github/stale.yml @@ -0,0 +1 @@ +_extends: .github diff --git a/promotions/MIGRATING.md b/promotions/MIGRATING.md new file mode 100644 index 00000000000..4c4841dbbd6 --- /dev/null +++ b/promotions/MIGRATING.md @@ -0,0 +1,150 @@ +# Migrating from Solidus' promotion system to SolidusPromotions + +The system is designed to completely replace the Solidus promotion system. Follow these steps to migrate your store to the gem: + +## Install solidus_promotions + +Add the following line to your `Gemfile`: + +```rb +gem "solidus_promotions" +``` + +Then run + +```sh +bundle install +bundle exec rails generate solidus_promotions:install +``` + +This will install the extension. It will add new tables, and new routes. It will also generate an initializer in `config/initializers/solidus_promotions.rb`. + +For the time being, comment out the following lines: + +```rb +# Make sure we use Spree::SimpleOrderContents +# Spree::Config.order_contents_class = "Spree::SimpleOrderContents" +# Set the promotion configuration to ours +# Spree::Config.promotions = SolidusPromotions.configuration +``` + +This makes sure that the behavior of the current promotion system does not change - yet. + +## Migrate existing promotions + +Now, run the promotion migrator: + +```sh +bundle exec rails solidus_promotions:migrate_existing_promotions +``` + +This will create equivalents of the legacy promotion configuration in SolidusPromotions. + +Now, change `config/initializers/solidus_promotions.rb` to use your new promotion configuration: + +## Change store behavior to use SolidusPromotions + +```rb +# Make sure we use Spree::SimpleOrderContents +Spree::Config.order_contents_class = "Spree::SimpleOrderContents" +# Set the promotion configuration to ours +Spree::Config.promotions = SolidusPromotions.configuration + +# Sync legacy order promotions with the new promotion system +SolidusPromotions.config.sync_order_promotions = true +``` + +From a user's perspective, your promotions should work as before. + +Before you create new promotions, migrate the adjustments and order promotions in your database: + +```rb +bundle exec rails solidus_promotions:migrate_adjustments:up +bundle exec rails solidus_promotions:migrate_order_promotions:up + +``` + +Check your `spree_adjustments` table for correctness. If things went wrong, you should be able to roll back with + +```rb +bundle exec rails solidus_promotions:migrate_adjustments:down +bundle exec rails solidus_promotions:migrate_order_promotions:down +``` + +Both of these tasks only work if every promotion rule and promotion action have an equivalent condition or benefit in SolidusFrienndlyPromotions. Benefits are connected to their originals promotion action using the `SolidusPromotions#original_promotion_action_id`, Promotions are connected to their originals using the `SolidusPromotions#original_promotion_id`. + +Once these tasks have run and everything works, you can stop syncing legacy order promotions and new order promotions: + +```rb +SolidusPromotions.config.sync_order_promotions = false +``` + +## Solidus Starter Frontend (and other custom frontends) + +Stores that have a custom coupon codes controller, such as Solidus' starter frontend, have to change the coupon promotion handler to the one from this gem. Cange any reference to `Spree::PromotionHandler::Coupon` to `Spree::Config.promotions.coupon_code_handler_class`. + +## Migrating custom rules and actions + +If you have custom promotion rules or actions, you need to create new conditions and benefits, respectively. + +> [!IMPORTANT] +> SolidusPromotions currently only supports actions that discount line items and shipments, as well as creating discounted line items. If you have actions that create order-level adjustments, we currently have no support for that. + +In our experience, using the three actions can do almost all the things necessary, since they are customizable using calculators. + +Rules share a lot of the previous API. If you make use of `#actionable?`, you might want to migrate your rule to be a line-item level rule: + +```rb +class MyRule < Spree::PromotionRule + def actionable?(promotable) + promotable.quantity > 3 + end +end +``` + +would become: + +```rb +class MyCondition < SolidusPromotions::Condition + include LineItemLevelCondition + + def eligible?(promotable) + promotable.quantity > 3 + end +end +``` + +Now, create your own Promotion conversion map: + +```rb +require 'solidus_promotions/promotion_map' + +MY_PROMOTION_MAP = SolidusPromotions::PROMOTION_MAP.deep_merge( + rules: { + MyRule => MyCondition + } +) +``` + +The value of the conversion map can also be a callable that takes the original promotion rule and should return a new condition. + +```rb +require 'solidus_promotions/promotion_map' + +MY_PROMOTION_MAP = SolidusPromotions::PROMOTION_MAP.deep_merge( + rules: { + MyRule => ->(old_promotion_rule) { + MyCondition.new(preferred_quantity: old_promotion_rule.preferred_count) + } + } +) +``` + +You can now run our migrator with your map: + +```rb +require 'solidus_promotions/promotion_migrator' +require 'my_promotion_map' + +SolidusPromotions::PromotionMigrator.new(MY_PROMOTION_MAP).call +``` diff --git a/promotions/README.md b/promotions/README.md new file mode 100644 index 00000000000..4103f90b2c1 --- /dev/null +++ b/promotions/README.md @@ -0,0 +1,122 @@ +# Solidus Promotions + +This gem contains Solidus' recommended promotion system. It is slated to replace the promotion system in the `legacy_promotions` gem. + +The basic architecture is very similar to the legacy promotion system, but with a few decisive tweaks, which I'll explain in the coming sections. + +## Architecture + +This extension centralizes promotion handling in the order updater. A service class, the `SolidusPromotions::OrderAdjuster` applies the current promotion configuration to the order, adjusting or removing adjustments as necessary. + +`SolidusPromotions::Promotion` objects have benefits, and benefits have conditions. For example, a promotion that is "20% off shirts" would have a benefit of type "AdjustLineItem", and that benefit would have a condition of type "LineItemTaxon" that makes sure only line items with the "shirts" taxon will get the benefit. + +### Promotion lanes + +Promotions get applied by "lane". Promotions within a lane conflict with each other, whereas promotions that do not share a lane will apply sequentially in the order of the lanes. By default these are "pre", "default" and "post", but you can configure this using the SolidusPromotions initializer: + +```rb +SolidusPromotions.configure do |config| + config.preferred_lanes = { + pre: 0, + default: 1, + grogu: 2, + post: 3 + } +end +``` + +### Benefits + +Solidus Friendly Promotions ships with only three benefit types by default that should cover most use cases: `AdjustLineItem`, `AdjustShipment` and `CreateDiscountedItem`. There is no benefit that creates order-level adjustments, as this feature of Solidus' legacy promotions system has proven to be very difficult for customer service and finance departments due to the difficulty of accruing order-level adjustments to individual line items when e.g. processing returns. In order to give a fixed discount to all line items in an order, use the `AdjustLineItem` benefit with the `DistributedAmount` calculator. + +Alle benefits are calculable. By setting their `calculator` to one of the classes provided, a great range of discounts is possible. + +#### `AdjustLineItem` + +Benefits of this class will create promotion adjustments on line items. By default, they will create a discount on every line item in the order. If you want to restrict which line items get the discount, add line-item level conditions, such as `LineItemProduct`. + +#### `AdjustShipment` + +Benefits of this class will create promotion adjustments on shipments. By default, they will create a discount on every shipment in the order. If you want to restrict which shipments get a discount, add shipment-level conditions, such as `ShippingMethod`. + +### Conditions + +Every type of benefit has a list of rules that can be applied to them. When calculating adjustments for an order, benefits will only produce adjustments on line items or shipments if all their respective conditions are true. + +### Connecting promotions to orders + +When there is a join record `SolidusPromotions::OrderPromotion`, the promotion and the order will be "connected", and the promotion will be applied even if it does not `apply_automatically` (see below). This is different from Solidus' legacy promotion system here in that promotions are not automatically connected to orders when they produce an adjustment. + +If you want to create an `OrderPromotion` record, the usual way to do this is via a promotion handler: + +- `SolidusPromotions::PromotionHandler::Coupon`: Connects orders to promotions if a customer or service agent enters a matching promotion code. +- `SolidusPromotions::PromotionHandler::Page`: Connects orders to promotions if a customer visits a page with the correct path. This handler is not integrated in core Solidus, and must be integrated by you. +- `SolidusPromotions::PromotionHandler::Page`: Connects orders to promotions if a customer visits a page with the correct path. This handler is not integrated in core Solidus, and must be integrated by you. + +### Promotion categories + +Promotion categories simply allow admins to group promotions. They have no further significance with regards to the functionality of the promotion system. + +### Promotion recalculation + +Solidus allows changing orders up until when they are shipped. SolidusPromotions therefore will recalculate orders up until when they are shipped as well. If your admins change promotions rather than add new ones and carefully manage the start and end dates of promotions, you might want to disable this feature: + +```rb +SolidusPromotions.configure do |config| + config.recalculate_complete_orders = false +end +``` + +## Installation + +Add solidus_promotions to your Gemfile: + +```ruby +gem 'solidus_promotions' +``` + +Once this project is out of the research phase, a proper gem release will follow. + +Bundle your dependencies and run the installation generator: + +```shell +bin/rails generate solidus_promotions:install +``` + +This will create the tables for this extension. It will also replace the promotion administration system under +`/admin/promotions` with a new one that needs `turbo-rails`. It will also create an initializer within which Solidus is configured to use `Spree::SimpleOrderContents` and this extension's `OrderAdjuster` classes. Feel free to override with your own implementations! + +## Usage + +Add a promotion using the admin. Add benefits with conditions, and observe promotions getting applied how you'd expect them to. + +In the admin screen, you can set a number of attributes on your promotion: +- Name: The name of the promotion. The `name` attribute will also be used to generate adjustment labels. In multi-lingual stores, you probably want different promotions per language for this reason. + +- Description: This is purely informative. Some stores use `description` in order display information about this promotion to customers, but there is nothing in core Solidus that does it. + +- Start and End: Outside of the time range between `starts_at` and `expires_at`, the promotion will not be eligible to any order. + +- Usage Limit: `usage_limit` controls to how many orders this promotion can be applied, independent of promotion code or user. This is most commonly used to limit the risk of losing too much revenue with a particular promotion. + +- Path: `path` is a URL path that connects the promotion to the order upon visitation. Not currently implemented in either Solidus core or this extension. + +- Per Code Usage Limit: How often each code can be used. Useful for limiting how many orders can be placed with a single promotion code. + +- Apply Automatically: Whether this promotion should apply automatically. This means that the promotion is checked for eligibility every time the customer's order is recalculated. Promotion Codes and automatic applications are incompatible. + +- Promotion Category: Used to group promotions in the admin view. + +## Development + +When testing your application's integration with this extension you may use its factories. +You can load Solidus core factories along with this extension's factories using this statement: + +```ruby +SolidusDevSupport::TestingSupport::Factories.load_for(SolidusPromotions::Engine) +``` + + +## License + +Copyright (c) 2024 Martin Meyerhoff, Solidus Team, released under the New BSD License. diff --git a/promotions/Rakefile b/promotions/Rakefile new file mode 100644 index 00000000000..effa28ab40e --- /dev/null +++ b/promotions/Rakefile @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rubygems" +require "rake" +require "rake/testtask" +require "rspec/core/rake_task" +require "solidus_legacy_promotions" +require "spree/testing_support/dummy_app/rake_tasks" +require "solidus_admin/testing_support/dummy_app/rake_tasks" +require "bundler/gem_tasks" + +RSpec::Core::RakeTask.new +task default: :spec + +DummyApp::RakeTasks.new( + gem_root: File.dirname(__FILE__), + lib_name: "solidus_promotions" +) + +task test_app: "db:reset" diff --git a/promotions/app/assets/config/solidus_promotions/manifest.js b/promotions/app/assets/config/solidus_promotions/manifest.js new file mode 100644 index 00000000000..c814bd36fcb --- /dev/null +++ b/promotions/app/assets/config/solidus_promotions/manifest.js @@ -0,0 +1,13 @@ +//= link backend/solidus_promotions.js +//= link backend/solidus_promotions/controllers/application.js +//= link backend/solidus_promotions/controllers/index.js +//= link backend/solidus_promotions/controllers/calculator_tiers_controller.js +//= link backend/solidus_promotions/controllers/flash_controller.js +//= link backend/solidus_promotions/controllers/product_option_values_controller.js +//= link backend/solidus_promotions/web_components/option_value_picker.js +//= link backend/solidus_promotions/web_components/product_picker.js +//= link backend/solidus_promotions/web_components/user_picker.js +//= link backend/solidus_promotions/web_components/taxon_picker.js +//= link backend/solidus_promotions/web_components/variant_picker.js +//= link backend/solidus_promotions/web_components/number_with_currency.js +//= link backend/solidus_promotions/web_components/select_two.js diff --git a/promotions/app/decorators/models/solidus_promotions/adjustment_decorator.rb b/promotions/app/decorators/models/solidus_promotions/adjustment_decorator.rb new file mode 100644 index 00000000000..87295179251 --- /dev/null +++ b/promotions/app/decorators/models/solidus_promotions/adjustment_decorator.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module SolidusPromotions + module AdjustmentDecorator + def self.prepended(base) + base.scope :solidus_promotion, -> { where(source_type: "SolidusPromotions::Benefit") } + end + + Spree::Adjustment.prepend self + end +end diff --git a/promotions/app/decorators/models/solidus_promotions/line_item_decorator.rb b/promotions/app/decorators/models/solidus_promotions/line_item_decorator.rb new file mode 100644 index 00000000000..a1169f0e7ee --- /dev/null +++ b/promotions/app/decorators/models/solidus_promotions/line_item_decorator.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module SolidusPromotions + module LineItemDecorator + def self.prepended(base) + base.attr_accessor :quantity_setter + base.belongs_to :managed_by_order_benefit, class_name: "SolidusPromotions::Benefit", optional: true + base.validate :validate_managed_quantity_same, on: :update + base.after_save :reset_quantity_setter + end + + private + + def validate_managed_quantity_same + if managed_by_order_benefit && quantity_changed? && quantity_setter != managed_by_order_benefit + errors.add(:quantity, :cannot_be_changed_for_automated_items) + end + end + + def reset_quantity_setter + @quantity_setter = nil + end + + Spree::LineItem.prepend self + Spree::LineItem.prepend SolidusPromotions::DiscountableAmount + end +end diff --git a/promotions/app/decorators/models/solidus_promotions/order_decorator.rb b/promotions/app/decorators/models/solidus_promotions/order_decorator.rb new file mode 100644 index 00000000000..08cc180e030 --- /dev/null +++ b/promotions/app/decorators/models/solidus_promotions/order_decorator.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module SolidusPromotions + module OrderDecorator + module ClassMethods + def allowed_ransackable_associations + super + ["solidus_promotions", "solidus_order_promotions"] + end + end + + def self.prepended(base) + base.has_many :solidus_order_promotions, + class_name: "SolidusPromotions::OrderPromotion", + dependent: :destroy, + inverse_of: :order + base.has_many :solidus_promotions, through: :solidus_order_promotions, source: :promotion + end + + def discountable_item_total + line_items.sum(&:discountable_amount) + end + + def reset_current_discounts + line_items.each(&:reset_current_discounts) + shipments.each(&:reset_current_discounts) + end + + # This helper method excludes line items that are managed by an order benefit for the benefit + # of calculators and benefits that discount normal line items. Line items that are managed by an + # order benefits handle their discounts themselves. + def discountable_line_items + line_items.reject(&:managed_by_order_benefit) + end + + def free_from_order_benefit?(line_item, _options) + !line_item.managed_by_order_benefit + end + + Spree::Order.singleton_class.prepend self::ClassMethods + Spree::Order.prepend self + end +end diff --git a/promotions/app/decorators/models/solidus_promotions/order_recalculator_decorator.rb b/promotions/app/decorators/models/solidus_promotions/order_recalculator_decorator.rb new file mode 100644 index 00000000000..ff497e9dba0 --- /dev/null +++ b/promotions/app/decorators/models/solidus_promotions/order_recalculator_decorator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module OrderRecalculatorDecorator + # This is only needed for stores upgrading from the legacy promotion system. + # Once we've removed support for the legacy promotion system, we can remove this. + def recalculate + if SolidusPromotions.config.sync_order_promotions + MigrationSupport::OrderPromotionSyncer.new(order: order).call + end + super + end + Spree::Config.order_recalculator_class.prepend self + end +end diff --git a/promotions/app/decorators/models/solidus_promotions/shipment_decorator.rb b/promotions/app/decorators/models/solidus_promotions/shipment_decorator.rb new file mode 100644 index 00000000000..9df47adb33e --- /dev/null +++ b/promotions/app/decorators/models/solidus_promotions/shipment_decorator.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module SolidusPromotions + module ShipmentDecorator + Spree::Shipment.prepend SolidusPromotions::DiscountableAmount + + def reset_current_discounts + super + shipping_rates.each(&:reset_current_discounts) + end + + Spree::Shipment.prepend self + end +end diff --git a/promotions/app/decorators/models/solidus_promotions/shipping_rate_decorator.rb b/promotions/app/decorators/models/solidus_promotions/shipping_rate_decorator.rb new file mode 100644 index 00000000000..c0687fa6948 --- /dev/null +++ b/promotions/app/decorators/models/solidus_promotions/shipping_rate_decorator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module SolidusPromotions + module ShippingRateDecorator + def self.prepended(base) + base.class_eval do + has_many :discounts, + class_name: "SolidusPromotions::ShippingRateDiscount", + foreign_key: :shipping_rate_id, + dependent: :destroy, + inverse_of: :shipping_rate, + autosave: true + + money_methods :total_before_tax, :promo_total + end + end + + def total_before_tax + amount + promo_total + end + + def promo_total + discounts.sum(&:amount) + end + + Spree::ShippingRate.prepend SolidusPromotions::DiscountableAmount + Spree::ShippingRate.prepend self + end +end diff --git a/promotions/app/helpers/solidus_promotions/admin/benefits_helper.rb b/promotions/app/helpers/solidus_promotions/admin/benefits_helper.rb new file mode 100644 index 00000000000..20b608ad6e5 --- /dev/null +++ b/promotions/app/helpers/solidus_promotions/admin/benefits_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Admin + module BenefitsHelper + def options_for_benefit_calculator_types(benefit) + calculators = benefit.available_calculators + options = calculators.map { |calculator| [calculator.model_name.human, calculator.name] } + options_for_select(options, benefit.calculator_type.to_s) + end + + def options_for_benefit_types(benefit) + benefits = SolidusPromotions.config.benefits + options = benefits.map { |action| [action.model_name.human, action.name] } + options_for_select(options, benefit&.type&.to_s) + end + end + end +end diff --git a/promotions/app/helpers/solidus_promotions/admin/conditions_helper.rb b/promotions/app/helpers/solidus_promotions/admin/conditions_helper.rb new file mode 100644 index 00000000000..dfed62fc0bf --- /dev/null +++ b/promotions/app/helpers/solidus_promotions/admin/conditions_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Admin + module ConditionsHelper + def options_for_condition_types(benefit, condition) + options = benefit.available_conditions.map do |available_condition| + [available_condition.model_name.human, available_condition.name] + end + options_for_select(options, condition&.type.to_s) + end + end + end +end diff --git a/promotions/app/helpers/solidus_promotions/admin/promotions_helper.rb b/promotions/app/helpers/solidus_promotions/admin/promotions_helper.rb new file mode 100644 index 00000000000..8b6616cd2f6 --- /dev/null +++ b/promotions/app/helpers/solidus_promotions/admin/promotions_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Admin + module PromotionsHelper + def admin_promotion_status(promotion) + return :active if promotion.active? + return :not_started if promotion.not_started? + return :expired if promotion.expired? + + :inactive + end + end + end +end diff --git a/promotions/app/javascript/backend/solidus_promotions.js b/promotions/app/javascript/backend/solidus_promotions.js new file mode 100644 index 00000000000..e533069ca7e --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions.js @@ -0,0 +1,11 @@ +import "@hotwired/turbo-rails"; +import "backend/solidus_promotions/controllers"; +import "backend/solidus_promotions/web_components/option_value_picker" +import "backend/solidus_promotions/web_components/product_picker" +import "backend/solidus_promotions/web_components/user_picker" +import "backend/solidus_promotions/web_components/taxon_picker" +import "backend/solidus_promotions/web_components/variant_picker" +import "backend/solidus_promotions/web_components/number_with_currency" +import "backend/solidus_promotions/web_components/select_two" + +Turbo.session.drive = false; diff --git a/promotions/app/javascript/backend/solidus_promotions/controllers/application.js b/promotions/app/javascript/backend/solidus_promotions/controllers/application.js new file mode 100644 index 00000000000..d6fe5ebece7 --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus"; + +const application = Application.start(); + +// Configure Stimulus development experience +application.debug = false; +window.Stimulus = application; + +export { application }; diff --git a/promotions/app/javascript/backend/solidus_promotions/controllers/calculator_tiers_controller.js b/promotions/app/javascript/backend/solidus_promotions/controllers/calculator_tiers_controller.js new file mode 100644 index 00000000000..b977952fbeb --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/controllers/calculator_tiers_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["links", "template"]; + + connect() { + this.wrapperClass = this.data.get("wrapperClass") || "calculator-tiers"; + } + + add_association(event) { + event.preventDefault(); + + var content = this.templateTarget.innerHTML; + this.linksTarget.insertAdjacentHTML("beforebegin", content); + } + + propagate_base_to_value_input(event) { + event.preventDefault(); + + // targets the content of the last pair of square brackets + // we first need to greedily match all other square brackets + const regEx = /(\[.*\])\[.*?\]$/; + let wrapper = event.target.closest("." + this.wrapperClass); + let valueInput = wrapper.querySelector(".js-value-input"); + valueInput.name = valueInput.name.replace( + regEx, + `$1[${event.target.value}]` + ); + } + + remove_association(event) { + event.preventDefault(); + + let wrapper = event.target.closest("." + this.wrapperClass); + wrapper.remove(); + } +} diff --git a/promotions/app/javascript/backend/solidus_promotions/controllers/flash_controller.js b/promotions/app/javascript/backend/solidus_promotions/controllers/flash_controller.js new file mode 100644 index 00000000000..7ee234e7399 --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/controllers/flash_controller.js @@ -0,0 +1,10 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + connect() { + window.show_flash( + this.element.dataset.severity, + this.element.dataset.message + ); + } +} diff --git a/promotions/app/javascript/backend/solidus_promotions/controllers/index.js b/promotions/app/javascript/backend/solidus_promotions/controllers/index.js new file mode 100644 index 00000000000..10fd31f8b2a --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/controllers/index.js @@ -0,0 +1,8 @@ +import { application } from "backend/solidus_promotions/controllers/application"; + +// Eager load all controllers defined in the import map under controllers/**/*_controller +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"; +eagerLoadControllersFrom( + "backend/solidus_promotions/controllers", + application +); diff --git a/promotions/app/javascript/backend/solidus_promotions/controllers/product_option_values_controller.js b/promotions/app/javascript/backend/solidus_promotions/controllers/product_option_values_controller.js new file mode 100644 index 00000000000..3b8f0c9fbe8 --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/controllers/product_option_values_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["links", "template"]; + + connect() { + this.wrapperClass = + this.data.get("wrapperClass") || "promo-condition-option-value"; + + this.element.querySelectorAll("." + this.wrapperClass).forEach((element) => this.buildSelects(element)) + } + + add_row(event) { + event.preventDefault(); + + var content = this.templateTarget.innerHTML; + this.linksTarget.insertAdjacentHTML("beforebegin", content); + this.buildSelects(this.linksTarget.previousElementSibling) + } + + propagate_product_id_to_value_input(event) { + event.preventDefault(); + // targets the content of the last pair of square brackets + // we first need to greedily match all other square brackets + const regEx = /(\[.*\])\[.*?\]$/; + let wrapper = event.target.closest("." + this.wrapperClass); + let optionValuesInput = wrapper.querySelector(".option-values-select[type='hidden']"); + optionValuesInput.name = optionValuesInput.name.replace( + regEx, + `$1[${event.target.value}]` + ); + } + + remove_row(event) { + event.preventDefault(); + + let wrapper = event.target.closest("." + this.wrapperClass); + wrapper.remove(); + } + + // helper functions + + buildSelects(wrapper) { + let productSelect = wrapper.querySelector(".product-select") + let optionValueSelect = wrapper.querySelector(".option-values-select[type='hidden']") + this.buildProductSelect(productSelect) + $(optionValueSelect).optionValueAutocomplete({ productSelect }); + } + + buildProductSelect(productSelect) { + var jQueryProductSelect = $(productSelect) + jQueryProductSelect.productAutocomplete({ + multiple: false, + }) + // capture the jQuery "change" event and re-emit it as DOM event "select2Change" + // so that Stimulus can capture it + jQueryProductSelect.on('change', function () { + let event = new Event('select2Change', { bubbles: true }) // fire a native event + productSelect.dispatchEvent(event); + }); + } +} diff --git a/promotions/app/javascript/backend/solidus_promotions/web_components/number_with_currency.js b/promotions/app/javascript/backend/solidus_promotions/web_components/number_with_currency.js new file mode 100644 index 00000000000..c355c5520f8 --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/web_components/number_with_currency.js @@ -0,0 +1,35 @@ +class NumberWithCurrency extends HTMLElement { + connectedCallback() { + this.currencySelector = this.querySelector('.number-with-currency-select'); + this.render() + this.addEventListener('change', this); + } + + handleEvent() { + this.render() + } + + get currency() { + if (this.currencySelector) { + return this.currencySelector.value; + } else { + return this.querySelector('.number-with-currency-addon')?.dataset?.currency; + } + } + + get currencySymbol() { + const currency = this.currency; + if (currency) { + const currencyInfo = Spree.currencyInfo[currency]; + return currencyInfo[0]; + } else { + return ''; + } + } + + render() { + this.querySelector('.number-with-currency-symbol').textContent = this.currencySymbol; + } +} + +customElements.define('number-with-currency', NumberWithCurrency); diff --git a/promotions/app/javascript/backend/solidus_promotions/web_components/option_value_picker.js b/promotions/app/javascript/backend/solidus_promotions/web_components/option_value_picker.js new file mode 100644 index 00000000000..a5f7d0489a9 --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/web_components/option_value_picker.js @@ -0,0 +1,52 @@ +$.fn.optionValueAutocomplete = function (options) { + 'use strict'; + + // Default options + options = options || {} + var multiple = typeof(options['multiple']) !== 'undefined' ? options['multiple'] : true; + var productSelect = options['productSelect']; + + function formatOptionValue(option_value) { + return Select2.util.escapeMarkup(option_value.name); + } + + this.select2({ + minimumInputLength: 3, + multiple: multiple, + initSelection: function (element, callback) { + $.get(Spree.pathFor('api/option_values'), { + ids: element.val().split(','), + token: Spree.api_key + }, function (data) { + callback(multiple ? data : data[0]); + }); + }, + ajax: { + url: Spree.pathFor('api/option_values'), + datatype: 'json', + data: function (term, page) { + var productId = typeof(productSelect) !== 'undefined' ? $(productSelect).select2('val') : null; + return { + q: { + name_cont: term, + variants_product_id_eq: productId + }, + token: Spree.api_key + }; + }, + results: function (data, page) { + return { results: data }; + } + }, + formatResult: formatOptionValue, + formatSelection: formatOptionValue + }); +}; + +class OptionValuePicker extends HTMLInputElement { + connectedCallback() { + $(this).optionValueAutocomplete(); + } +} + +customElements.define('option-value-picker', OptionValuePicker, { extends: 'input' }); diff --git a/promotions/app/javascript/backend/solidus_promotions/web_components/product_picker.js b/promotions/app/javascript/backend/solidus_promotions/web_components/product_picker.js new file mode 100644 index 00000000000..02cc58e6840 --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/web_components/product_picker.js @@ -0,0 +1,7 @@ +class ProductPicker extends HTMLInputElement { + connectedCallback() { + $(this).productAutocomplete(); + } +} + +customElements.define('product-picker', ProductPicker, { extends: 'input' }); diff --git a/promotions/app/javascript/backend/solidus_promotions/web_components/select_two.js b/promotions/app/javascript/backend/solidus_promotions/web_components/select_two.js new file mode 100644 index 00000000000..7dea7318879 --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/web_components/select_two.js @@ -0,0 +1,11 @@ +class SelectTwo extends HTMLSelectElement { + connectedCallback() { + $(this).select2({ + allowClear: true, + dropdownAutoWidth: true, + minimumResultsForSearch: 8, + }); + } +} + +customElements.define("select-two", SelectTwo, { extends: "select" }); diff --git a/promotions/app/javascript/backend/solidus_promotions/web_components/taxon_picker.js b/promotions/app/javascript/backend/solidus_promotions/web_components/taxon_picker.js new file mode 100644 index 00000000000..3ce30077e0f --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/web_components/taxon_picker.js @@ -0,0 +1,7 @@ +class TaxonPicker extends HTMLInputElement { + connectedCallback() { + $(this).taxonAutocomplete(); + } +} + +customElements.define('taxon-picker', TaxonPicker, { extends: 'input' }); diff --git a/promotions/app/javascript/backend/solidus_promotions/web_components/user_picker.js b/promotions/app/javascript/backend/solidus_promotions/web_components/user_picker.js new file mode 100644 index 00000000000..72114b57a37 --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/web_components/user_picker.js @@ -0,0 +1,7 @@ +class UserPicker extends HTMLInputElement { + connectedCallback() { + $(this).userAutocomplete(); + } +} + +customElements.define('user-picker', UserPicker, { extends: 'input' }); diff --git a/promotions/app/javascript/backend/solidus_promotions/web_components/variant_picker.js b/promotions/app/javascript/backend/solidus_promotions/web_components/variant_picker.js new file mode 100644 index 00000000000..901e38d75f3 --- /dev/null +++ b/promotions/app/javascript/backend/solidus_promotions/web_components/variant_picker.js @@ -0,0 +1,7 @@ +class VariantPicker extends HTMLInputElement { + connectedCallback() { + $(this).variantAutocomplete(); + } +} + +customElements.define('variant-picker', VariantPicker, { extends: 'input' }); diff --git a/promotions/app/jobs/solidus_promotions/promotion_code_batch_job.rb b/promotions/app/jobs/solidus_promotions/promotion_code_batch_job.rb new file mode 100644 index 00000000000..8afa771eeb0 --- /dev/null +++ b/promotions/app/jobs/solidus_promotions/promotion_code_batch_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionCodeBatchJob < ActiveJob::Base + queue_as :default + + def perform(promotion_code_batch) + PromotionCode::BatchBuilder.new( + promotion_code_batch + ).build_promotion_codes + + if promotion_code_batch.email? + SolidusPromotions.config.promotion_code_batch_mailer_class + .promotion_code_batch_finished(promotion_code_batch) + .deliver_now + end + rescue StandardError => e + if promotion_code_batch.email? + SolidusPromotions.config.promotion_code_batch_mailer_class + .promotion_code_batch_errored(promotion_code_batch) + .deliver_now + end + raise e + end + end +end diff --git a/promotions/app/mailers/solidus_promotions/promotion_code_batch_mailer.rb b/promotions/app/mailers/solidus_promotions/promotion_code_batch_mailer.rb new file mode 100644 index 00000000000..4a9b9a0a14b --- /dev/null +++ b/promotions/app/mailers/solidus_promotions/promotion_code_batch_mailer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionCodeBatchMailer < Spree::BaseMailer + def promotion_code_batch_finished(promotion_code_batch) + @promotion_code_batch = promotion_code_batch + mail(to: promotion_code_batch.email) + end + + def promotion_code_batch_errored(promotion_code_batch) + @promotion_code_batch = promotion_code_batch + mail(to: promotion_code_batch.email) + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/benefits/line_item_benefit.rb b/promotions/app/models/concerns/solidus_promotions/benefits/line_item_benefit.rb new file mode 100644 index 00000000000..248e558d336 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/benefits/line_item_benefit.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Benefits + module LineItemBenefit + def can_discount?(object) + object.is_a? Spree::LineItem + end + + def level + :line_item + end + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/benefits/order_benefit.rb b/promotions/app/models/concerns/solidus_promotions/benefits/order_benefit.rb new file mode 100644 index 00000000000..daad550c9d4 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/benefits/order_benefit.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Benefits + module OrderBenefit + def can_discount?(_) + false + end + + def level + :order + end + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/benefits/shipment_benefit.rb b/promotions/app/models/concerns/solidus_promotions/benefits/shipment_benefit.rb new file mode 100644 index 00000000000..86e932ab041 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/benefits/shipment_benefit.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Benefits + module ShipmentBenefit + def can_discount?(object) + object.is_a?(Spree::Shipment) || object.is_a?(Spree::ShippingRate) + end + + def level + :shipment + end + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb b/promotions/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb new file mode 100644 index 00000000000..eaa41cec483 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Calculators + module PromotionCalculator + def description + self.class.human_attribute_name(:description) + end + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/conditions/line_item_applicable_order_level_condition.rb b/promotions/app/models/concerns/solidus_promotions/conditions/line_item_applicable_order_level_condition.rb new file mode 100644 index 00000000000..229a3a73d83 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/conditions/line_item_applicable_order_level_condition.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + module LineItemApplicableOrderLevelCondition + def self.included(klass) + klass.preference :line_item_applicable, :boolean, default: true + end + + def applicable?(promotable) + promotable.is_a?(Spree::Order) || preferred_line_item_applicable && promotable.is_a?(Spree::LineItem) + end + + def eligible?(promotable) + send(:"#{promotable.class.name.demodulize.underscore}_eligible?", promotable) + end + + def level + :order + end + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/conditions/line_item_level_condition.rb b/promotions/app/models/concerns/solidus_promotions/conditions/line_item_level_condition.rb new file mode 100644 index 00000000000..de5f4063910 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/conditions/line_item_level_condition.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + module LineItemLevelCondition + def applicable?(promotable) + promotable.is_a?(Spree::LineItem) + end + + def level + :line_item + end + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/conditions/order_level_condition.rb b/promotions/app/models/concerns/solidus_promotions/conditions/order_level_condition.rb new file mode 100644 index 00000000000..7f84cd874a7 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/conditions/order_level_condition.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + module OrderLevelCondition + def applicable?(promotable) + promotable.is_a?(Spree::Order) + end + + def level + :order + end + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/conditions/shipment_level_condition.rb b/promotions/app/models/concerns/solidus_promotions/conditions/shipment_level_condition.rb new file mode 100644 index 00000000000..87a7dc049fb --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/conditions/shipment_level_condition.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + module ShipmentLevelCondition + def applicable?(promotable) + promotable.is_a?(Spree::Shipment) + end + + def level + :shipment + end + end + end +end diff --git a/promotions/app/models/concerns/solidus_promotions/discountable_amount.rb b/promotions/app/models/concerns/solidus_promotions/discountable_amount.rb new file mode 100644 index 00000000000..f40d86c2628 --- /dev/null +++ b/promotions/app/models/concerns/solidus_promotions/discountable_amount.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module SolidusPromotions + module DiscountableAmount + def discountable_amount + amount + current_discounts.sum(&:amount) + end + + def current_discounts + @current_discounts ||= [] + end + + def current_discounts=(args) + @current_discounts = args + end + + def reset_current_discounts + @current_discounts = [] + end + end +end diff --git a/promotions/app/models/solidus_promotions/benefit.rb b/promotions/app/models/solidus_promotions/benefit.rb new file mode 100644 index 00000000000..679f94ed558 --- /dev/null +++ b/promotions/app/models/solidus_promotions/benefit.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "spree/preferences/persistable" + +module SolidusPromotions + # Base class for all types of benefit. + # + # Benefits perform the necessary tasks when a promotion is activated + # by an event and determined to be eligible. + class Benefit < Spree::Base + include Spree::Preferences::Persistable + include Spree::CalculatedAdjustments + include Spree::AdjustmentSource + before_destroy :remove_adjustments_from_incomplete_orders + before_destroy :raise_for_adjustments_for_completed_orders + + belongs_to :promotion, inverse_of: :benefits + belongs_to :original_promotion_action, class_name: "Spree::PromotionAction", optional: true + has_many :adjustments, class_name: "Spree::Adjustment", as: :source + has_many :shipping_rate_discounts, class_name: "SolidusPromotions::ShippingRateDiscount", inverse_of: :benefit + has_many :conditions, class_name: "SolidusPromotions::Condition", inverse_of: :benefit, dependent: :destroy + + scope :of_type, ->(type) { where(type: Array.wrap(type).map(&:to_s)) } + + def preload_relations + [:calculator] + end + + def can_discount?(object) + raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \ + "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules" + end + + def discount(adjustable) + amount = compute_amount(adjustable) + return if amount.zero? + ItemDiscount.new( + item: adjustable, + label: adjustment_label(adjustable), + amount: amount, + source: self + ) + end + + # Ensure a negative amount which does not exceed the object's amount + def compute_amount(adjustable) + promotion_amount = calculator.compute(adjustable) || BigDecimal("0") + [adjustable.discountable_amount, promotion_amount.abs].min * -1 + end + + def adjustment_label(adjustable) + I18n.t( + "solidus_promotions.adjustment_labels.#{adjustable.class.name.demodulize.underscore}", + promotion: SolidusPromotions::Promotion.model_name.human, + promotion_customer_label: promotion.customer_label + ) + end + + def to_partial_path + "solidus_promotions/admin/benefit_fields/#{model_name.element}" + end + + def level + raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \ + "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules" + end + + def available_conditions + possible_conditions - conditions.select(&:persisted?) + end + + def available_calculators + SolidusPromotions.config.promotion_calculators[self.class] || [] + end + + def eligible_by_applicable_conditions?(promotable, dry_run: false) + applicable_conditions = conditions.select do |condition| + condition.applicable?(promotable) + end + + applicable_conditions.map do |applicable_condition| + eligible = applicable_condition.eligible?(promotable) + + break [false] if !eligible && !dry_run + + if dry_run + if applicable_condition.eligibility_errors.details[:base].first + code = applicable_condition.eligibility_errors.details[:base].first[:error_code] + message = applicable_condition.eligibility_errors.full_messages.first + end + promotion.eligibility_results.add( + item: promotable, + condition: applicable_condition, + success: eligible, + code: eligible ? nil : (code || :coupon_code_unknown_error), + message: eligible ? nil : (message || I18n.t(:coupon_code_unknown_error, scope: [:solidus_promotions, :eligibility_errors])) + ) + end + + eligible + end.all? + end + + def applicable_line_items(order) + order.discountable_line_items.select do |line_item| + eligible_by_applicable_conditions?(line_item) + end + end + + def possible_conditions + Set.new(SolidusPromotions.config.order_conditions) + end + + private + + def raise_for_adjustments_for_completed_orders + if adjustments.joins(:order).merge(Spree::Order.complete).any? + errors.add(:base, :cannot_destroy_if_order_completed) + throw(:abort) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/benefits/adjust_line_item.rb b/promotions/app/models/solidus_promotions/benefits/adjust_line_item.rb new file mode 100644 index 00000000000..a7a58425b70 --- /dev/null +++ b/promotions/app/models/solidus_promotions/benefits/adjust_line_item.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Benefits + class AdjustLineItem < Benefit + include SolidusPromotions::Benefits::LineItemBenefit + + def possible_conditions + super + SolidusPromotions.config.line_item_conditions + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb b/promotions/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb new file mode 100644 index 00000000000..17d196089b8 --- /dev/null +++ b/promotions/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Benefits + class AdjustLineItemQuantityGroups < AdjustLineItem + preference :group_size, :integer, default: 1 + + ## + # Computes the amount for the adjustment based on the line item and any + # other applicable items in the order. The conditions for this specific + # adjustment are as follows: + # + # = Setup + # + # We have a quantity group promotion on t-shirts. If a user orders 3 + # t-shirts, they get $5 off of each. The shirts come in one size and three + # colours: red, blue, and white. + # + # == Scenario 1 + # + # User has 2 red shirts, 1 white shirt, and 1 blue shirt in their + # order. We want to compute the adjustment amount for the white shirt. + # + # *Result:* -$5 + # + # *Reasoning:* There are a total of 4 items that are eligible for the + # promotion. Since that is greater than 3, we can discount the items. The + # white shirt has a quantity of 1, therefore it will get discounted by + # +adjustment_amount * 1+ or $5. + # + # === Scenario 1-1 + # + # What about the blue shirt? How much does it get discounted? + # + # *Result:* $0 + # + # *Reasoning:* We have a total quantity of 4. However, we only apply the + # adjustment to groups of 3. Assuming the white and red shirts have already + # had their adjustment calculated, that means 3 units have been discounted. + # Leaving us with a lonely blue shirt that isn't part of a group of 3. + # Therefore, it does not receive the discount. + # + # == Scenario 2 + # + # User has 4 red shirts in their order. What is the amount? + # + # *Result:* -$15 + # + # *Reasoning:* The total quantity of eligible items is 4, so we the + # adjustment will be non-zero. However, we only apply it to groups of 3, + # therefore there is one extra item that is not eligible for the + # adjustment. +adjustment_amount * 3+ or $15. + # + def compute_amount(line_item) + adjustment_amount = calculator.compute(Item.new(line_item)) + return BigDecimal("0") if adjustment_amount.nil? || adjustment_amount.zero? + + adjustment_amount = adjustment_amount.abs + + order = line_item.order + line_items = applicable_line_items(order) + + item_units = line_items.sort_by do |applicable_line_item| + [-applicable_line_item.quantity, applicable_line_item.id] + end.flat_map do |applicable_line_item| + Array.new(applicable_line_item.quantity) do + Item.new(applicable_line_item) + end + end + + item_units_in_groups = item_units.in_groups_of(preferred_group_size, false) + item_units_in_groups.select! { |group| group.length == preferred_group_size } + usable_quantity = item_units_in_groups.flatten.count { |item_unit| item_unit.line_item == line_item } + + amount = adjustment_amount * usable_quantity + [line_item.discountable_amount, amount].min * -1 + end + + ## + # Used specifically for PercentOnLineItem calculator. That calculator uses + # `line_item.amount`, however we might not necessarily want to discount the + # entire amount. This class allows us to determine the discount per + # quantity and then calculate the adjustment amount the way we normally do + # for flat rate adjustments. + class Item + attr_reader :line_item + + def initialize(line_item) + @line_item = line_item + end + + def discountable_amount + @line_item.discountable_amount / @line_item.quantity.to_d + end + alias_method :amount, :discountable_amount + + def order + @line_item.order + end + + def currency + @line_item.currency + end + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/benefits/adjust_shipment.rb b/promotions/app/models/solidus_promotions/benefits/adjust_shipment.rb new file mode 100644 index 00000000000..00b23eb8912 --- /dev/null +++ b/promotions/app/models/solidus_promotions/benefits/adjust_shipment.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Benefits + class AdjustShipment < Benefit + include SolidusPromotions::Benefits::ShipmentBenefit + + def possible_conditions + super + SolidusPromotions.config.shipment_conditions + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/benefits/create_discounted_item.rb b/promotions/app/models/solidus_promotions/benefits/create_discounted_item.rb new file mode 100644 index 00000000000..1732898bc48 --- /dev/null +++ b/promotions/app/models/solidus_promotions/benefits/create_discounted_item.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Benefits + class CreateDiscountedItem < Benefit + include OrderBenefit + preference :variant_id, :integer + preference :quantity, :integer, default: 1 + preference :necessary_quantity, :integer, default: 1 + + def perform(order) + line_item = find_item(order) || create_item(order) + set_quantity(line_item, determine_item_quantity(order)) + line_item.current_discounts << discount(line_item) + end + + def remove_from(order) + line_item = find_item(order) + order.line_items.destroy(line_item) + end + + private + + def find_item(order) + order.line_items.detect { |line_item| line_item.managed_by_order_benefit == self } + end + + def create_item(order) + order.line_items.create!(quantity: determine_item_quantity(order), variant: variant, managed_by_order_benefit: self) + end + + def determine_item_quantity(order) + # Integer division will floor automatically, which is what we want here: + # 1 Item, 2 needed: 1 * 1 / 2 => 0 + # 5 items, 2 preferred, 2 needed: 5 / 2 * 2 => 4 + applicable_line_items(order).sum(&:quantity) / preferred_necessary_quantity * preferred_quantity + end + + def set_quantity(line_item, quantity) + line_item.quantity_setter = self + line_item.quantity = quantity + end + + def variant + Spree::Variant.find(preferred_variant_id) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/calculators/distributed_amount.rb b/promotions/app/models/solidus_promotions/calculators/distributed_amount.rb new file mode 100644 index 00000000000..112e56ea1ca --- /dev/null +++ b/promotions/app/models/solidus_promotions/calculators/distributed_amount.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_dependency "spree/calculator" + +# This is a calculator for line item adjustment benefits. It accepts a line item +# and calculates its weighted adjustment amount based on the value of the +# preferred amount and the price of the other line items. More expensive line +# items will receive a greater share of the preferred amount. +module SolidusPromotions + module Calculators + class DistributedAmount < Spree::Calculator + include PromotionCalculator + + preference :amount, :decimal, default: 0 + preference :currency, :string, default: -> { Spree::Config[:currency] } + + def compute_line_item(line_item) + return 0 unless line_item + return 0 unless preferred_currency.casecmp(line_item.currency).zero? + + distributable_line_items = calculable.applicable_line_items(line_item.order) + return 0 unless line_item.in?(distributable_line_items) + + DistributedAmountsHandler.new( + distributable_line_items, + preferred_amount + ).amount(line_item) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/calculators/flat_rate.rb b/promotions/app/models/solidus_promotions/calculators/flat_rate.rb new file mode 100644 index 00000000000..627e6ee5b31 --- /dev/null +++ b/promotions/app/models/solidus_promotions/calculators/flat_rate.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_dependency "spree/calculator" + +module SolidusPromotions + module Calculators + class FlatRate < Spree::Calculator + include PromotionCalculator + + preference :amount, :decimal, default: 0 + preference :currency, :string, default: -> { Spree::Config[:currency] } + + def compute(object = nil) + currency = object.order.currency + if object && preferred_currency.casecmp(currency).zero? + preferred_amount + else + 0 + end + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/calculators/flexi_rate.rb b/promotions/app/models/solidus_promotions/calculators/flexi_rate.rb new file mode 100644 index 00000000000..4f7406327db --- /dev/null +++ b/promotions/app/models/solidus_promotions/calculators/flexi_rate.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_dependency "spree/calculator" + +module SolidusPromotions + module Calculators + class FlexiRate < Spree::Calculator + include PromotionCalculator + + preference :first_item, :decimal, default: 0 + preference :additional_item, :decimal, default: 0 + preference :max_items, :integer, default: 0 + preference :currency, :string, default: -> { Spree::Config[:currency] } + + def compute(object) + items_count = object.quantity + items_count = [items_count, preferred_max_items].min unless preferred_max_items.zero? + + return BigDecimal("0") if items_count == 0 + + additional_items_count = items_count - 1 + preferred_first_item + preferred_additional_item * additional_items_count + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/calculators/percent.rb b/promotions/app/models/solidus_promotions/calculators/percent.rb new file mode 100644 index 00000000000..1cfce31ee02 --- /dev/null +++ b/promotions/app/models/solidus_promotions/calculators/percent.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_dependency "spree/calculator" + +module SolidusPromotions + module Calculators + class Percent < Spree::Calculator + include PromotionCalculator + + preference :percent, :decimal, default: 0 + + def compute(object) + preferred_currency = object.order.currency + currency_exponent = ::Money::Currency.find(preferred_currency).exponent + (object.discountable_amount * preferred_percent / 100).round(currency_exponent) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/calculators/tiered_flat_rate.rb b/promotions/app/models/solidus_promotions/calculators/tiered_flat_rate.rb new file mode 100644 index 00000000000..cdbfb67cf37 --- /dev/null +++ b/promotions/app/models/solidus_promotions/calculators/tiered_flat_rate.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_dependency "spree/calculator" + +module SolidusPromotions + module Calculators + class TieredFlatRate < Spree::Calculator + include PromotionCalculator + + preference :base_amount, :decimal, default: 0 + preference :tiers, :hash, default: { 10 => 10 } + preference :currency, :string, default: -> { Spree::Config[:currency] } + + before_validation do + # Convert tier values to decimals. Strings don't do us much good. + if preferred_tiers.is_a?(Hash) + self.preferred_tiers = preferred_tiers.map do |key, value| + [cast_to_d(key.to_s), cast_to_d(value.to_s)] + end.to_h + end + end + + validate :preferred_tiers_content + + def compute_item(object) + _base, amount = preferred_tiers.sort.reverse.detect do |value, _| + object.discountable_amount >= value + end + + if preferred_currency.casecmp(object.currency).zero? + amount || preferred_base_amount + else + 0 + end + end + alias_method :compute_shipment, :compute_item + alias_method :compute_line_item, :compute_item + + private + + def cast_to_d(value) + value.to_s.to_d + rescue ArgumentError + BigDecimal("0") + end + + def preferred_tiers_content + if preferred_tiers.is_a? Hash + unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 } + errors.add(:base, :keys_should_be_positive_number) + end + else + errors.add(:preferred_tiers, :should_be_hash) + end + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/calculators/tiered_percent.rb b/promotions/app/models/solidus_promotions/calculators/tiered_percent.rb new file mode 100644 index 00000000000..2113c3643dc --- /dev/null +++ b/promotions/app/models/solidus_promotions/calculators/tiered_percent.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_dependency "spree/calculator" + +module SolidusPromotions + module Calculators + class TieredPercent < Spree::Calculator + include PromotionCalculator + + preference :base_percent, :decimal, default: 0 + preference :tiers, :hash, default: { 50 => 5 } + preference :currency, :string, default: -> { Spree::Config[:currency] } + + before_validation do + # Convert tier values to decimals. Strings don't do us much good. + if preferred_tiers.is_a?(Hash) + self.preferred_tiers = preferred_tiers.map do |key, value| + [cast_to_d(key.to_s), cast_to_d(value.to_s)] + end.to_h + end + end + + validates :preferred_base_percent, numericality: { + greater_than_or_equal_to: 0, + less_than_or_equal_to: 100 + } + validate :preferred_tiers_content + + def compute_item(object) + order = object.order + + _base, percent = preferred_tiers.sort.reverse.detect do |value, _| + order.item_total >= value + end + + if preferred_currency.casecmp(order.currency).zero? + currency_exponent = ::Money::Currency.find(preferred_currency).exponent + (object.amount * (percent || preferred_base_percent) / 100).round(currency_exponent) + else + 0 + end + end + alias_method :compute_shipment, :compute_item + alias_method :compute_line_item, :compute_item + + private + + def cast_to_d(value) + value.to_s.to_d + rescue ArgumentError + BigDecimal("0") + end + + def preferred_tiers_content + if preferred_tiers.is_a? Hash + unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 } + errors.add(:base, :keys_should_be_positive_number) + end + unless preferred_tiers.values.all? { |key| key.is_a?(Numeric) && key >= 0 && key <= 100 } + errors.add(:base, :values_should_be_percent) + end + else + errors.add(:preferred_tiers, :should_be_hash) + end + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb b/promotions/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb new file mode 100644 index 00000000000..e8568b280f1 --- /dev/null +++ b/promotions/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_dependency "spree/calculator" + +module SolidusPromotions + module Calculators + class TieredPercentOnEligibleItemQuantity < SolidusPromotions::Calculators::TieredPercent + preference :tiers, :hash, default: { 10 => 5 } + + before_validation do + # Convert tier values to decimals. Strings don't do us much good. + if preferred_tiers.is_a?(Hash) + self.preferred_tiers = preferred_tiers.map do |key, value| + [key.to_i, cast_to_d(value.to_s)] + end.to_h + end + end + + def compute_line_item(line_item) + order = line_item.order + + _base, percent = preferred_tiers.sort.reverse.detect do |value, _| + eligible_line_items_quantity_total(order) >= value + end + if preferred_currency.casecmp(order.currency).zero? + currency_exponent = ::Money::Currency.find(preferred_currency).exponent + (line_item.discountable_amount * (percent || preferred_base_percent) / 100).round(currency_exponent) + else + 0 + end + end + + private + + def eligible_line_items_quantity_total(order) + calculable.applicable_line_items(order).sum(&:quantity) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/condition.rb b/promotions/app/models/solidus_promotions/condition.rb new file mode 100644 index 00000000000..a1264ab99c2 --- /dev/null +++ b/promotions/app/models/solidus_promotions/condition.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "spree/preferences/persistable" + +module SolidusPromotions + class Condition < Spree::Base + include Spree::Preferences::Persistable + + belongs_to :benefit, class_name: "SolidusPromotions::Benefit", inverse_of: :conditions, optional: true + has_one :promotion, through: :benefit + + scope :of_type, ->(type) { where(type: type) } + + validate :unique_per_benefit, on: :create + validate :possible_condition_for_benefit, if: -> { benefit.present? } + + def preload_relations + [] + end + + def applicable?(_promotable) + raise NotImplementedError, "applicable? should be implemented in a sub-class of SolidusPromotions::Rule" + end + + def eligible?(_promotable, _options = {}) + raise NotImplementedError, "eligible? should be implemented in a sub-class of SolidusPromotions::Rule" + end + + def level + raise NotImplementedError, "level should be implemented in a sub-class of SolidusPromotions::Rule" + end + + def eligibility_errors + @eligibility_errors ||= ActiveModel::Errors.new(self) + end + + def to_partial_path + "solidus_promotions/admin/condition_fields/#{model_name.element}" + end + + def updateable? + preferences.any? + end + + private + + def unique_per_benefit + return unless self.class.exists?(benefit_id: benefit_id, type: self.class.name) + + errors.add(:benefit, :already_contains_condition_type) + end + + def possible_condition_for_benefit + benefit.possible_conditions.include?(self.class) || errors.add(:type, :invalid_condition_type) + end + + def eligibility_error_message(key, options = {}) + I18n.t(key, scope: [:solidus_promotions, :eligibility_errors, self.class.name.underscore], **options) + end + end +end diff --git a/promotions/app/models/solidus_promotions/condition_product.rb b/promotions/app/models/solidus_promotions/condition_product.rb new file mode 100644 index 00000000000..d4cde26d5f0 --- /dev/null +++ b/promotions/app/models/solidus_promotions/condition_product.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module SolidusPromotions + class ConditionProduct < Spree::Base + belongs_to :condition, class_name: "SolidusPromotions::Condition", optional: true + belongs_to :product, class_name: "Spree::Product", optional: true + end +end diff --git a/promotions/app/models/solidus_promotions/condition_store.rb b/promotions/app/models/solidus_promotions/condition_store.rb new file mode 100644 index 00000000000..37772369e08 --- /dev/null +++ b/promotions/app/models/solidus_promotions/condition_store.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module SolidusPromotions + class ConditionStore < Spree::Base + belongs_to :condition, class_name: "SolidusPromotions::Condition", optional: true + belongs_to :store, class_name: "Spree::Store", optional: true + end +end diff --git a/promotions/app/models/solidus_promotions/condition_taxon.rb b/promotions/app/models/solidus_promotions/condition_taxon.rb new file mode 100644 index 00000000000..6a65d98de51 --- /dev/null +++ b/promotions/app/models/solidus_promotions/condition_taxon.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module SolidusPromotions + class ConditionTaxon < Spree::Base + belongs_to :condition, class_name: "SolidusPromotions::Condition", optional: true + belongs_to :taxon, class_name: "Spree::Taxon", optional: true + end +end diff --git a/promotions/app/models/solidus_promotions/condition_user.rb b/promotions/app/models/solidus_promotions/condition_user.rb new file mode 100644 index 00000000000..7fe0c89b75f --- /dev/null +++ b/promotions/app/models/solidus_promotions/condition_user.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module SolidusPromotions + class ConditionUser < Spree::Base + belongs_to :condition, class_name: "SolidusPromotions::Condition", optional: true + belongs_to :user, class_name: Spree::UserClassHandle.new, optional: true + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/discounted_item_total.rb b/promotions/app/models/solidus_promotions/conditions/discounted_item_total.rb new file mode 100644 index 00000000000..8c25dbbba09 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/discounted_item_total.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + # A condition to apply to an order greater than (or greater than or equal to) + # a specific amount after previous promotions have applied + # + # To add extra operators please override `self.operators_map` or any other helper method. + # To customize the error message you can also override `ineligible_message`. + class DiscountedItemTotal < ItemTotal + def to_partial_path + "solidus_promotions/admin/condition_fields/item_total" + end + + private + + def total_for_order(order) + order.discountable_item_total + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/first_order.rb b/promotions/app/models/solidus_promotions/conditions/first_order.rb new file mode 100644 index 00000000000..6e9942825b3 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/first_order.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class FirstOrder < Condition + include OrderLevelCondition + attr_reader :user, :email + + def eligible?(order, options = {}) + @user = order.try(:user) || options[:user] + @email = order.email + + if (user || email) && (completed_orders.present? && completed_orders.first != order) + eligibility_errors.add(:base, eligibility_error_message(:not_first_order), error_code: :not_first_order) + end + + eligibility_errors.empty? + end + + private + + def completed_orders + user ? user.orders.complete : orders_by_email + end + + def orders_by_email + Spree::Order.where(email: email).complete + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/first_repeat_purchase_since.rb b/promotions/app/models/solidus_promotions/conditions/first_repeat_purchase_since.rb new file mode 100644 index 00000000000..d561c7671d9 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/first_repeat_purchase_since.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class FirstRepeatPurchaseSince < Condition + include OrderLevelCondition + + preference :days_ago, :integer, default: 365 + validates :preferred_days_ago, numericality: { only_integer: true, greater_than: 0 } + + # This is never eligible if the order does not have a user, and that user does not have any previous completed orders. + # + # This is eligible if the user's most recently completed order is more than the preferred days ago + # @param order [Spree::Order] + def eligible?(order, _options = {}) + return false unless order.user + + last_order = last_completed_order(order.user) + return false unless last_order + + last_order.completed_at < preferred_days_ago.days.ago + end + + private + + def last_completed_order(user) + user.orders.complete.order(:completed_at).last + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/item_total.rb b/promotions/app/models/solidus_promotions/conditions/item_total.rb new file mode 100644 index 00000000000..c9d4ea6aee5 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/item_total.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + # A condition to apply to an order greater than (or greater than or equal to) + # a specific amount + # + # To add extra operators please override `self.operators_map` or any other helper method. + # To customize the error message you can also override `ineligible_message`. + class ItemTotal < Condition + include OrderLevelCondition + + preference :amount, :decimal, default: 100.00 + preference :currency, :string, default: -> { Spree::Config[:currency] } + preference :operator, :string, default: "gt" + + # The list of allowed operators names mapped to their symbols. + def self.operators_map + { + gte: :>=, + gt: :> + } + end + + def self.operator_options + operators_map.map do |name, _method| + [I18n.t(name, scope: "solidus_promotions.item_total_condition.operators"), name] + end + end + + def eligible?(order, _options = {}) + return false unless order.currency == preferred_currency + + unless total_for_order(order).send(operator, threshold) + eligibility_errors.add(:base, ineligible_message, error_code: ineligible_error_code) + end + + eligibility_errors.empty? + end + + private + + def operator + self.class.operators_map.fetch( + preferred_operator.to_sym, + preferred_operator_default + ) + end + + def total_for_order(order) + order.item_total + end + + def threshold + BigDecimal(preferred_amount.to_s) + end + + def formatted_amount + Spree::Money.new(preferred_amount, currency: preferred_currency).to_s + end + + def ineligible_message + eligibility_error_message(ineligible_error_code, amount: formatted_amount) + end + + def ineligible_error_code + if preferred_operator == "gte" + :item_total_less_than + else + :item_total_less_than_or_equal + end + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/line_item_option_value.rb b/promotions/app/models/solidus_promotions/conditions/line_item_option_value.rb new file mode 100644 index 00000000000..c548b8b5311 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/line_item_option_value.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class LineItemOptionValue < Condition + include LineItemLevelCondition + + preference :eligible_values, :hash + + def eligible?(line_item, _options = {}) + pid = line_item.product.id + ovids = line_item.variant.option_values.pluck(:id) + + product_ids.include?(pid) && (value_ids(pid) & ovids).present? + end + + def preferred_eligible_values + values = preferences[:eligible_values] || {} + values.keys.map(&:to_i).zip( + values.values.map do |value| + (value.is_a?(Array) ? value : value.split(",")).map(&:to_i) + end + ).to_h + end + + private + + def product_ids + preferred_eligible_values.keys + end + + def value_ids(product_id) + preferred_eligible_values[product_id] + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/line_item_product.rb b/promotions/app/models/solidus_promotions/conditions/line_item_product.rb new file mode 100644 index 00000000000..4c3e1a82f84 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/line_item_product.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + # A condition to apply a promotion only to line items with or without a chosen product + class LineItemProduct < Condition + include LineItemLevelCondition + + MATCH_POLICIES = %w[include exclude].freeze + + has_many :condition_products, + dependent: :destroy, + foreign_key: :condition_id, + class_name: "SolidusPromotions::ConditionProduct" + has_many :products, + class_name: "Spree::Product", + through: :condition_products + + preference :match_policy, :string, default: MATCH_POLICIES.first + + def preload_relations + [:products] + end + + def eligible?(line_item, _options = {}) + order_includes_product = product_ids.include?(line_item.variant.product_id) + success = inverse? ? !order_includes_product : order_includes_product + + unless success + message_code = inverse? ? :has_excluded_product : :no_applicable_products + eligibility_errors.add( + :base, + eligibility_error_message(message_code), + error_code: message_code + ) + end + + success + end + + def product_ids_string + product_ids.join(",") + end + + def product_ids_string=(product_ids) + self.product_ids = product_ids.to_s.split(",").map(&:strip) + end + + private + + def inverse? + preferred_match_policy == "exclude" + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/line_item_taxon.rb b/promotions/app/models/solidus_promotions/conditions/line_item_taxon.rb new file mode 100644 index 00000000000..c304d080edb --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/line_item_taxon.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class LineItemTaxon < Condition + include LineItemLevelCondition + + has_many :condition_taxons, class_name: "SolidusPromotions::ConditionTaxon", foreign_key: :condition_id, + dependent: :destroy + has_many :taxons, through: :condition_taxons, class_name: "Spree::Taxon" + + MATCH_POLICIES = %w[include exclude].freeze + + validates :preferred_match_policy, inclusion: { in: MATCH_POLICIES } + + preference :match_policy, :string, default: MATCH_POLICIES.first + + def preload_relations + [:taxons] + end + + def eligible?(line_item, _options = {}) + found = Spree::Classification.where( + product_id: line_item.variant.product_id, + taxon_id: condition_taxon_ids_with_children + ).exists? + + case preferred_match_policy + when "include" + found + when "exclude" + !found + else + raise "unexpected match policy: #{preferred_match_policy.inspect}" + end + end + + def taxon_ids_string + taxons.pluck(:id).join(",") + end + + def taxon_ids_string=(taxon_ids) + taxon_ids = taxon_ids.to_s.split(",").map(&:strip) + self.taxons = Spree::Taxon.find(taxon_ids) + end + + def updateable? + true + end + + private + + # ids of taxons conditions and taxons conditions children + def condition_taxon_ids_with_children + taxons.flat_map { |taxon| taxon.self_and_descendants.ids }.uniq + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/minimum_quantity.rb b/promotions/app/models/solidus_promotions/conditions/minimum_quantity.rb new file mode 100644 index 00000000000..06a3ddcc694 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/minimum_quantity.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + # Promotion condition for ensuring an order contains a minimum quantity of + # applicable items. + # + # This promotion condition is only compatible with the "all" match policy. It + # doesn't make a lot of sense to use it without that policy as it reduces + # it to a simple quantity check across the entire order which would be + # better served by an item total condition. + class MinimumQuantity < Condition + include OrderLevelCondition + + validates :preferred_minimum_quantity, numericality: { only_integer: true, greater_than: 0 } + + preference :minimum_quantity, :integer, default: 1 + + # Will look at all of the "applicable" line items in the order and + # determine if the sum of their quantity is greater than the minimum. + # + # "Applicable" items are ones that pass all eligibility checks of applicable conditions. + # + # When false is returned, the reason will be included in the + # `eligibility_errors` object. + # + # @param order [Spree::Order] the order we want to check eligibility on + # @return [Boolean] true if promotion is eligible, false otherwise + def eligible?(order) + if benefit.applicable_line_items(order).sum(&:quantity) < preferred_minimum_quantity + eligibility_errors.add( + :base, + eligibility_error_message(:quantity_less_than_minimum, count: preferred_minimum_quantity), + error_code: :quantity_less_than_minimum + ) + end + + eligibility_errors.empty? + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/nth_order.rb b/promotions/app/models/solidus_promotions/conditions/nth_order.rb new file mode 100644 index 00000000000..de4f8927343 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/nth_order.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class NthOrder < Condition + include OrderLevelCondition + + preference :nth_order, :integer, default: 2 + # It does not make sense to have this apply to the first order using preferred_nth_order == 1 + # Instead we could use the first_order condition + validates :preferred_nth_order, numericality: { only_integer: true, greater_than: 1 } + + # This is never eligible if the order does not have a user, and that user does not have any previous completed orders. + # + # Use the first order condition if you want a promotion to be applied to the first order for a user. + # @param order [Spree::Order] + def eligible?(order, _options = {}) + return false unless order.user + + nth_order?(order) + end + + private + + def completed_order_count(order) + order + .user + .orders + .complete + .where(Spree::Order.arel_table[:completed_at].lt(order.completed_at || Time.current)) + .count + end + + def nth_order?(order) + count = completed_order_count(order) + 1 + count == preferred_nth_order + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/one_use_per_user.rb b/promotions/app/models/solidus_promotions/conditions/one_use_per_user.rb new file mode 100644 index 00000000000..43a78b30162 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/one_use_per_user.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class OneUsePerUser < Condition + include OrderLevelCondition + + def eligible?(order, _options = {}) + if order.user.present? + if promotion.used_by?(order.user, [order]) + eligibility_errors.add( + :base, + eligibility_error_message(:limit_once_per_user), + error_code: :limit_once_per_user + ) + end + else + eligibility_errors.add(:base, eligibility_error_message(:no_user_specified), error_code: :no_user_specified) + end + + eligibility_errors.empty? + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/option_value.rb b/promotions/app/models/solidus_promotions/conditions/option_value.rb new file mode 100644 index 00000000000..085cbfaa556 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/option_value.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class OptionValue < Condition + include LineItemApplicableOrderLevelCondition + + preference :eligible_values, :hash + + def order_eligible?(order) + order.line_items.any? { |item| line_item_eligible?(item) } + end + + def line_item_eligible?(line_item) + LineItemOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(line_item) + end + + def preferred_eligible_values + values = preferences[:eligible_values] || {} + values.keys.map(&:to_i).zip( + values.values.map do |value| + (value.is_a?(Array) ? value : value.split(",")).map(&:to_i) + end + ).to_h + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/product.rb b/promotions/app/models/solidus_promotions/conditions/product.rb new file mode 100644 index 00000000000..d529cb6cca7 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/product.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + # A condition to limit a promotion based on products in the order. Can + # require all or any of the products to be present. Valid products + # either come from assigned product group or are assingned directly to + # the condition. + class Product < Condition + include LineItemApplicableOrderLevelCondition + + has_many :condition_products, + dependent: :destroy, + foreign_key: :condition_id, + class_name: "SolidusPromotions::ConditionProduct" + has_many :products, class_name: "Spree::Product", through: :condition_products + + def preload_relations + [:products] + end + + MATCH_POLICIES = %w[any all none only].freeze + + validates :preferred_match_policy, inclusion: { in: MATCH_POLICIES } + + preference :match_policy, :string, default: MATCH_POLICIES.first + + # scope/association that is used to test eligibility + def eligible_products + products + end + + def order_eligible?(order) + return true if eligible_products.empty? + + case preferred_match_policy + when "all" + unless eligible_products.all? { |product| order_products(order).include?(product) } + eligibility_errors.add(:base, eligibility_error_message(:missing_product), error_code: :missing_product) + end + when "any" + unless order_products(order).any? { |product| eligible_products.include?(product) } + eligibility_errors.add(:base, eligibility_error_message(:no_applicable_products), + error_code: :no_applicable_products) + end + when "none" + unless order_products(order).none? { |product| eligible_products.include?(product) } + eligibility_errors.add(:base, eligibility_error_message(:has_excluded_product), + error_code: :has_excluded_product) + end + when "only" + unless order_products(order).all? { |product| eligible_products.include?(product) } + eligibility_errors.add(:base, eligibility_error_message(:has_excluded_product), + error_code: :has_excluded_product) + end + else + raise "unexpected match policy: #{preferred_match_policy.inspect}" + end + + eligibility_errors.empty? + end + + def line_item_eligible?(line_item, _options = {}) + # The order level eligibility check happens first, and if none of the products + # are in the order, then no line items should be available to check. + raise "This should not happen" if preferred_match_policy == "none" + product_ids.include?(line_item.variant.product_id) + end + + def product_ids_string + product_ids.join(",") + end + + def product_ids_string=(product_ids) + self.product_ids = product_ids.to_s.split(",").map(&:strip) + end + + private + + def order_products(order) + order.line_items.map(&:variant).map(&:product) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/shipping_method.rb b/promotions/app/models/solidus_promotions/conditions/shipping_method.rb new file mode 100644 index 00000000000..08430d64ae4 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/shipping_method.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class ShippingMethod < Condition + include ShipmentLevelCondition + + preference :shipping_method_ids, type: :array, default: [] + + def applicable?(promotable) + promotable.is_a?(Spree::Shipment) || promotable.is_a?(Spree::ShippingRate) + end + + def eligible?(promotable) + promotable.shipping_method&.id&.in?(preferred_shipping_method_ids.map(&:to_i)) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/store.rb b/promotions/app/models/solidus_promotions/conditions/store.rb new file mode 100644 index 00000000000..7d832e0c0ff --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/store.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class Store < Condition + include OrderLevelCondition + + has_many :condition_stores, class_name: "SolidusPromotions::ConditionStore", + foreign_key: :condition_id, + dependent: :destroy + has_many :stores, through: :condition_stores, class_name: "Spree::Store" + + def preload_relations + [:stores] + end + + def eligible?(order, _options = {}) + stores.none? || stores.include?(order.store) + end + + def updateable? + true + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/taxon.rb b/promotions/app/models/solidus_promotions/conditions/taxon.rb new file mode 100644 index 00000000000..c0e6aca7a1f --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/taxon.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class Taxon < Condition + include LineItemApplicableOrderLevelCondition + + has_many :condition_taxons, class_name: "SolidusPromotions::ConditionTaxon", foreign_key: :condition_id, + dependent: :destroy + has_many :taxons, through: :condition_taxons, class_name: "Spree::Taxon" + + def preload_relations + [:taxons] + end + + MATCH_POLICIES = %w[any all none].freeze + + validates :preferred_match_policy, inclusion: { in: MATCH_POLICIES } + + preference :match_policy, :string, default: MATCH_POLICIES.first + + def order_eligible?(order) + order_taxons = taxons_in_order(order) + + case preferred_match_policy + when "all" + matches_all = taxons.all? do |condition_taxon| + order_taxons.where(id: condition_taxon.self_and_descendants.ids).exists? + end + + unless matches_all + eligibility_errors.add(:base, eligibility_error_message(:missing_taxon), error_code: :missing_taxon) + end + when "any" + unless order_taxons.where(id: condition_taxon_ids_with_children).exists? + eligibility_errors.add( + :base, + eligibility_error_message(:no_matching_taxons), + error_code: :no_matching_taxons + ) + end + when "none" + if order_taxons.where(id: condition_taxon_ids_with_children).exists? + eligibility_errors.add( + :base, + eligibility_error_message(:has_excluded_taxon), + error_code: :has_excluded_taxon + ) + end + else + raise "unexpected match policy: #{preferred_match_policy.inspect}" + end + + eligibility_errors.empty? + end + + def line_item_eligible?(line_item) + # The order level eligibility check happens first, and if none of the taxons + # are in the order, then no line items should be available to check. + raise "This should not happen" if preferred_match_policy == "none" + + raise "unexpected match policy: #{preferred_match_policy.inspect}" unless preferred_match_policy.in?(MATCH_POLICIES) + + Spree::Classification.where( + product_id: line_item.variant.product_id, + taxon_id: condition_taxon_ids_with_children + ).exists? + end + + def taxon_ids_string + taxon_ids.join(",") + end + + def taxon_ids_string=(taxon_ids) + self.taxon_ids = taxon_ids.to_s.split(",").map(&:strip) + end + + def updateable? + true + end + + private + + # All taxons in an order + def taxons_in_order(order) + Spree::Taxon + .joins(products: { variants_including_master: :line_items }) + .where(spree_line_items: { order_id: order.id }) + .distinct + end + + # ids of taxons conditions and taxons conditions children + def condition_taxon_ids_with_children + taxons.flat_map { |taxon| taxon.self_and_descendants.ids }.uniq + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/user.rb b/promotions/app/models/solidus_promotions/conditions/user.rb new file mode 100644 index 00000000000..997d1b94a9d --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/user.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class User < Condition + include OrderLevelCondition + + has_many :condition_users, + class_name: "SolidusPromotions::ConditionUser", + foreign_key: :condition_id, + dependent: :destroy + has_many :users, through: :condition_users, class_name: Spree::UserClassHandle.new + + def preload_relations + [:users] + end + + def eligible?(order, _options = {}) + users.include?(order.user) + end + + def user_ids_string + user_ids.join(",") + end + + def user_ids_string=(user_ids) + self.user_ids = user_ids.to_s.split(",").map(&:strip) + end + + def updateable? + true + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/user_logged_in.rb b/promotions/app/models/solidus_promotions/conditions/user_logged_in.rb new file mode 100644 index 00000000000..45db92487cc --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/user_logged_in.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class UserLoggedIn < Condition + include OrderLevelCondition + + def eligible?(order, _options = {}) + if order.user.blank? + eligibility_errors.add(:base, eligibility_error_message(:no_user_specified), error_code: :no_user_specified) + end + eligibility_errors.empty? + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/conditions/user_role.rb b/promotions/app/models/solidus_promotions/conditions/user_role.rb new file mode 100644 index 00000000000..d4c816d0429 --- /dev/null +++ b/promotions/app/models/solidus_promotions/conditions/user_role.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Conditions + class UserRole < Condition + include OrderLevelCondition + + preference :role_ids, :array, default: [] + + MATCH_POLICIES = %w[any all].freeze + preference :match_policy, default: MATCH_POLICIES.first + + def eligible?(order, _options = {}) + return false unless order.user + + if all_match_policy? + match_all_roles?(order) + else + match_any_roles?(order) + end + end + + private + + def all_match_policy? + preferred_match_policy == "all" && preferred_role_ids.present? + end + + def user_roles(order) + order.user.spree_roles.where(id: preferred_role_ids) + end + + def match_all_roles?(order) + user_roles(order).count == preferred_role_ids.count + end + + def match_any_roles?(order) + user_roles(order).exists? + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/distributed_amounts_handler.rb b/promotions/app/models/solidus_promotions/distributed_amounts_handler.rb new file mode 100644 index 00000000000..74b583c7853 --- /dev/null +++ b/promotions/app/models/solidus_promotions/distributed_amounts_handler.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module SolidusPromotions + class DistributedAmountsHandler + attr_reader :line_items, :total_amount + + def initialize(line_items, total_amount) + @line_items = line_items + @total_amount = total_amount + end + + # @param line_item [LineItem] one of the line_items distributed over + # @return [BigDecimal] the weighted adjustment for this line_item + def amount(line_item) + distributed_amounts[line_item.id].to_d + end + + private + + # @private + # @return [Hash] a hash of line item IDs and their + # corresponding weighted adjustments + def distributed_amounts + line_item_ids.zip(allocated_amounts).to_h + end + + def line_item_ids + line_items.map(&:id) + end + + def elligible_amounts + line_items.map(&:discountable_amount) + end + + def allocated_amounts + total_amount.to_money.allocate(elligible_amounts).map(&:to_money) + end + end +end diff --git a/promotions/app/models/solidus_promotions/eligibility_result.rb b/promotions/app/models/solidus_promotions/eligibility_result.rb new file mode 100644 index 00000000000..7a3c10cb0cf --- /dev/null +++ b/promotions/app/models/solidus_promotions/eligibility_result.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module SolidusPromotions + EligibilityResult = Struct.new(:item, :condition, :success, :code, :message, keyword_init: true) +end diff --git a/promotions/app/models/solidus_promotions/eligibility_results.rb b/promotions/app/models/solidus_promotions/eligibility_results.rb new file mode 100644 index 00000000000..8c62d0f695b --- /dev/null +++ b/promotions/app/models/solidus_promotions/eligibility_results.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module SolidusPromotions + class EligibilityResults + include Enumerable + attr_reader :results, :promotion + + def initialize(promotion) + @promotion = promotion + @results = [] + end + + def add(item:, condition:, success:, code:, message:) + results << EligibilityResult.new( + item: item, + condition: condition, + success: success, + code: code, + message: message + ) + end + + def success? + return true if results.empty? + promotion.benefits.any? do |benefit| + benefit.conditions.all? do |condition| + results_for_condition = results.select { |result| result.condition == condition } + results_for_condition.any?(&:success) + end + end + end + + def error_messages + return [] if results.empty? + results.group_by(&:condition).map do |_condition, results| + next if results.any?(&:success) + results.detect { |r| !r.success }&.message + end.compact + end + + def each(&block) + results.each(&block) + end + + delegate :last, to: :results + end +end diff --git a/promotions/app/models/solidus_promotions/item_discount.rb b/promotions/app/models/solidus_promotions/item_discount.rb new file mode 100644 index 00000000000..2f680c6e5d3 --- /dev/null +++ b/promotions/app/models/solidus_promotions/item_discount.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module SolidusPromotions + # Simple object used to hold discount data for an item. + # + # This generic object will hold the amount of discount that should be applied to + # an item. + # + # @attr_reader [Spree::LineItem,Spree::Shipment] the item to be discounted. + # @attr_reader [String] label information about the discount + # @attr_reader [ApplicationRecord] source will be used as the source for adjustments + # @attr_reader [BigDecimal] amount the amount of discount applied to the item + class ItemDiscount + include ActiveModel::Model + attr_accessor :item, :label, :source, :amount + + def ==(other) + item == other.item && label == other.label && source == other.source && amount == other.amount + end + end +end diff --git a/promotions/app/models/solidus_promotions/migration_support/order_promotion_syncer.rb b/promotions/app/models/solidus_promotions/migration_support/order_promotion_syncer.rb new file mode 100644 index 00000000000..4e5db30373c --- /dev/null +++ b/promotions/app/models/solidus_promotions/migration_support/order_promotion_syncer.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module SolidusPromotions + module MigrationSupport + class OrderPromotionSyncer + attr_reader :order + + def initialize(order:) + @order = order + end + + def call + sync_spree_order_promotions_to_solidus_order_promotions + sync_solidus_order_promotions_to_spree_order_promotions + end + + private + + def sync_spree_order_promotions_to_solidus_order_promotions + order.order_promotions.each do |spree_order_promotion| + solidus_promotion = SolidusPromotions::Promotion.find_by( + original_promotion_id: spree_order_promotion.promotion.id + ) + next unless solidus_promotion + if spree_order_promotion.promotion_code + solidus_promotion_code = solidus_promotion.codes.find_by( + value: spree_order_promotion.promotion_code.value + ) + end + order.solidus_order_promotions.find_or_create_by!( + promotion: solidus_promotion, + promotion_code: solidus_promotion_code + ) + end + end + + def sync_solidus_order_promotions_to_spree_order_promotions + order.solidus_order_promotions.each do |solidus_order_promotion| + spree_promotion = solidus_order_promotion.promotion.original_promotion + next unless spree_promotion + if solidus_order_promotion.promotion_code + spree_promotion_code = spree_promotion.promotion_codes.find_by( + value: solidus_order_promotion.promotion_code.value + ) + end + order.order_promotions.find_or_create_by!( + promotion: spree_promotion, + promotion_code: spree_promotion_code + ) + end + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/order_adjuster.rb b/promotions/app/models/solidus_promotions/order_adjuster.rb new file mode 100644 index 00000000000..8769670be57 --- /dev/null +++ b/promotions/app/models/solidus_promotions/order_adjuster.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module SolidusPromotions + class OrderAdjuster + attr_reader :order, :promotions, :dry_run + + def initialize(order, dry_run_promotion: nil) + @order = order + @dry_run = !!dry_run_promotion + @promotions = LoadPromotions.new(order: order, dry_run_promotion: dry_run_promotion).call + end + + def call + order.reset_current_discounts + + return order if (!SolidusPromotions.config.recalculate_complete_orders && order.complete?) || order.shipped? + + discounted_order = DiscountOrder.new(order, promotions, dry_run: dry_run).call + + PersistDiscountedOrder.new(discounted_order).call unless dry_run + + order.reset_current_discounts + + unless dry_run + # Since automations might have added a line item, we need to recalculate item total and item count here. + order.item_total = order.line_items.sum(&:amount) + order.item_count = order.line_items.sum(&:quantity) + order.promo_total = (order.line_items + order.shipments).sum(&:promo_total) + end + order + end + end +end diff --git a/promotions/app/models/solidus_promotions/order_adjuster/choose_discounts.rb b/promotions/app/models/solidus_promotions/order_adjuster/choose_discounts.rb new file mode 100644 index 00000000000..be23f8a76ba --- /dev/null +++ b/promotions/app/models/solidus_promotions/order_adjuster/choose_discounts.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module SolidusPromotions + class OrderAdjuster + class ChooseDiscounts + attr_reader :discounts + + def initialize(discounts) + @discounts = discounts + end + + def call + Array.wrap( + discounts.min_by do |discount| + [discount.amount, -discount.source&.id.to_i] + end + ) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/order_adjuster/discount_order.rb b/promotions/app/models/solidus_promotions/order_adjuster/discount_order.rb new file mode 100644 index 00000000000..763778da7a7 --- /dev/null +++ b/promotions/app/models/solidus_promotions/order_adjuster/discount_order.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module SolidusPromotions + class OrderAdjuster + class DiscountOrder + attr_reader :order, :promotions, :dry_run + + def initialize(order, promotions, dry_run: false) + @order = order + @promotions = promotions + @dry_run = dry_run + end + + def call + return order if order.shipped? + + SolidusPromotions::Promotion.ordered_lanes.each_key do |lane| + lane_promotions = promotions.select { |promotion| promotion.lane == lane } + lane_benefits = eligible_benefits_for_promotable(lane_promotions.flat_map(&:benefits), order) + perform_order_benefits(lane_benefits, lane) unless dry_run + line_item_discounts = adjust_line_items(lane_benefits) + shipment_discounts = adjust_shipments(lane_benefits) + shipping_rate_discounts = adjust_shipping_rates(lane_benefits) + (line_item_discounts + shipment_discounts + shipping_rate_discounts).each do |item, chosen_discounts| + item.current_discounts.concat(chosen_discounts) + end + end + + order + end + + private + + def perform_order_benefits(lane_benefits, lane) + lane_benefits.select { |benefit| benefit.level == :order }.each do |benefit| + benefit.perform(order) + end + + automated_line_items = order.line_items.select(&:managed_by_order_benefit) + return if automated_line_items.empty? + + ineligible_line_items = automated_line_items.select do |line_item| + line_item.managed_by_order_benefit.promotion.lane == lane && !line_item.managed_by_order_benefit.in?(lane_benefits) + end + + ineligible_line_items.each do |line_item| + line_item.managed_by_order_benefit.remove_from(order) + end + end + + def adjust_line_items(benefits) + order.discountable_line_items.select do |line_item| + line_item.variant.product.promotionable? + end.map do |line_item| + discounts = generate_discounts(benefits, line_item) + chosen_item_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call + [line_item, chosen_item_discounts] + end + end + + def adjust_shipments(benefits) + order.shipments.map do |shipment| + discounts = generate_discounts(benefits, shipment) + chosen_item_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call + [shipment, chosen_item_discounts] + end + end + + def adjust_shipping_rates(benefits) + order.shipments.flat_map(&:shipping_rates).select(&:cost).map do |rate| + discounts = generate_discounts(benefits, rate) + chosen_item_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call + [rate, chosen_item_discounts] + end + end + + def eligible_benefits_for_promotable(possible_benefits, promotable) + possible_benefits.select do |candidate| + candidate.eligible_by_applicable_conditions?(promotable, dry_run: dry_run) + end + end + + def generate_discounts(possible_benefits, item) + eligible_benefits = eligible_benefits_for_promotable(possible_benefits, item) + eligible_benefits.select do |benefit| + benefit.can_discount?(item) + end.map do |benefit| + benefit.discount(item) + end.compact + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/order_adjuster/load_promotions.rb b/promotions/app/models/solidus_promotions/order_adjuster/load_promotions.rb new file mode 100644 index 00000000000..63df06ab3f7 --- /dev/null +++ b/promotions/app/models/solidus_promotions/order_adjuster/load_promotions.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module SolidusPromotions + class OrderAdjuster + class LoadPromotions + def initialize(order:, dry_run_promotion: nil) + @order = order + @dry_run_promotion = dry_run_promotion + end + + def call + promos = connected_order_promotions | sale_promotions + promos << dry_run_promotion if dry_run_promotion + promos.flat_map(&:benefits).group_by(&:preload_relations).each do |benefit_preload_relations, benefits| + preload(records: benefits, associations: benefit_preload_relations) + benefits.flat_map(&:conditions).group_by(&:preload_relations).each do |condition_preload_relations, conditions| + preload(records: conditions, associations: condition_preload_relations) + end + end + promos.reject { |promotion| promotion.usage_limit_exceeded?(excluded_orders: [order]) } + end + + private + + attr_reader :order, :dry_run_promotion + + def preload(records:, associations:) + ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call + end + + def connected_order_promotions + eligible_connected_promotion_ids = order.solidus_order_promotions.select do |order_promotion| + order_promotion.promotion.kept? && (order_promotion.promotion_code.nil? || !order_promotion.promotion_code.usage_limit_exceeded?(excluded_orders: [order])) + end.map(&:promotion_id) + order.solidus_promotions.active(reference_time).where(id: eligible_connected_promotion_ids).includes(promotion_includes) + end + + def sale_promotions + SolidusPromotions::Promotion.kept.where(apply_automatically: true).active(reference_time).includes(promotion_includes) + end + + def reference_time + order.completed_at || Time.current + end + + def promotion_includes + { + benefits: :conditions + } + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/order_adjuster/persist_discounted_order.rb b/promotions/app/models/solidus_promotions/order_adjuster/persist_discounted_order.rb new file mode 100644 index 00000000000..6d05a29afc7 --- /dev/null +++ b/promotions/app/models/solidus_promotions/order_adjuster/persist_discounted_order.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module SolidusPromotions + class OrderAdjuster + class PersistDiscountedOrder + def initialize(order) + @order = order + end + + def call + order.line_items.each do |line_item| + update_adjustments(line_item, line_item.current_discounts) + end + + order.shipments.each do |shipment| + update_adjustments(shipment, shipment.current_discounts) + end + + order.shipments.flat_map(&:shipping_rates).each do |shipping_rate| + shipping_rate.discounts = shipping_rate.current_discounts.map do |discount| + SolidusPromotions::ShippingRateDiscount.create!( + shipping_rate: shipping_rate, + amount: discount.amount, + label: discount.label, + benefit: discount.source + ) + end + end + order.reset_current_discounts + order + end + + private + + attr_reader :order + + # Walk through the discounts for an item and update adjustments for it. Once + # all of the discounts have been added as adjustments, remove any old promotion + # adjustments that weren't touched. + # + # @private + # @param [#adjustments] item a {Spree::LineItem} or {Spree::Shipment} + # @param [Array] item_discounts a list of calculated discounts for an item + # @return [void] + def update_adjustments(item, item_discounts) + promotion_adjustments = item.adjustments.select(&:promotion?) + + active_adjustments = item_discounts.map do |item_discount| + update_adjustment(item, item_discount) + end + item.update(promo_total: active_adjustments.sum(&:amount)) + # Remove any promotion adjustments tied to promotion benefits which no longer match. + unmatched_adjustments = promotion_adjustments - active_adjustments + + item.adjustments.destroy(unmatched_adjustments) + end + + # Update or create a new promotion adjustment on an item. + # + # @private + # @param [#adjustments] item a {Spree::LineItem} or {Spree::Shipment} + # @param [SolidusPromotions::ItemDiscount] discount_item calculated discounts for an item + # @return [Spree::Adjustment] the created or updated promotion adjustment + def update_adjustment(item, discount_item) + adjustment = item.adjustments.detect do |item_adjustment| + item_adjustment.source == discount_item.source + end + + adjustment ||= item.adjustments.new( + source: discount_item.source, + order_id: item.is_a?(Spree::Order) ? item.id : item.order_id, + label: discount_item.label + ) + adjustment.update!(amount: discount_item.amount) + adjustment + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/order_promotion.rb b/promotions/app/models/solidus_promotions/order_promotion.rb new file mode 100644 index 00000000000..894c999031f --- /dev/null +++ b/promotions/app/models/solidus_promotions/order_promotion.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module SolidusPromotions + # SolidusPromotions::OrderPromotion represents the relationship between: + # + # 1. A promotion that a user attempted to apply to their order + # 2. The specific code that they used + class OrderPromotion < Spree::Base + belongs_to :order, class_name: "Spree::Order" + belongs_to :promotion, -> { with_discarded }, class_name: "SolidusPromotions::Promotion" + belongs_to :promotion_code, class_name: "SolidusPromotions::PromotionCode", optional: true + + validates :promotion_code, presence: true, if: :require_promotion_code? + + self.allowed_ransackable_associations = %w[promotion_code] + + private + + def require_promotion_code? + promotion && !promotion.apply_automatically && promotion.codes.any? + end + end +end diff --git a/promotions/app/models/solidus_promotions/permission_sets/solidus_promotion_management.rb b/promotions/app/models/solidus_promotions/permission_sets/solidus_promotion_management.rb new file mode 100644 index 00000000000..db016f68e4c --- /dev/null +++ b/promotions/app/models/solidus_promotions/permission_sets/solidus_promotion_management.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module PermissionSets + class SolidusPromotionManagement < Spree::PermissionSets::Base + def activate! + can :manage, SolidusPromotions::Promotion + can :manage, SolidusPromotions::Condition + can :manage, SolidusPromotions::Benefit + can :manage, SolidusPromotions::PromotionCategory + can :manage, SolidusPromotions::PromotionCode + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion.rb b/promotions/app/models/solidus_promotions/promotion.rb new file mode 100644 index 00000000000..377bd28e31e --- /dev/null +++ b/promotions/app/models/solidus_promotions/promotion.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +module SolidusPromotions + class Promotion < Spree::Base + include Spree::SoftDeletable + + belongs_to :category, class_name: "SolidusPromotions::PromotionCategory", + foreign_key: :promotion_category_id, optional: true + belongs_to :original_promotion, class_name: "Spree::Promotion", optional: true + has_many :benefits, class_name: "SolidusPromotions::Benefit", dependent: :destroy + has_many :conditions, through: :benefits + has_many :codes, class_name: "SolidusPromotions::PromotionCode", dependent: :destroy + has_many :code_batches, class_name: "SolidusPromotions::PromotionCodeBatch", dependent: :destroy + has_many :order_promotions, class_name: "SolidusPromotions::OrderPromotion", dependent: :destroy + + validates :name, :customer_label, presence: true + validates :path, uniqueness: { allow_blank: true, case_sensitive: true } + validates :usage_limit, numericality: { greater_than: 0, allow_nil: true } + validates :per_code_usage_limit, numericality: { greater_than_or_equal_to: 0, allow_nil: true } + validates :description, length: { maximum: 255 } + validate :apply_automatically_disallowed_with_paths + validate :apply_automatically_disallowed_with_promotion_codes + + before_save :normalize_blank_values + after_discard :delete_cart_connections + + scope :active, ->(time = Time.current) { has_benefits.started_and_unexpired(time) } + scope :advertised, -> { where(advertise: true) } + scope :coupons, -> { joins(:codes).distinct } + scope :started_and_unexpired, ->(time = Time.current) do + table = arel_table + + where(table[:starts_at].eq(nil).or(table[:starts_at].lt(time))) + .where(table[:expires_at].eq(nil).or(table[:expires_at].gt(time))) + end + scope :has_benefits, -> do + joins(:benefits).distinct + end + + enum lane: SolidusPromotions.config.preferred_lanes + + def self.with_coupon_code(val) + joins(:codes).where( + SolidusPromotions::PromotionCode.arel_table[:value].eq(val.downcase) + ).first + end + + def self.human_enum_name(enum_name, enum_value) + I18n.t("activerecord.attributes.#{model_name.i18n_key}.#{enum_name.to_s.pluralize}.#{enum_value}") + end + + def self.lane_options + ordered_lanes.map do |lane_name, _index| + [human_enum_name(:lane, lane_name), lane_name] + end + end + + def self.ordered_lanes + lanes.sort_by(&:last).to_h + end + + self.allowed_ransackable_associations = ["codes"] + self.allowed_ransackable_attributes = %w[name customer_label path promotion_category_id lane updated_at] + self.allowed_ransackable_scopes = %i[active with_discarded] + + # All orders that have been discounted using this promotion + def discounted_orders + Spree::Order + .joins(:all_adjustments) + .where( + spree_adjustments: { + source_type: "SolidusPromotions::Benefit", + source_id: benefits.map(&:id) + } + ).distinct + end + + # Number of times the code has been used overall + # + # @param excluded_orders [Array] Orders to exclude from usage count + # @return [Integer] usage count + def usage_count(excluded_orders: []) + discounted_orders + .complete + .where.not(id: [excluded_orders.map(&:id)]) + .where.not(spree_orders: { state: :canceled }) + .count + end + + def used_by?(user, excluded_orders = []) + discounted_orders + .complete + .where.not(id: excluded_orders.map(&:id)) + .where(user: user) + .where.not(spree_orders: { state: :canceled }) + .exists? + end + + # Whether the promotion has exceeded its usage restrictions. + # + # @param excluded_orders [Array] Orders to exclude from usage limit + # @return true or false + def usage_limit_exceeded?(excluded_orders: []) + return unless usage_limit + + usage_count(excluded_orders: excluded_orders) >= usage_limit + end + + def not_expired?(time = Time.current) + !expired?(time) + end + + def not_started?(time = Time.current) + !started?(time) + end + + def started?(time = Time.current) + starts_at.nil? || starts_at < time + end + + def active?(time = Time.current) + started?(time) && not_expired?(time) && benefits.present? + end + + def inactive?(time = Time.current) + !active?(time) + end + + def expired?(time = Time.current) + expires_at.present? && expires_at < time + end + + def products + conditions.where(type: "SolidusPromotions::Conditions::Product").flat_map(&:products).uniq + end + + def eligibility_results + @eligibility_results ||= SolidusPromotions::EligibilityResults.new(self) + end + + private + + def normalize_blank_values + self[:path] = nil if self[:path].blank? + end + + def apply_automatically_disallowed_with_paths + return unless apply_automatically + + errors.add(:apply_automatically, :disallowed_with_path) if path.present? + end + + def apply_automatically_disallowed_with_promotion_codes + return unless apply_automatically + + errors.add(:apply_automatically, :disallowed_with_promotion_codes) if codes.present? + end + + def delete_cart_connections + order_promotions.where(order: Spree::Order.incomplete).destroy_all + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion_advertiser.rb b/promotions/app/models/solidus_promotions/promotion_advertiser.rb new file mode 100644 index 00000000000..16fa180949a --- /dev/null +++ b/promotions/app/models/solidus_promotions/promotion_advertiser.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionAdvertiser + def self.for_product(product) + promotion_ids = ConditionProduct.joins(condition: :benefit).where(product: product).select(:promotion_id).distinct + SolidusPromotions::Promotion.advertised.where(id: promotion_ids).reject(&:inactive?) + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion_category.rb b/promotions/app/models/solidus_promotions/promotion_category.rb new file mode 100644 index 00000000000..1b8f96dffcf --- /dev/null +++ b/promotions/app/models/solidus_promotions/promotion_category.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionCategory < Spree::Base + has_many :promotions, dependent: :nullify + + validates :name, presence: true + end +end diff --git a/promotions/app/models/solidus_promotions/promotion_code.rb b/promotions/app/models/solidus_promotions/promotion_code.rb new file mode 100644 index 00000000000..37da59ee875 --- /dev/null +++ b/promotions/app/models/solidus_promotions/promotion_code.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionCode < Spree::Base + belongs_to :promotion, -> { with_discarded }, inverse_of: :codes + belongs_to :promotion_code_batch, inverse_of: :promotion_codes, optional: true + + has_many :order_promotions, class_name: "SolidusPromotions::OrderPromotion", dependent: :destroy + + before_validation :normalize_code + + validates :value, presence: true, uniqueness: { allow_blank: true, case_sensitive: true } + validate :promotion_not_apply_automatically, on: :create + + self.allowed_ransackable_attributes = ["value"] + + # Whether the promotion code has exceeded its usage restrictions + # + # @param excluded_orders [Array] Orders to exclude from usage limit + # @return true or false + def usage_limit_exceeded?(excluded_orders: []) + return unless usage_limit + + usage_count(excluded_orders: excluded_orders) >= usage_limit + end + + # Number of times the code has been used overall + # + # @param excluded_orders [Array] Orders to exclude from usage count + # @return [Integer] usage count + def usage_count(excluded_orders: []) + promotion + .discounted_orders + .complete + .where.not(spree_orders: { state: :canceled }) + .joins(:solidus_order_promotions) + .where(SolidusPromotions::OrderPromotion.table_name => { promotion_code_id: id }) + .where.not(id: excluded_orders.map(&:id)) + .count + end + + def usage_limit + promotion.per_code_usage_limit + end + + def promotion_not_apply_automatically + errors.add(:base, :disallowed_with_apply_automatically) if promotion.apply_automatically + end + + private + + def normalize_code + self.value = value.downcase.strip + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion_code/batch_builder.rb b/promotions/app/models/solidus_promotions/promotion_code/batch_builder.rb new file mode 100644 index 00000000000..9e7189f7050 --- /dev/null +++ b/promotions/app/models/solidus_promotions/promotion_code/batch_builder.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionCode < Spree::Base + class BatchBuilder + attr_reader :promotion_code_batch, :options + + delegate :promotion, :number_of_codes, :base_code, to: :promotion_code_batch + + DEFAULT_OPTIONS = { + random_code_length: 6, + batch_size: 1000, + sample_characters: ("a".."z").to_a + (2..9).to_a.map(&:to_s) + }.freeze + + def initialize(promotion_code_batch, options = {}) + @promotion_code_batch = promotion_code_batch + options.assert_valid_keys(*DEFAULT_OPTIONS.keys) + @options = DEFAULT_OPTIONS.merge(options) + end + + def build_promotion_codes + generate_random_codes + promotion_code_batch.update!(state: "completed") + rescue StandardError => e + promotion_code_batch.update!( + error: e.inspect, + state: "failed" + ) + raise e + end + + private + + def generate_random_codes + created_codes = promotion_code_batch.promotion_codes.count + + batch_size = @options[:batch_size] + + while created_codes < number_of_codes + max_codes_to_generate = [batch_size, number_of_codes - created_codes].min + + new_codes = Array.new(max_codes_to_generate) { generate_random_code }.uniq + codes_for_current_batch = get_unique_codes(new_codes) + + codes_for_current_batch.filter! do |value| + SolidusPromotions::PromotionCode.create!( + value: value, + promotion: promotion, + promotion_code_batch: promotion_code_batch + ) + rescue ActiveRecord::RecordInvalid + false + end + + created_codes += codes_for_current_batch.size + end + end + + def generate_random_code + suffix = Array.new(@options[:random_code_length]) do + @options[:sample_characters].sample + end.join + + "#{base_code}#{@promotion_code_batch.join_characters}#{suffix}" + end + + def get_unique_codes(code_set) + code_set - PromotionCode.where(value: code_set.to_a).pluck(:value) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion_code_batch.rb b/promotions/app/models/solidus_promotions/promotion_code_batch.rb new file mode 100644 index 00000000000..e18d90bdf1e --- /dev/null +++ b/promotions/app/models/solidus_promotions/promotion_code_batch.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionCodeBatch < Spree::Base + class CantProcessStartedBatch < StandardError + end + + belongs_to :promotion + has_many :promotion_codes, dependent: :destroy + + validates :number_of_codes, numericality: { greater_than: 0 } + validates :base_code, :number_of_codes, presence: true + + def finished? + state == "completed" + end + + def process + raise CantProcessStartedBatch, "Batch #{id} already started" unless state == "pending" + + update!(state: "processing") + PromotionCodeBatchJob.perform_later(self) + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion_finder.rb b/promotions/app/models/solidus_promotions/promotion_finder.rb new file mode 100644 index 00000000000..3a55bb4e3fc --- /dev/null +++ b/promotions/app/models/solidus_promotions/promotion_finder.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionFinder + def self.by_code_or_id(coupon_code_or_id) + SolidusPromotions::Promotion.with_coupon_code(coupon_code_or_id.to_s) || + SolidusPromotions::Promotion.find(coupon_code_or_id) + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion_handler/coupon.rb b/promotions/app/models/solidus_promotions/promotion_handler/coupon.rb new file mode 100644 index 00000000000..48db345a41e --- /dev/null +++ b/promotions/app/models/solidus_promotions/promotion_handler/coupon.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module SolidusPromotions + module PromotionHandler + class Coupon + attr_reader :order, :coupon_code, :errors + attr_accessor :error, :success, :status_code + + def initialize(order) + @order = order + @errors = [] + @coupon_code = order&.coupon_code&.downcase + end + + def apply + if coupon_code.present? + if promotion.present? && promotion.active? + handle_present_promotion + elsif promotion_code&.promotion&.expired? + set_error_code :coupon_code_expired + else + set_error_code :coupon_code_not_found + end + end + + self + end + + def remove + if promotion.blank? + set_error_code :coupon_code_not_found + elsif !promotion_exists_on_order?(order, promotion) + set_error_code :coupon_code_not_present + else + order.solidus_order_promotions.destroy_by( + promotion: promotion + ) + order.recalculate + set_success_code :coupon_code_removed + end + + self + end + + def successful? + success.present? && error.blank? + end + + def promotion + @promotion ||= if promotion_code&.promotion&.active? + promotion_code.promotion + end + end + + private + + def set_success_code(status_code) + @status_code = status_code + @success = I18n.t(status_code, scope: "solidus_promotions.eligibility_results") + end + + def set_error_code(status_code, options = {}) + @status_code = status_code + @error = options[:error] || I18n.t(status_code, scope: "solidus_promotions.eligibility_errors") + @errors = options[:errors] || [@error] + end + + def promotion_code + @promotion_code ||= SolidusPromotions::PromotionCode.where(value: coupon_code).first + end + + def handle_present_promotion + return promotion_usage_limit_exceeded if promotion.usage_limit_exceeded? || promotion_code.usage_limit_exceeded? + return promotion_applied if promotion_exists_on_order?(order, promotion) + + # Try applying this promotion, with no effects + Spree::Config.promotions.order_adjuster_class.new(order, dry_run_promotion: promotion).call + + if promotion.eligibility_results.success? + order.solidus_order_promotions.create!( + promotion: promotion, + promotion_code: promotion_code + ) + order.recalculate + set_success_code :coupon_code_applied + else + set_promotion_eligibility_error(promotion) + end + end + + def set_promotion_eligibility_error(promotion) + eligibility_error = promotion.eligibility_results.detect { |result| !result.success } + set_error_code(eligibility_error.code, error: eligibility_error.message, errors: promotion.eligibility_results.error_messages) + end + + def promotion_usage_limit_exceeded + set_error_code :coupon_code_max_usage + end + + def ineligible_for_this_order + set_error_code :coupon_code_not_eligible + end + + def promotion_applied + set_error_code :coupon_code_already_applied + end + + def promotion_exists_on_order?(order, promotion) + order.solidus_promotions.include? promotion + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/promotion_handler/page.rb b/promotions/app/models/solidus_promotions/promotion_handler/page.rb new file mode 100644 index 00000000000..4f72fabb8b4 --- /dev/null +++ b/promotions/app/models/solidus_promotions/promotion_handler/page.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module SolidusPromotions + module PromotionHandler + class Page + attr_reader :order, :path + + def initialize(order, path) + @order = order + @path = path.delete_prefix('/') + end + + def activate + if promotion + Spree::Config.promotions.order_adjuster_class.new(order, dry_run_promotion: promotion).call + if promotion.eligibility_results.success? + order.solidus_promotions << promotion + order.recalculate + end + end + end + + private + + def promotion + @promotion ||= SolidusPromotions::Promotion.active.find_by(path: path) + end + end + end +end diff --git a/promotions/app/models/solidus_promotions/shipping_rate_discount.rb b/promotions/app/models/solidus_promotions/shipping_rate_discount.rb new file mode 100644 index 00000000000..74ea25ce3ec --- /dev/null +++ b/promotions/app/models/solidus_promotions/shipping_rate_discount.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module SolidusPromotions + class ShippingRateDiscount < Spree::Base + belongs_to :shipping_rate, inverse_of: :discounts, class_name: "Spree::ShippingRate" + belongs_to :benefit, inverse_of: :shipping_rate_discounts + + extend Spree::DisplayMoney + money_methods :amount + end +end diff --git a/promotions/app/subscribers/solidus_promotions/order_promotion_subscriber.rb b/promotions/app/subscribers/solidus_promotions/order_promotion_subscriber.rb new file mode 100644 index 00000000000..9b3c40f0d79 --- /dev/null +++ b/promotions/app/subscribers/solidus_promotions/order_promotion_subscriber.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module SolidusPromotions + # Clears promotions from an emptied order + class OrderPromotionSubscriber + include Omnes::Subscriber + + handle :order_emptied, + with: :clear_order_promotions, + id: :solidus_promotions_order_promotion_clear_order_promotions + + # Clears all promotions from the order + # + # @param event [Omnes::UnstructuredEvent] + def clear_order_promotions(event) + order = event[:order] + order.solidus_order_promotions.destroy_all + end + end +end diff --git a/promotions/bin/rails b/promotions/bin/rails new file mode 100755 index 00000000000..ced0ed57959 --- /dev/null +++ b/promotions/bin/rails @@ -0,0 +1,13 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails gems +# installed from the root of your application. + +ENGINE_ROOT = File.expand_path('..', __dir__) +ENGINE_PATH = File.expand_path('../lib/solidus_promotions/engine', __dir__) + +# Set up gems listed in the Gemfile. +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) + +require 'rails/all' +require 'rails/engine/commands' diff --git a/promotions/config/importmap.rb b/promotions/config/importmap.rb new file mode 100644 index 00000000000..dbbfa75ceb5 --- /dev/null +++ b/promotions/config/importmap.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true + +pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true +pin_all_from SolidusPromotions::Engine.root.join("app/javascript/backend/solidus_promotions/controllers"), + under: "backend/solidus_promotions/controllers" +pin_all_from SolidusPromotions::Engine.root.join("app/javascript/backend/solidus_promotions/jquery"), + under: "backend/solidus_promotions/jquery" +pin_all_from SolidusPromotions::Engine.root.join("app/javascript/backend/solidus_promotions/web_components"), + under: "backend/solidus_promotions/web_components" + +pin "backend/solidus_promotions", to: "backend/solidus_promotions.js", preload: true diff --git a/promotions/config/locales/en.yml b/promotions/config/locales/en.yml new file mode 100644 index 00000000000..19b07d81f2f --- /dev/null +++ b/promotions/config/locales/en.yml @@ -0,0 +1,376 @@ +# Sample localization file for English. Add more files in this directory for other locales. +# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. + +en: + spree: + admin: + tab: + promotions: Promotions + promotion_categories: Promotion Categories + legacy_promotions: Legacy Promotions + legacy_promotion_categories: Legacy Promotion Categories + hints: + solidus_promotions/promotion: + expires_at: This determines when the promotion expires.
If no value is specified, the promotion will never expire. + promo_code_will_be_disabled: Selecting this option, promo codes will be disabled for this promotion because all its conditions / benefits will be applied automatically to all orders. + starts_at: This determines when the promotion can be applied to orders.
If no value is specified, the promotion will be immediately available. + solidus_promotions: + hints: + solidus_promotions/benefit: + conditions: This is used to determine if the benefit should be applied. + type: This is the type of benefit that will be applied. + calculator: This is used to determine the promotional discount to be applied to an order, an item, or shipping charges. + benefits: Benefits + adjustment_labels: + line_item: "%{promotion} (%{promotion_customer_label})" + shipment: "%{promotion} (%{promotion_customer_label})" + shipping_rate: "%{promotion} (%{promotion_customer_label})" + adjustment_type: Adjustment type + add_benefit: Add Benefit + add_condition: Add Condition + order_conditions: Order Conditions + line_item_conditions: Line Item Conditions + shipment_conditions: Shipment Conditions + line_item_benefits: Line Item Benefits + shipment_benefits: Shipment Benefits + order_benefits: Order Benefits + invalid_benefit: Invalid promotion benefit. + invalid_condition: Invalid promotion condition. + create_promotion_code: Create promotion code + current_promotion_usage: 'Current Usage: %{count}' + discount_conditions: Promotion Conditions + download_promotion_codes_list: Download codes list + new_promotion: New Promotion + new_promotion_category: New Promotion Category + new_promotion_code_batch: New Promotion Code Batch + number_of_codes: "%{count} codes" + legacy_promotions: Legacy Promotions + no_conditions_added: No Conditions Added + promotion_successfully_created: Promotion has been successfully created! + promotion_total_changed_before_complete: One or more of the promotions on your order have become ineligible and were removed. Please check the new order amounts and try again. + promotion_code_batches: + finished: Finished + errored: Errored + view_promotion_codes_list: View codes list + conditions: + line_item_product: + match_policies: + include: Line item's product is one of the chosen products + exclude: Line item's product is not one of the chosen products + line_item_taxon: + match_policies: + include: Line item's product has one of the chosen taxons + exclude: Line item's product does not have one of the chosen taxons + item_total_condition: + operators: + gt: greater than + gte: greater than or equal to + user_condition: + choose_users: Choose Users + user_role_condition: + choose_roles: Choose Roles + label: User must contain %{select} of these roles + match_all: all + match_any: at least one + coupon_code: Coupon code + eligibility_results: + coupon_code_applied: The coupon code was successfully applied to your order. + coupon_code_removed: The coupon code was successfully removed from this order. + eligibility_errors: + coupon_code_already_applied: The coupon code has already been applied to this order + coupon_code_expired: The coupon code is expired + coupon_code_max_usage: Coupon code usage limit exceeded + coupon_code_not_eligible: This coupon code is not eligible for this order + coupon_code_not_found: The coupon code you entered doesn't exist. Please try again. + coupon_code_not_present: The coupon code you are trying to remove is not present on this order. + coupon_code_unknown_error: This coupon code could not be applied to the cart at this time. + solidus_promotions/conditions/first_order: + not_first_order: This coupon code can only be applied to your first order. + solidus_promotions/conditions/first_repeat_purchase_since: + solidus_promotions/conditions/item_total: + item_total_doesnt_match_with_operator: This coupon code can't be applied to orders %{operator} %{amount}. + item_total_less_than: This coupon code can't be applied to orders less than %{amount}. + item_total_less_than_or_equal: This coupon code can't be applied to orders less than or equal to %{amount}. + solidus_promotions/conditions/discounted_item_total: + item_total_doesnt_match_with_operator: This coupon code can't be applied to orders %{operator} %{amount}. + item_total_less_than: This coupon code can't be applied to orders less than %{amount}. + item_total_less_than_or_equal: This coupon code can't be applied to orders less than or equal to %{amount}. + solidus_promotions/conditions/landing_page: + solidus_promotions/conditions/nth_order: + solidus_promotions/conditions/one_use_per_user: + no_user_specified: You need to login before applying this coupon code. + limit_once_per_user: This coupon code can only be used once per user. + solidus_promotions/conditions/option_value: + solidus_promotions/conditions/line_item_option_value: + solidus_promotions/conditions/product: + has_excluded_product: Your cart contains a product that prevents this coupon code from being applied. + missing_product: This coupon code can't be applied because you don't have all of the necessary products in your cart. + no_applicable_products: You need to add an applicable product before applying this coupon code. + solidus_promotions/conditions/line_item_product: + has_excluded_product: Your cart contains a product that prevents this coupon code from being applied. + missing_product: This coupon code can't be applied because you don't have all of the necessary products in your cart. + no_applicable_products: You need to add an applicable product before applying this coupon code. + solidus_promotions/conditions/taxon: + has_excluded_taxon: Your cart contains a product from an excluded category that prevents this coupon code from being applied. + missing_taxon: You need to add a product from all applicable categories before applying this coupon code. + no_matching_taxons: You need to add a product from an applicable category before applying this coupon code. + solidus_promotions/conditions/line_item_taxon: + has_excluded_taxon: Your cart contains a product from an excluded category that prevents this coupon code from being applied. + missing_taxon: You need to add a product from all applicable categories before applying this coupon code. + no_matching_taxons: You need to add a product from an applicable category before applying this coupon code. + solidus_promotions/conditions/user: + no_user_specified: You need to login before applying this coupon code. + solidus_promotions/conditions/user_logged_in: + no_user_specified: You need to login before applying this coupon code. + solidus_promotions/conditions/user_role: + solidus_promotions/conditions/minimum_quantity: + quantity_less_than_minimum: + one: "You need to add a least 1 applicable item to your order." + other: "You need to add a least %{count} applicable items to your order." + product_condition: + choose_products: Choose products + label: Order must contain %{select} these products + match_all: all of + match_any: at least one of + match_none: none of + match_only: only + product_source: + group: From product group + manual: Manually choose + taxon_condition: + choose_taxons: Choose taxons + label: Order must contain %{select} of these taxons + match_all: all + match_any: at least one + match_none: none + line_item_option_value_condition: + add_product: Add product + option_value_condition: + add_product: Add product + crud: + add: Add + destroy: Delete + update: Update + admin: + promotions: + benefits: + calculator_label: Calculated by + activations_edit: + auto: All orders will attempt to use this promotion + multiple_codes_html: This promotion uses %{count} promotion codes + single_code_html: 'This promotion uses the promotion code: %{code}' + activations_new: + auto: Apply to all orders + multiple_codes: Multiple promotion codes + single_code: Single promotion code + form: + activation: Activation + expires_at_placeholder: Never + general: General + starts_at_placeholder: Immediately + codes_present: This promotion has promotion codes defined. You cannot select the apply automatically option. + edit: + order_conditions: Order Conditions + calculator: + add_tier: Add tier + promotion_status: + active: Active + expired: Expired + inactive: Inactive + not_started: Not started + promotion_code_batch_mailer: + promotion_code_batch_errored: + message: 'Promotion code batch errored (%{error}) for promotion: ' + subject: Promotion code batch errored + promotion_code_batch_finished: + message: 'All %{number_of_codes} codes have been created for promotion: ' + subject: Promotion code batch finished + activerecord: + models: + solidus_promotions/benefits/adjust_shipment: Discount matching shipments + solidus_promotions/benefits/adjust_line_item: Discount matching line items + solidus_promotions/benefits/create_discounted_item: Create discounted line item + solidus_promotions/benefits/adjust_line_item_quantity_groups: Discount matching line items based on quantity groups + solidus_promotions/calculators/distributed_amount: Distributed Amount + solidus_promotions/calculators/percent: Percent + solidus_promotions/calculators/flat_rate: Flat Rate + solidus_promotions/calculators/flexi_rate: Flexible Rate + solidus_promotions/calculators/tiered_flat_rate: Tiered Flat Rate + solidus_promotions/calculators/tiered_percent: Tiered Percent + solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity: Tiered Percent based on eligible item quantity + solidus_promotions/conditions/first_order: First Order + solidus_promotions/conditions/first_repeat_purchase_since: First Repeat Purchase Since + solidus_promotions/conditions/item_total: Item Total + solidus_promotions/conditions/discounted_item_total: Item Total after previous lanes + solidus_promotions/conditions/landing_page: Landing Page + solidus_promotions/conditions/minimum_quantity: Minimum Quantity + solidus_promotions/conditions/nth_order: Nth Order + solidus_promotions/conditions/one_use_per_user: One Use Per User + solidus_promotions/conditions/option_value: Option Value(s) + solidus_promotions/conditions/line_item_option_value: Line Item Option Value(s) + solidus_promotions/conditions/product: Order Product(s) + solidus_promotions/conditions/line_item_product: Line Item Product(s) + solidus_promotions/conditions/taxon: Order Taxon(s) + solidus_promotions/conditions/line_item_taxon: Line Item Taxon(s) + solidus_promotions/conditions/user: User + solidus_promotions/conditions/user_logged_in: User Logged In + solidus_promotions/conditions/user_role: User Role(s) + solidus_promotions/promotion_code_batch: + one: Code batch + other: Code batches + attributes: + solidus_promotions/promotion: + active: Active + customer_label: Customer-facing label + usage: Usage + lanes: + pre: Pre + default: Default + post: Post + solidus_promotions/benefit: + type: Benefit Type + solidus_promotions/condition: + type: Condition Type + solidus_promotions/benefits/adjust_line_item: + description: Creates a promotion credit on matching line items + solidus_promotions/benefits/adjust_shipment: + description: Creates a promotion credit on matching shipments + solidus_promotions/benefits/create_discounted_item: + description: Creates a discounted item with the quantity the applicable line items. + preferred_quantity: Quantity per applicable line item quantity + preferred_necessary_quantity: Number of items needed for a discounted item + solidus_promotions/conditions/first_order: + description: Must be the customer's first order + solidus_promotions/conditions/first_repeat_purchase_since: + description: Available only to users who have not purchased in a while + preferred_days_ago: Last purchase was at least this many days ago + solidus_promotions/conditions/item_total: + description: Order total must be greater than or equal to the specified amount + preferred_operator: Operator + preferred_amount: Amount + solidus_promotions/conditions/landing_page: + description: Customer must have visited the specified page + solidus_promotions/conditions/nth_order: + description: Apply a promotion to the nth order a user has completed. + form_text: 'Apply this promotion on the users Nth order: ' + solidus_promotions/conditions/one_use_per_user: + description: Only one use per user + solidus_promotions/conditions/option_value: + description: Order includes specified product(s) with matching option value(s) + preferred_line_item_applicable: Should also apply to line items + solidus_promotions/conditions/line_item_option_value: + description: Line Item has specified product with matching option value + solidus_promotions/conditions/minimum_quantity: + description: Order contains minimum quantity of applicable items + solidus_promotions/conditions/product: + description: Order includes specified product(s) + line_item_level_description: 'Line item matches the specified products:' + preferred_line_item_applicable: Should also apply to line items + solidus_promotions/conditions/line_item_product: + description: Line item matches specified product(s) + preferred_match_policy: Match Policy + solidus_promotions/conditions/store: + description: Available only to the specified stores + solidus_promotions/conditions/taxon: + description: Order includes products with specified taxon(s) + preferred_line_item_applicable: Should also apply to line items + solidus_promotions/conditions/line_item_taxon: + description: Line Item has product with specified taxon(s) + preferred_match_policy: Match Policy + solidus_promotions/conditions/user: + description: Available only to the specified users + solidus_promotions/conditions/user_logged_in: + description: Available only to logged in users + solidus_promotions/conditions/user_role: + description: Order includes User with specified Role(s) + solidus_promotions/calculators/tiered_flat_rate: + description: Flat Rate in tiers based on item amount + preferred_base_amount: Base Amount + tiers: Tiers + solidus_promotions/calculators/tiered_percent: + description: Tiered percentage based on order's item total + preferred_base_percent: Base Percent + tiers: Tiers + order_item_total: Order Item Total + percent: Percent + solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity: + description: Tiered percentage based on eligible item quantity + preferred_base_percent: Base Percent + tiers: Tiers + order_item_total: Order Item Total + discount: Discount + solidus_promotions/calculators/flat_rate: + description: Provides a flat rate discount + solidus_promotions/calculators/flexi_rate: + description: Calculates a discount for the first item and (separately) any additional items, with a maximum number of items that can be specified. + preferred_first_item: Discount for the first item + preferred_additional_item: Discount for additional items + preferred_max_items: Maximum number of items + preferred_currency: Currency + solidus_promotions/calculators/percent: + description: Provides a discount calculated by percent of the discountable amount of the item being discounted + solidus_promotions/calculators/distributed_amount: + description: Distributes the configured amount among all eligible line items of the order. + explanation: | +

+ This amount will be distributed to line items weighted relative to + their price. More expensive line items will receive a greater share + of the adjustment. +

+

+ For example, with three line items and a preferred amount of $15 we + would end up with the following distribution: +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
PriceWeighted Adj.
Socks$5-$1.5
Shoes$30-$9
Slippers$15-$4.5
+ preferred_amount: Amount + + errors: + models: + solidus_promotions/benefit: + attributes: + base: + cannot_destroy_if_order_completed: Benefit has been applied to complete orders. It cannot be destroyed. + solidus_promotions/condition: + attributes: + benefit: + already_contains_condition_type: already contains this condition type + type: + invalid_condition_type: is not a valid condition type + solidus_promotions/promotion_code: + attributes: + base: + disallowed_with_apply_automatically: Could not create promotion code on promotion that apply automatically + spree/line_item: + attributes: + quantity: + cannot_be_changed_for_automated_items: cannot be changed on a line item managed by a promotion benefit + solidus_promotions/promotion: + attributes: + apply_automatically: + disallowed_with_path: cannot be set to true when path is present + disallowed_with_promotion_codes: cannot be set to true when promotion code is present diff --git a/promotions/config/locales/promotion_categories.en.yml b/promotions/config/locales/promotion_categories.en.yml new file mode 100644 index 00000000000..33bc51967bc --- /dev/null +++ b/promotions/config/locales/promotion_categories.en.yml @@ -0,0 +1,6 @@ +en: + solidus_promotions: + promotion_categories: + title: "Promotion Categories" + destroy: + success: "Promotion Categories were successfully removed." diff --git a/promotions/config/locales/promotions.en.yml b/promotions/config/locales/promotions.en.yml new file mode 100644 index 00000000000..5cb6a08321a --- /dev/null +++ b/promotions/config/locales/promotions.en.yml @@ -0,0 +1,6 @@ +en: + solidus_promotions: + promotions: + title: "Promotions" + destroy: + success: "Promotions were successfully removed." diff --git a/promotions/config/routes.rb b/promotions/config/routes.rb new file mode 100644 index 00000000000..43c8b08f7b2 --- /dev/null +++ b/promotions/config/routes.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +SolidusPromotions::Engine.routes.draw do + if SolidusSupport.admin_available? + require "solidus_admin/admin_resources" + extend SolidusAdmin::AdminResources + + constraints(->(request) { + request.cookies["solidus_admin"] == "true" || + request.params["solidus_admin"] == "true" || + SolidusPromotions.config.use_new_admin? + }) do + scope :admin do + scope :solidus do + admin_resources :promotion_categories, only: [:index, :destroy] + admin_resources :promotions, only: [:index, :destroy] + end + end + end + end + if SolidusSupport.backend_available? + namespace :admin do + scope :solidus do + resources :promotion_categories, except: [:show] + + resources :promotions do + resources :benefits do + resources :conditions + end + resources :promotion_codes, only: [:index, :new, :create] + resources :promotion_code_batches, only: [:index, :new, :create] do + get "/download", to: "promotion_code_batches#download", defaults: { format: "csv" } + end + end + end + end + end +end diff --git a/promotions/db/migrate/20230703101637_create_promotions.rb b/promotions/db/migrate/20230703101637_create_promotions.rb new file mode 100644 index 00000000000..eacc7c414dc --- /dev/null +++ b/promotions/db/migrate/20230703101637_create_promotions.rb @@ -0,0 +1,25 @@ +class CreatePromotions < ActiveRecord::Migration[7.0] + def change + promotion_foreign_key = table_exists?(:spree_promotions) ? { to_table: :spree_promotions } : false + + create_table :solidus_promotions_promotions do |t| + t.string :description + t.datetime :expires_at, precision: nil + t.datetime :starts_at, precision: nil + t.string :name + t.integer :usage_limit + t.boolean :advertise, default: false + t.string :path + t.integer :per_code_usage_limit + t.boolean :apply_automatically, default: false + t.integer :lane, null: false, default: 1 + t.string :customer_label + t.datetime :deleted_at + t.references :original_promotion, type: :integer, index: { name: :index_original_promotion_id }, foreign_key: promotion_foreign_key + + t.timestamps + end + + add_index :solidus_promotions_promotions, :deleted_at + end +end diff --git a/promotions/db/migrate/20230703113625_create_promotion_benefits.rb b/promotions/db/migrate/20230703113625_create_promotion_benefits.rb new file mode 100644 index 00000000000..accb7fa76e0 --- /dev/null +++ b/promotions/db/migrate/20230703113625_create_promotion_benefits.rb @@ -0,0 +1,15 @@ +class CreatePromotionBenefits < ActiveRecord::Migration[7.0] + def change + promotion_action_foreign_key = table_exists?(:spree_promotion_actions) ? { to_table: :spree_promotion_actions } : false + + create_table :solidus_promotions_benefits do |t| + t.references :promotion, index: true, null: false, foreign_key: { to_table: :solidus_promotions_promotions } + t.string :type + t.text :preferences + t.references :original_promotion_action, type: :integer, index: { name: :index_original_promotion_action_id }, foreign_key: promotion_action_foreign_key + t.index [:id, :type], name: :index_solidus_promotions_benefits_on_id_and_type + + t.timestamps + end + end +end diff --git a/promotions/db/migrate/20230703141116_create_promotion_categories.rb b/promotions/db/migrate/20230703141116_create_promotion_categories.rb new file mode 100644 index 00000000000..0cd5f5c72f6 --- /dev/null +++ b/promotions/db/migrate/20230703141116_create_promotion_categories.rb @@ -0,0 +1,14 @@ +class CreatePromotionCategories < ActiveRecord::Migration[7.0] + def change + create_table :solidus_promotions_promotion_categories do |t| + t.string :name + t.string :code + + t.timestamps + end + + add_reference :solidus_promotions_promotions, + :promotion_category, + foreign_key: { to_table: :solidus_promotions_promotion_categories } + end +end diff --git a/promotions/db/migrate/20230703143943_create_promotion_conditions.rb b/promotions/db/migrate/20230703143943_create_promotion_conditions.rb new file mode 100644 index 00000000000..444becadf9d --- /dev/null +++ b/promotions/db/migrate/20230703143943_create_promotion_conditions.rb @@ -0,0 +1,12 @@ +class CreatePromotionConditions < ActiveRecord::Migration[7.0] + def change + create_table :solidus_promotions_conditions do |t| + t.references :benefit, + foreign_key: { to_table: :solidus_promotions_benefits } + t.string :type + t.text :preferences + + t.timestamps + end + end +end diff --git a/promotions/db/migrate/20230704083830_add_condition_join_tables.rb b/promotions/db/migrate/20230704083830_add_condition_join_tables.rb new file mode 100644 index 00000000000..212879750cf --- /dev/null +++ b/promotions/db/migrate/20230704083830_add_condition_join_tables.rb @@ -0,0 +1,31 @@ +class AddConditionJoinTables < ActiveRecord::Migration[7.0] + def change + create_table :solidus_promotions_condition_products, force: :cascade do |t| + t.references :product, type: :integer, index: true, null: false, foreign_key: { to_table: :spree_products } + t.references :condition, index: true, null: false, foreign_key: { to_table: :solidus_promotions_conditions } + + t.timestamps + end + + create_table :solidus_promotions_condition_taxons, force: :cascade do |t| + t.references :taxon, type: :integer, index: true, null: false, foreign_key: { to_table: :spree_taxons } + t.references :condition, index: true, null: false, foreign_key: { to_table: :solidus_promotions_conditions } + + t.timestamps + end + + create_table :solidus_promotions_condition_users, force: :cascade do |t| + t.references :user, type: :integer, index: true, null: false, foreign_key: { to_table: Spree.user_class.table_name } + t.references :condition, index: true, null: false, foreign_key: { to_table: :solidus_promotions_conditions } + + t.timestamps + end + + create_table :solidus_promotions_condition_stores do |t| + t.references :store, type: :integer, index: true, null: false, foreign_key: { to_table: :spree_stores } + t.references :condition, index: true, null: false, foreign_key: { to_table: :solidus_promotions_conditions } + + t.timestamps + end + end +end diff --git a/promotions/db/migrate/20230704102444_create_promotion_codes.rb b/promotions/db/migrate/20230704102444_create_promotion_codes.rb new file mode 100644 index 00000000000..36b48e46059 --- /dev/null +++ b/promotions/db/migrate/20230704102444_create_promotion_codes.rb @@ -0,0 +1,11 @@ +class CreatePromotionCodes < ActiveRecord::Migration[7.0] + def change + create_table :solidus_promotions_promotion_codes, force: :cascade do |t| + t.references :promotion, null: false, index: true, foreign_key: { to_table: :solidus_promotions_promotions } + t.string :value, null: false + t.timestamps + + t.index ["value"], name: "index_solidus_promotions_promotion_codes_on_value", unique: true + end + end +end diff --git a/promotions/db/migrate/20230704102656_create_promotion_code_batches.rb b/promotions/db/migrate/20230704102656_create_promotion_code_batches.rb new file mode 100644 index 00000000000..5a626b36ff5 --- /dev/null +++ b/promotions/db/migrate/20230704102656_create_promotion_code_batches.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CreatePromotionCodeBatches < ActiveRecord::Migration[7.0] + def change + create_table :solidus_promotions_promotion_code_batches do |t| + t.references :promotion, null: false, index: true, foreign_key: { to_table: :solidus_promotions_promotions } + t.string :base_code, null: false + t.integer :number_of_codes, null: false + t.string :join_characters, null: false, default: "_" + t.string :email + t.string :error + t.string :state, default: "pending" + t.timestamps precision: 6 + end + + add_column( + :solidus_promotions_promotion_codes, + :promotion_code_batch_id, + :bigint + ) + + add_foreign_key( + :solidus_promotions_promotion_codes, + :solidus_promotions_promotion_code_batches, + column: :promotion_code_batch_id + ) + + add_index( + :solidus_promotions_promotion_codes, + :promotion_code_batch_id, + name: "index_promotion_codes_on_promotion_code_batch_id" + ) + end +end diff --git a/promotions/db/migrate/20230705171556_create_order_promotions.rb b/promotions/db/migrate/20230705171556_create_order_promotions.rb new file mode 100644 index 00000000000..ff9a9cd0175 --- /dev/null +++ b/promotions/db/migrate/20230705171556_create_order_promotions.rb @@ -0,0 +1,11 @@ +class CreateOrderPromotions < ActiveRecord::Migration[7.0] + def change + create_table :solidus_promotions_order_promotions do |t| + t.references :order, type: :integer, index: true, null: false, foreign_key: { to_table: :spree_orders } + t.references :promotion, index: true, null: false, foreign_key: { to_table: :solidus_promotions_promotions } + t.references :promotion_code, index: true, null: true, foreign_key: { to_table: :solidus_promotions_promotion_codes } + + t.timestamps + end + end +end diff --git a/promotions/db/migrate/20230725074235_create_shipping_rate_discounts.rb b/promotions/db/migrate/20230725074235_create_shipping_rate_discounts.rb new file mode 100644 index 00000000000..a10309362f0 --- /dev/null +++ b/promotions/db/migrate/20230725074235_create_shipping_rate_discounts.rb @@ -0,0 +1,12 @@ +class CreateShippingRateDiscounts < ActiveRecord::Migration[7.0] + def change + create_table :solidus_promotions_shipping_rate_discounts do |t| + t.references :benefit, type: :bigint, null: false, foreign_key: { to_table: :solidus_promotions_benefits }, index: { name: "index_shipping_rate_discounts_on_benefit_id" } + t.references :shipping_rate, type: :integer, null: false, foreign_key: { to_table: :spree_shipping_rates }, index: { name: "index_shipping_rate_discounts_on_shipping_rate_id" } + t.decimal :amount, precision: 10, scale: 2, null: false + t.string :label, null: false + + t.timestamps + end + end +end diff --git a/promotions/db/migrate/20231011100059_add_db_comments_to_order_promotions.rb b/promotions/db/migrate/20231011100059_add_db_comments_to_order_promotions.rb new file mode 100644 index 00000000000..e7f29603e66 --- /dev/null +++ b/promotions/db/migrate/20231011100059_add_db_comments_to_order_promotions.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class AddDbCommentsToOrderPromotions < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_order_promotions, solidus_promotions_order_promotions_table_comment) + change_column_comment(:solidus_promotions_order_promotions, :order_id, order_id_comment) + change_column_comment(:solidus_promotions_order_promotions, :promotion_id, promotion_id_comment) + change_column_comment(:solidus_promotions_order_promotions, :promotion_code_id, promotion_code_id_comment) + change_column_comment(:solidus_promotions_order_promotions, :id, id_comment) + change_column_comment(:solidus_promotions_order_promotions, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_order_promotions, :updated_at, updated_at_comment) + end + end + + private + + def solidus_promotions_order_promotions_table_comment + <<~COMMENT + Join table between spree_orders and solidus_promotions_promotions. One of two places that record whether a promotion is linked to an order. + The other place is the spree_adjustments table when the source of an adjustment is a SolidusPromotions::Benefit. + An entry here is created every time a promotion is explicitly linked to an order. No entry is created for automatic promotions. + COMMENT + end + + def order_id_comment + <<~COMMENT + Foreign key to the spree_orders table. + COMMENT + end + + def promotion_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_promotions table. + COMMENT + end + + def promotion_code_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_promotion_codes table. If a promotion code was used, records the promotion code. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end +end diff --git a/promotions/db/migrate/20231011120928_add_db_comments_to_condition_taxons.rb b/promotions/db/migrate/20231011120928_add_db_comments_to_condition_taxons.rb new file mode 100644 index 00000000000..7d5ee9b169a --- /dev/null +++ b/promotions/db/migrate/20231011120928_add_db_comments_to_condition_taxons.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class AddDbCommentsToConditionTaxons < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_condition_taxons, solidus_promotions_condition_taxons_table_comment) + change_column_comment(:solidus_promotions_condition_taxons, :id, id_comment) + change_column_comment(:solidus_promotions_condition_taxons, :taxon_id, taxon_id_comment) + change_column_comment(:solidus_promotions_condition_taxons, :condition_id, condition_id_comment) + change_column_comment(:solidus_promotions_condition_taxons, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_condition_taxons, :updated_at, updated_at_comment) + end + end + + private + + def solidus_promotions_condition_taxons_table_comment + <<~COMMENT + Join table between promotion conditions and taxons. Only used if the promotion rule's type is SolidusPromotions::Conditions::Taxon or + SolidusPromotions::Conditions::LineItemTaxon. Represents those taxons that the promotion rule matches on using its any/all/none + or include/exclude match policy. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def taxon_id_comment + <<~COMMENT + Foreign key to the spree_taxons table. + COMMENT + end + + def condition_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_conditions table. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end +end diff --git a/promotions/db/migrate/20231011131324_add_db_comments_to_conditions.rb b/promotions/db/migrate/20231011131324_add_db_comments_to_conditions.rb new file mode 100644 index 00000000000..67bf6fe753d --- /dev/null +++ b/promotions/db/migrate/20231011131324_add_db_comments_to_conditions.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class AddDbCommentsToConditions < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_conditions, solidus_promotions_conditions_table_comment) + change_column_comment(:solidus_promotions_conditions, :id, id_comment) + change_column_comment(:solidus_promotions_conditions, :benefit_id, benefit_id_comment) + change_column_comment(:solidus_promotions_conditions, :type, type_comment) + change_column_comment(:solidus_promotions_conditions, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_conditions, :updated_at, updated_at_comment) + change_column_comment(:solidus_promotions_conditions, :preferences, preferences_comment) + end + end + + private + + def solidus_promotions_conditions_table_comment + <<~COMMENT + Represents promotion conditions. A benefit may have many conditions, which determine whether the benefit is eligible. + All rules must be eligible for the benefit to be eligible. If there are no rules, the benefit is always eligible. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def benefit_id_comment + <<~COMMENT + Foreign key to the benefits table. + COMMENT + end + + def type_comment + <<~COMMENT + STI column. This represents which Ruby class to load when an entry of this table is loaded in Rails. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end + + def preferences_comment + <<~COMMENT + Preferences for this condition. Serialized YAML column with preferences for this condition. + COMMENT + end +end diff --git a/promotions/db/migrate/20231011142040_add_db_comments_to_condition_users.rb b/promotions/db/migrate/20231011142040_add_db_comments_to_condition_users.rb new file mode 100644 index 00000000000..64f7a2b5c60 --- /dev/null +++ b/promotions/db/migrate/20231011142040_add_db_comments_to_condition_users.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class AddDbCommentsToConditionUsers < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_condition_users, solidus_promotions_condition_users_table_comment) + change_column_comment(:solidus_promotions_condition_users, :user_id, user_id_comment) + change_column_comment(:solidus_promotions_condition_users, :condition_id, condition_id_comment) + change_column_comment(:solidus_promotions_condition_users, :id, id_comment) + change_column_comment(:solidus_promotions_condition_users, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_condition_users, :updated_at, updated_at_comment) + end + end + + private + + def solidus_promotions_condition_users_table_comment + <<~COMMENT + Join table between conditions and users. Used with conditions of type "SolidusPromotions::Conditions::User". + An entry here indicates that a promotion is eligible for the user ID specified here. + COMMENT + end + + def user_id_comment + <<~COMMENT + Foreign key to the users table. + COMMENT + end + + def condition_id_comment + <<~COMMENT + Foreign key to the conditions table. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end +end diff --git a/promotions/db/migrate/20231011155822_add_db_comments_to_promotions.rb b/promotions/db/migrate/20231011155822_add_db_comments_to_promotions.rb new file mode 100644 index 00000000000..38cdaebed55 --- /dev/null +++ b/promotions/db/migrate/20231011155822_add_db_comments_to_promotions.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +class AddDbCommentsToPromotions < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_promotions, solidus_promotions_promotions_table_comment) + change_column_comment(:solidus_promotions_promotions, :id, id_comment) + change_column_comment(:solidus_promotions_promotions, :description, description_comment) + change_column_comment(:solidus_promotions_promotions, :expires_at, expires_at_comment) + change_column_comment(:solidus_promotions_promotions, :starts_at, starts_at_comment) + change_column_comment(:solidus_promotions_promotions, :customer_label, customer_label_comment) + change_column_comment(:solidus_promotions_promotions, :usage_limit, usage_limit_comment) + change_column_comment(:solidus_promotions_promotions, :advertise, advertise_comment) + change_column_comment(:solidus_promotions_promotions, :path, path_comment) + change_column_comment(:solidus_promotions_promotions, :lane, lane_comment) + change_column_comment(:solidus_promotions_promotions, :name, name_comment) + change_column_comment(:solidus_promotions_promotions, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_promotions, :updated_at, updated_at_comment) + change_column_comment(:solidus_promotions_promotions, :promotion_category_id, promotion_category_id_comment) + change_column_comment(:solidus_promotions_promotions, :per_code_usage_limit, per_code_usage_limit_comment) + change_column_comment(:solidus_promotions_promotions, :apply_automatically, apply_automatically_comment) + end + end + + private + + def solidus_promotions_promotions_table_comment + <<~COMMENT + Promotions are sets of rules and actions to discount (parts of) an order. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def description_comment + <<~COMMENT + The description of this promotion. + COMMENT + end + + def expires_at_comment + <<~COMMENT + Timestamp at which the promotion stops being eligible. + COMMENT + end + + def starts_at_comment + <<~COMMENT + Timestamp at which the promotion starts being eligible. + COMMENT + end + + def customer_label_comment + <<~COMMENT + The contents of this field will be replicated in the labels of adjustments created with it. + COMMENT + end + + def name_comment + <<~COMMENT + Admin name of the promotion. + COMMENT + end + + def usage_limit_comment + <<~COMMENT + How many times this promotion can be applied to orders globally. + COMMENT + end + + def advertise_comment + <<~COMMENT + Marks a promotion as advertised. + COMMENT + end + + def path_comment + <<~COMMENT + This could be used for applying a promotion based on a route the customer visits. + COMMENT + end + + def lane_comment + <<~COMMENT + Priority lane of this promotion. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end + + def promotion_category_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_promotion_categories table. + COMMENT + end + + def per_code_usage_limit_comment + <<~COMMENT + How many times this promotion can be used per promotion code. + COMMENT + end + + def apply_automatically_comment + <<~COMMENT + Whether this promotion applies automatically in the cart, as opposed to the promotion being activated through a promotion code + or a path (see above). + COMMENT + end +end diff --git a/promotions/db/migrate/20231011163030_add_db_comments_to_promotion_codes.rb b/promotions/db/migrate/20231011163030_add_db_comments_to_promotion_codes.rb new file mode 100644 index 00000000000..0d38be9b2d8 --- /dev/null +++ b/promotions/db/migrate/20231011163030_add_db_comments_to_promotion_codes.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class AddDbCommentsToPromotionCodes < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_promotion_codes, solidus_promotions_promotion_codes_table_comment) + change_column_comment(:solidus_promotions_promotion_codes, :id, id_comment) + change_column_comment(:solidus_promotions_promotion_codes, :promotion_id, promotion_id_comment) + change_column_comment(:solidus_promotions_promotion_codes, :value, value_comment) + change_column_comment(:solidus_promotions_promotion_codes, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_promotion_codes, :updated_at, updated_at_comment) + change_column_comment(:solidus_promotions_promotion_codes, :promotion_code_batch_id, promotion_code_batch_id_comment) + end + end + + private + + def solidus_promotions_promotion_codes_table_comment + <<~COMMENT + Promotions can have many promotion codes. This table is a collection of those codes. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def promotion_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_promotions table. + COMMENT + end + + def value_comment + <<~COMMENT + The actual code, such as "BOATLIFE" for example. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end + + def promotion_code_batch_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_promotion_code_batches table. + If this promotion code was created using a promotion code batch, links to the batch. + COMMENT + end +end diff --git a/promotions/db/migrate/20231011173312_add_db_comments_to_promotion_code_batches.rb b/promotions/db/migrate/20231011173312_add_db_comments_to_promotion_code_batches.rb new file mode 100644 index 00000000000..b417270ebf9 --- /dev/null +++ b/promotions/db/migrate/20231011173312_add_db_comments_to_promotion_code_batches.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class AddDbCommentsToPromotionCodeBatches < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_promotion_code_batches, solidus_promotions_promotion_code_batches_table_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :id, id_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :promotion_id, promotion_id_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :base_code, base_code_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :number_of_codes, number_of_codes_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :email, email_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :error, error_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :state, state_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :updated_at, updated_at_comment) + change_column_comment(:solidus_promotions_promotion_code_batches, :join_characters, join_characters_comment) + end + end + + private + + def solidus_promotions_promotion_code_batches_table_comment + <<~COMMENT + We allow creating a large number of promotion codes automatically through a background job. This table collects the input + for creating such a batch of promotion codes. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def promotion_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_promotions table. + COMMENT + end + + def base_code_comment + <<~COMMENT + The base code of this promotion code batch, such as "BOATLIFE". + COMMENT + end + + def number_of_codes_comment + <<~COMMENT + How many codes should be generated. + COMMENT + end + + def email_comment + <<~COMMENT + After the batch has been created, or in the event of an error, notify this email address. + COMMENT + end + + def error_comment + <<~COMMENT + Error that has occurred during batch processing, if any. + COMMENT + end + + def state_comment + <<~COMMENT + What the state of the promotion code batch is: + - pending + - processing + - completed + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end + + def join_characters_comment + <<~COMMENT + What characters to use for joining the base code with the individual extension, such as "_" for creating "BOATLIFE_S3CR3T". + COMMENT + end +end diff --git a/promotions/db/migrate/20231011184205_add_db_comments_to_condition_stores.rb b/promotions/db/migrate/20231011184205_add_db_comments_to_condition_stores.rb new file mode 100644 index 00000000000..d1f48575ee6 --- /dev/null +++ b/promotions/db/migrate/20231011184205_add_db_comments_to_condition_stores.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class AddDbCommentsToConditionStores < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_condition_stores, solidus_promotions_condition_stores_table_comment) + change_column_comment(:solidus_promotions_condition_stores, :id, id_comment) + change_column_comment(:solidus_promotions_condition_stores, :store_id, store_id_comment) + change_column_comment(:solidus_promotions_condition_stores, :condition_id, condition_id_comment) + change_column_comment(:solidus_promotions_condition_stores, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_condition_stores, :updated_at, updated_at_comment) + end + end + + private + + def solidus_promotions_condition_stores_table_comment + <<~COMMENT + Join table between conditions and stores. Only used with the condition "Store", which checks that an order + has been placed in a particular Spree::Store instance. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def store_id_comment + <<~COMMENT + Foreign key to the spree_stores table. + COMMENT + end + + def condition_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_promotion_rules table. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end +end diff --git a/promotions/db/migrate/20231011190222_add_db_comments_to_benefits.rb b/promotions/db/migrate/20231011190222_add_db_comments_to_benefits.rb new file mode 100644 index 00000000000..5b35795551d --- /dev/null +++ b/promotions/db/migrate/20231011190222_add_db_comments_to_benefits.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class AddDbCommentsToBenefits < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_benefits, solidus_promotions_benefits_table_comment) + change_column_comment(:solidus_promotions_benefits, :id, id_comment) + change_column_comment(:solidus_promotions_benefits, :promotion_id, promotion_id_comment) + change_column_comment(:solidus_promotions_benefits, :type, type_comment) + change_column_comment(:solidus_promotions_benefits, :preferences, preferences_comment) + change_column_comment(:solidus_promotions_benefits, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_benefits, :updated_at, updated_at_comment) + end + end + + private + + def solidus_promotions_benefits_table_comment + <<~COMMENT + Single Table inheritance table. Represents what to do to an order when the linked promotion is eligible. + Promotions can have many benefits. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def promotion_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_promotions table. + COMMENT + end + + def type_comment + <<~COMMENT + A class name representing which benefit this represents. + Usually SolidusPromotions::PromotionAction::Adjust{LineItem,Shipment}. + COMMENT + end + + def preferences_comment + <<~COMMENT + Preferences for this benefit. Serialized YAML. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end +end diff --git a/promotions/db/migrate/20231012020928_add_db_comments_to_condition_products.rb b/promotions/db/migrate/20231012020928_add_db_comments_to_condition_products.rb new file mode 100644 index 00000000000..b9bf9d9edb8 --- /dev/null +++ b/promotions/db/migrate/20231012020928_add_db_comments_to_condition_products.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class AddDbCommentsToConditionProducts < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_condition_products, solidus_promotions_condition_products_table_comment) + change_column_comment(:solidus_promotions_condition_products, :id, id_comment) + change_column_comment(:solidus_promotions_condition_products, :product_id, product_id_comment) + change_column_comment(:solidus_promotions_condition_products, :condition_id, condition_id_comment) + change_column_comment(:solidus_promotions_condition_products, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_condition_products, :updated_at, updated_at_comment) + end + end + + private + + def solidus_promotions_condition_products_table_comment + <<~COMMENT + Join table between conditions and products. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def product_id_comment + <<~COMMENT + Foreign key to the spree_products table. + COMMENT + end + + def condition_id_comment + <<~COMMENT + Foreign key to the solidus_promotions_conditions table. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end +end diff --git a/promotions/db/migrate/20231012120928_add_db_comments_to_promotion_categories.rb b/promotions/db/migrate/20231012120928_add_db_comments_to_promotion_categories.rb new file mode 100644 index 00000000000..d84963e9e0d --- /dev/null +++ b/promotions/db/migrate/20231012120928_add_db_comments_to_promotion_categories.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class AddDbCommentsToPromotionCategories < ActiveRecord::Migration[6.1] + def up + if connection.supports_comments? + change_table_comment(:solidus_promotions_promotion_categories, solidus_promotions_promotion_categories_table_comment) + change_column_comment(:solidus_promotions_promotion_categories, :id, id_comment) + change_column_comment(:solidus_promotions_promotion_categories, :name, name_comment) + change_column_comment(:solidus_promotions_promotion_categories, :code, code_comment) + change_column_comment(:solidus_promotions_promotion_categories, :created_at, created_at_comment) + change_column_comment(:solidus_promotions_promotion_categories, :updated_at, updated_at_comment) + end + end + + private + + def solidus_promotions_promotion_categories_table_comment + <<~COMMENT + Category that helps admins index promotions. + COMMENT + end + + def id_comment + <<~COMMENT + Primary key of this table. + COMMENT + end + + def name_comment + <<~COMMENT + Name of this promotion category. + COMMENT + end + + def code_comment + <<~COMMENT + Code of this promotion category. + COMMENT + end + + def created_at_comment + <<~COMMENT + Timestamp indicating when this record was created. + COMMENT + end + + def updated_at_comment + <<~COMMENT + Timestamp indicating when this record was last updated. + COMMENT + end +end diff --git a/promotions/db/migrate/20231104135812_add_managed_by_order_benefit_to_line_items.rb b/promotions/db/migrate/20231104135812_add_managed_by_order_benefit_to_line_items.rb new file mode 100644 index 00000000000..b0b1efaa58b --- /dev/null +++ b/promotions/db/migrate/20231104135812_add_managed_by_order_benefit_to_line_items.rb @@ -0,0 +1,5 @@ +class AddManagedByOrderBenefitToLineItems < ActiveRecord::Migration[7.0] + def change + add_reference :spree_line_items, :managed_by_order_benefit, foreign_key: { to_table: :solidus_promotions_benefits, null: true } + end +end diff --git a/promotions/lib/components/admin/solidus_admin/orders/show/adjustments/index/source/solidus_promotions_benefit/component.rb b/promotions/lib/components/admin/solidus_admin/orders/show/adjustments/index/source/solidus_promotions_benefit/component.rb new file mode 100644 index 00000000000..3f24c7c478f --- /dev/null +++ b/promotions/lib/components/admin/solidus_admin/orders/show/adjustments/index/source/solidus_promotions_benefit/component.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class SolidusAdmin::Orders::Show::Adjustments::Index::Source::SolidusPromotionsBenefit::Component < SolidusAdmin::Orders::Show::Adjustments::Index::Source::Component + def detail + link_to("#{model_name}: #{promotion_name}", solidus_promotions.edit_admin_promotion_path(adjustment.source_id), class: "body-link") + end + + private + + def promotion_name + source.promotion.name + end + + def solidus_promotions + @solidus_promotions ||= SolidusPromotions::Engine.routes.url_helpers + end +end diff --git a/promotions/lib/components/admin/solidus_promotions/orders/index/component.rb b/promotions/lib/components/admin/solidus_promotions/orders/index/component.rb new file mode 100644 index 00000000000..edcb8c5afce --- /dev/null +++ b/promotions/lib/components/admin/solidus_promotions/orders/index/component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class SolidusPromotions::Orders::Index::Component < SolidusAdmin::Orders::Index::Component + def filters + super + [ + { + label: t(".filters.promotions"), + combinator: "or", + attribute: "solidus_promotions_id", + predicate: "in", + options: SolidusPromotions::Promotion.all.pluck(:name, :id) + } + ] + end +end diff --git a/promotions/lib/components/admin/solidus_promotions/orders/index/component.yml b/promotions/lib/components/admin/solidus_promotions/orders/index/component.yml new file mode 100644 index 00000000000..95af3f495d4 --- /dev/null +++ b/promotions/lib/components/admin/solidus_promotions/orders/index/component.yml @@ -0,0 +1,3 @@ +en: + filters: + promotions: Promotions diff --git a/promotions/lib/components/admin/solidus_promotions/promotion_categories/index/component.rb b/promotions/lib/components/admin/solidus_promotions/promotion_categories/index/component.rb new file mode 100644 index 00000000000..53d675ed3ed --- /dev/null +++ b/promotions/lib/components/admin/solidus_promotions/promotion_categories/index/component.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class SolidusPromotions::PromotionCategories::Index::Component < SolidusAdmin::UI::Pages::Index::Component + def model_class + SolidusPromotions::PromotionCategory + end + + def row_url(promotion_category) + solidus_promotions.edit_admin_promotion_category_path(promotion_category) + end + + def page_actions + render component("ui/button").new( + tag: :a, + text: t(".add"), + href: solidus_promotions.new_admin_promotion_category_path, + icon: "add-line" + ) + end + + def batch_actions + [ + { + label: t(".batch_actions.delete"), + action: solidus_promotions.promotion_categories_path, + method: :delete, + icon: "delete-bin-7-line" + } + ] + end + + def columns + [ + name_column, + code_column + ] + end + + def name_column + { + header: :name, + data: ->(promotion_category) do + content_tag :div, promotion_category.name + end + } + end + + def code_column + { + header: :code, + data: ->(promotion_category) do + content_tag :div, promotion_category.code + end + } + end + + def solidus_promotions + @solidus_promotions ||= SolidusPromotions::Engine.routes.url_helpers + end +end diff --git a/promotions/lib/components/admin/solidus_promotions/promotions/index/component.rb b/promotions/lib/components/admin/solidus_promotions/promotions/index/component.rb new file mode 100644 index 00000000000..19cf4b79772 --- /dev/null +++ b/promotions/lib/components/admin/solidus_promotions/promotions/index/component.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +class SolidusPromotions::Promotions::Index::Component < SolidusAdmin::UI::Pages::Index::Component + def model_class + SolidusPromotions::Promotion + end + + def search_key + :name_or_codes_value_or_path_or_description_cont + end + + def search_url + solidus_promotions.promotions_path + end + + def row_url(promotion) + solidus_promotions.admin_promotion_path(promotion) + end + + def page_actions + render component("ui/button").new( + tag: :a, + text: t(".add"), + href: solidus_promotions.new_admin_promotion_path, + icon: "add-line" + ) + end + + def batch_actions + [ + { + label: t(".batch_actions.delete"), + action: solidus_promotions.promotions_path, + method: :delete, + icon: "delete-bin-7-line" + } + ] + end + + def scopes + [ + { name: :active, label: t(".scopes.active"), default: true }, + { name: :draft, label: t(".scopes.draft") }, + { name: :future, label: t(".scopes.future") }, + { name: :expired, label: t(".scopes.expired") }, + { name: :all, label: t(".scopes.all") } + ] + end + + def filters + [ + { + label: SolidusPromotions::PromotionCategory.model_name.human.pluralize, + attribute: "promotion_category_id", + predicate: "in", + options: SolidusPromotions::PromotionCategory.pluck(:name, :id) + } + ] + end + + def columns + [ + { + header: :name, + data: ->(promotion) do + content_tag :div, promotion.name + end + }, + { + header: :code, + data: ->(promotion) do + count = promotion.codes.count + (count == 1) ? promotion.codes.pick(:value) : t("spree.number_of_codes", count: count) + end + }, + { + header: :status, + data: ->(promotion) do + if promotion.active? + render component("ui/badge").new(name: t(".status.active"), color: :green) + else + render component("ui/badge").new(name: t(".status.inactive"), color: :graphite_light) + end + end + }, + { + header: :usage_limit, + data: ->(promotion) { promotion.usage_limit || icon_tag("infinity-line") } + }, + { + header: :uses, + data: ->(promotion) { promotion.usage_count } + }, + { + header: :starts_at, + data: ->(promotion) { promotion.starts_at ? l(promotion.starts_at, format: :long) : icon_tag("infinity-line") } + }, + { + header: :expires_at, + data: ->(promotion) { promotion.expires_at ? l(promotion.expires_at, format: :long) : icon_tag("infinity-line") } + } + ] + end + + def solidus_promotions + @solidus_promotions ||= SolidusPromotions::Engine.routes.url_helpers + end +end diff --git a/promotions/lib/components/admin/solidus_promotions/promotions/index/component.yml b/promotions/lib/components/admin/solidus_promotions/promotions/index/component.yml new file mode 100644 index 00000000000..7ba70160390 --- /dev/null +++ b/promotions/lib/components/admin/solidus_promotions/promotions/index/component.yml @@ -0,0 +1,10 @@ +en: + scopes: + active: Active + draft: Draft + future: Future + expired: Expired + all: All + status: + active: Active + inactive: Inactive diff --git a/promotions/lib/controllers/admin/solidus_promotions/promotion_categories_controller.rb b/promotions/lib/controllers/admin/solidus_promotions/promotion_categories_controller.rb new file mode 100644 index 00000000000..f27166e38d5 --- /dev/null +++ b/promotions/lib/controllers/admin/solidus_promotions/promotion_categories_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionCategoriesController < SolidusAdmin::BaseController + include SolidusAdmin::ControllerHelpers::Search + + def index + promotion_categories = apply_search_to( + SolidusPromotions::PromotionCategory.all, + param: :q + ) + + set_page_and_extract_portion_from(promotion_categories) + + respond_to do |format| + format.html { render component("promotion_categories/index").new(page: @page) } + end + end + + def destroy + @promotion_categories = SolidusPromotions::PromotionCategory.where(id: params[:id]) + + SolidusPromotions::PromotionCategory.transaction { @promotion_categories.destroy_all } + + flash[:notice] = t(".success") + redirect_back_or_to solidus_promotions.promotion_categories_path, status: :see_other + end + end +end diff --git a/promotions/lib/controllers/admin/solidus_promotions/promotions_controller.rb b/promotions/lib/controllers/admin/solidus_promotions/promotions_controller.rb new file mode 100644 index 00000000000..de2fcf25f78 --- /dev/null +++ b/promotions/lib/controllers/admin/solidus_promotions/promotions_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionsController < SolidusAdmin::BaseController + include SolidusAdmin::ControllerHelpers::Search + + search_scope(:active, default: true, &:active) + search_scope(:draft) { _1.where.not(id: _1.has_benefits.select(:id)) } + search_scope(:future) { _1.has_benefits.where(starts_at: Time.current..) } + search_scope(:expired) { _1.has_benefits.where(expires_at: ..Time.current) } + search_scope(:all) + + def index + promotions = apply_search_to( + SolidusPromotions::Promotion.order(id: :desc), + param: :q + ) + + set_page_and_extract_portion_from(promotions) + + respond_to do |format| + format.html { render component("promotions/index").new(page: @page) } + end + end + + def destroy + @promotions = SolidusPromotions::Promotion.where(id: params[:id]) + + SolidusPromotions::Promotion.transaction { @promotions.destroy_all } + + flash[:notice] = t(".success") + redirect_back_or_to promotions_path, status: :see_other + end + + private + + def load_promotion + @promotion = SolidusPromotions::Promotion.find_by!(number: params[:id]) + authorize! action_name, @promotion + end + + def promotion_params + params.require(:promotion).permit(:user_id, permitted_promotion_attributes) + end + end +end diff --git a/promotions/lib/controllers/backend/solidus_promotions/admin/base_controller.rb b/promotions/lib/controllers/backend/solidus_promotions/admin/base_controller.rb new file mode 100644 index 00000000000..666aaac3ac0 --- /dev/null +++ b/promotions/lib/controllers/backend/solidus_promotions/admin/base_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Admin + class BaseController < Spree::Admin::ResourceController + def routes_proxy + solidus_promotions + end + + def parent_model_name + self.class.parent_data[:model_name].gsub("solidus_promotions/", "") + end + end + end +end diff --git a/promotions/lib/controllers/backend/solidus_promotions/admin/benefits_controller.rb b/promotions/lib/controllers/backend/solidus_promotions/admin/benefits_controller.rb new file mode 100644 index 00000000000..cd0f40da056 --- /dev/null +++ b/promotions/lib/controllers/backend/solidus_promotions/admin/benefits_controller.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Admin + class BenefitsController < Spree::Admin::BaseController + before_action :load_promotion, only: [:create, :destroy, :new, :update, :edit] + before_action :validate_benefit_type, only: [:create, :edit] + + def new + @benefit = @promotion.benefits.build(benefit_params) + render layout: false + end + + def create + @benefit = @benefit_type.new(benefit_params) + @benefit.promotion = @promotion + if @benefit.save(validate: false) + flash[:success] = + t("spree.successfully_created", resource: SolidusPromotions::Benefit.model_name.human) + redirect_to location_after_save, format: :html + else + render :new, layout: false + end + end + + def edit + @benefit = @promotion.benefits.find(params[:id]) + if params.dig(:benefit, :calculator_type) + @benefit.calculator_type = params[:benefit][:calculator_type] + end + render layout: false + end + + def update + @benefit = @promotion.benefits.find(params[:id]) + @benefit.assign_attributes(benefit_params) + if @benefit.save + flash[:success] = + t("spree.successfully_updated", resource: SolidusPromotions::Benefit.model_name.human) + redirect_to location_after_save, format: :html + else + render :edit + end + end + + def destroy + @benefit = @promotion.benefits.find(params[:id]) + if @benefit.destroy + flash[:success] = + t("spree.successfully_removed", resource: SolidusPromotions::Benefit.model_name.human) + end + redirect_to location_after_save, format: :html + end + + private + + def location_after_save + solidus_promotions.edit_admin_promotion_path(@promotion) + end + + def load_promotion + @promotion = SolidusPromotions::Promotion.find(params[:promotion_id]) + end + + def benefit_params + params[:benefit].try(:permit!) || {} + end + + def validate_benefit_type + requested_type = params[:benefit].delete(:type) + benefit_types = SolidusPromotions.config.benefits + @benefit_type = benefit_types.detect do |klass| + klass.name == requested_type + end + return if @benefit_type + + flash[:error] = t("solidus_promotions.invalid_benefit") + respond_to do |format| + format.html { redirect_to solidus_promotions.edit_admin_promotion_path(@promotion) } + format.js { render layout: false } + end + end + end + end +end diff --git a/promotions/lib/controllers/backend/solidus_promotions/admin/conditions_controller.rb b/promotions/lib/controllers/backend/solidus_promotions/admin/conditions_controller.rb new file mode 100644 index 00000000000..a35974488d4 --- /dev/null +++ b/promotions/lib/controllers/backend/solidus_promotions/admin/conditions_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Admin + class ConditionsController < Spree::Admin::BaseController + helper "solidus_promotions/admin/conditions" + + before_action :load_benefit, only: [:create, :destroy, :update, :new] + rescue_from ActiveRecord::SubclassNotFound, with: :invalid_condition_error + + def new + @condition = @benefit.conditions.build(condition_params) + render layout: false + end + + def create + @condition = @benefit.conditions.build(condition_params) + if @condition.save + flash[:success] = + t("spree.successfully_created", resource: model_class.model_name.human) + end + redirect_to location_after_save + end + + def update + @condition = @benefit.conditions.find(params[:id]) + @condition.assign_attributes(condition_params) + if @condition.save + flash[:success] = + t("spree.successfully_updated", resource: model_class.model_name.human) + end + redirect_to location_after_save + end + + def destroy + @condition = @benefit.conditions.find(params[:id]) + if @condition.destroy + flash[:success] = + t("spree.successfully_removed", resource: model_class.model_name.human) + end + redirect_to location_after_save + end + + private + + def invalid_condition_error + flash[:error] = t("solidus_promotions.invalid_condition") + redirect_to location_after_save + end + + def location_after_save + solidus_promotions.edit_admin_promotion_path(@promotion) + end + + def load_benefit + @promotion = SolidusPromotions::Promotion.find(params[:promotion_id]) + @benefit = @promotion.benefits.find(params[:benefit_id]) + end + + def model_class + SolidusPromotions::Condition + end + + def condition_params + params[:condition].try(:permit!) || {} + end + end + end +end diff --git a/promotions/lib/controllers/backend/solidus_promotions/admin/promotion_categories_controller.rb b/promotions/lib/controllers/backend/solidus_promotions/admin/promotion_categories_controller.rb new file mode 100644 index 00000000000..70346c208a3 --- /dev/null +++ b/promotions/lib/controllers/backend/solidus_promotions/admin/promotion_categories_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Admin + class PromotionCategoriesController < BaseController + private + + def model_class + SolidusPromotions::PromotionCategory + end + end + end +end diff --git a/promotions/lib/controllers/backend/solidus_promotions/admin/promotion_code_batches_controller.rb b/promotions/lib/controllers/backend/solidus_promotions/admin/promotion_code_batches_controller.rb new file mode 100644 index 00000000000..ff08d7215a6 --- /dev/null +++ b/promotions/lib/controllers/backend/solidus_promotions/admin/promotion_code_batches_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Admin + class PromotionCodeBatchesController < BaseController + belongs_to "solidus_promotions/promotion" + + create.after :build_promotion_code_batch + + def download + require "csv" + + @promotion_code_batch = SolidusPromotions::PromotionCodeBatch.find( + params[:promotion_code_batch_id] + ) + + send_data( + render_to_string, + filename: "promotion-code-batch-list-#{@promotion_code_batch.id}.csv" + ) + end + + private + + def build_promotion_code_batch + @promotion_code_batch.process + end + + def model_class + SolidusPromotions::PromotionCodeBatch + end + + def collection + parent.code_batches + end + + def build_resource + parent.code_batches.build + end + end + end +end diff --git a/promotions/lib/controllers/backend/solidus_promotions/admin/promotion_codes_controller.rb b/promotions/lib/controllers/backend/solidus_promotions/admin/promotion_codes_controller.rb new file mode 100644 index 00000000000..ee23a457469 --- /dev/null +++ b/promotions/lib/controllers/backend/solidus_promotions/admin/promotion_codes_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "csv" + +module SolidusPromotions + module Admin + class PromotionCodesController < BaseController + before_action :load_promotion + + def index + @promotion_codes = @promotion.codes.order(:value) + + respond_to do |format| + format.html do + @promotion_codes = @promotion_codes.page(params[:page]).per(50) + end + format.csv do + filename = "promotion-code-list-#{@promotion.id}.csv" + headers["Content-Type"] = "text/csv" + headers["Content-disposition"] = "attachment; filename=\"#{filename}\"" + end + end + end + + def new + if @promotion.apply_automatically + flash[:error] = t( + :disallowed_with_apply_automatically, + scope: "activerecord.errors.models.solidus_promotions/promotion_code.attributes.base" + ) + redirect_to solidus_promotions.admin_promotion_promotion_codes_url(@promotion) + else + @promotion_code = @promotion.codes.build + end + end + + def create + @promotion_code = @promotion.codes.build(value: params[:promotion_code][:value]) + + if @promotion_code.save + flash[:success] = flash_message_for(@promotion_code, :successfully_created) + redirect_to solidus_promotions.admin_promotion_promotion_codes_url(@promotion) + else + flash.now[:error] = @promotion_code.errors.full_messages.to_sentence + render_after_create_error + end + end + + private + + def load_promotion + @promotion = SolidusPromotions::Promotion + .accessible_by(current_ability, :show) + .find(params[:promotion_id]) + end + end + end +end diff --git a/promotions/lib/controllers/backend/solidus_promotions/admin/promotions_controller.rb b/promotions/lib/controllers/backend/solidus_promotions/admin/promotions_controller.rb new file mode 100644 index 00000000000..6a1e9a9fb7e --- /dev/null +++ b/promotions/lib/controllers/backend/solidus_promotions/admin/promotions_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Admin + class PromotionsController < BaseController + before_action :load_data + + helper "solidus_promotions/admin/conditions" + helper "solidus_promotions/admin/benefits" + helper "solidus_promotions/admin/promotions" + + def create + @promotion = model_class.new(permitted_resource_params) + @promotion.codes.new(value: params[:single_code]) if params[:single_code].present? + + if params[:code_batch] + @code_batch = @promotion.code_batches.new(code_batch_params) + end + + if @promotion.save + @code_batch&.process + flash[:success] = t("solidus_promotions.promotion_successfully_created") + redirect_to location_after_save + else + flash[:error] = @promotion.errors.full_messages.to_sentence + render action: "new" + end + end + + private + + def collection + return @collection if @collection + + params[:q] ||= HashWithIndifferentAccess.new + params[:q][:s] ||= "updated_at desc" + + @collection = super + @search = @collection.ransack(params[:q]) + @collection = @search.result(distinct: true) + .includes(promotion_includes) + .page(params[:page]) + .per(params[:per_page] || SolidusPromotions.config.promotions_per_page) + + @collection + end + + def promotion_includes + [:benefits] + end + + def model_class + SolidusPromotions::Promotion + end + + def load_data + @promotion_categories = PromotionCategory.order(:name) + end + + def location_after_save + solidus_promotions.edit_admin_promotion_url(@promotion) + end + end + end +end diff --git a/promotions/lib/generators/solidus_promotions/install/install_generator.rb b/promotions/lib/generators/solidus_promotions/install/install_generator.rb new file mode 100644 index 00000000000..d1365879428 --- /dev/null +++ b/promotions/lib/generators/solidus_promotions/install/install_generator.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module SolidusPromotions + module Generators + class InstallGenerator < Rails::Generators::Base + class_option :auto_run_migrations, type: :boolean, default: false + source_root File.expand_path("templates", __dir__) + + def self.exit_on_failure? + true + end + + def copy_initializer + template "initializer.rb", "config/initializers/solidus_promotions.rb" + end + + def add_migrations + run "bin/rails railties:install:migrations FROM=solidus_promotions" + end + + def modify_spree_initializer + spree_rb_path = "config/initializers/spree.rb" + new_content = <<-RUBY.gsub(/^ {8}/, "") + # Make sure we use Spree::SimpleOrderContents + # Spree::Config.order_contents_class = "Spree::SimpleOrderContents" + # Set the promotion configuration to ours + # Spree::Config.promotions = SolidusPromotions.configuration + RUBY + insert_into_file spree_rb_path, new_content, after: "Spree.config do |config|\n" + end + + def mount_engine + inject_into_file "config/routes.rb", + " mount SolidusPromotions::Engine => '/'\n", + before: / mount Spree::Core::Engine.*/, + verbose: true + end + + def run_migrations + run_migrations = options[:auto_run_migrations] || ["", "y", "Y"].include?(ask("Would you like to run the migrations now? [Y/n]")) + if run_migrations + run "bin/rails db:migrate" + else + puts "Skipping bin/rails db:migrate, don't forget to run it!" + end + end + + def explain_promotion_config + say "SolidusPromotions is now installed. You can configure it by editing the initializer at `config/initializers/solidus_promotions.rb`." + say "By default, it is not activated. In order to activate it, you need to set `Spree::Config.promotions` to `SolidusPromotions.configuration`" \ + "in your `config/initializers/spree.rb` file." + say "If you have been running the legacy promotion system, we recommend converting your existing promotions using the `solidus_promotions:migrate_existing_promotions` rake task." + end + end + end +end diff --git a/promotions/lib/generators/solidus_promotions/install/templates/initializer.rb b/promotions/lib/generators/solidus_promotions/install/templates/initializer.rb new file mode 100644 index 00000000000..27349a57e61 --- /dev/null +++ b/promotions/lib/generators/solidus_promotions/install/templates/initializer.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# SolidusPromotions.configure do |config| +# # Add your own configuration here. See https://github.com/solidusio/solidus/blob/main/promotions/lib/solidus_promotions/configuration.rb +# # to learn more about the available options. +# end diff --git a/promotions/lib/solidus_promotions.rb b/promotions/lib/solidus_promotions.rb new file mode 100644 index 00000000000..2e6521828b3 --- /dev/null +++ b/promotions/lib/solidus_promotions.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "solidus_core" +require "solidus_support" +require "turbo-rails" +require "importmap-rails" +require "stimulus-rails" +require "ransack-enum" + +# We carry controllers and views for both the traditional backend +# and the new Solidus Admin interface, but we want to continue to function +# if either of them are not present. If they are present, +# however, they need to load before us. +begin + require "solidus_backend" +rescue LoadError + # Solidus backend is not available +end + +begin + require "solidus_admin" +rescue LoadError + # Solidus Admin is not available +end + +module SolidusPromotions + def self.table_name_prefix + "solidus_promotions_" + end + + # JS Importmap instance + singleton_class.attr_accessor :importmap + self.importmap = Importmap::Map.new +end + +require "solidus_promotions/configuration" +require "solidus_promotions/engine" diff --git a/promotions/lib/solidus_promotions/configuration.rb b/promotions/lib/solidus_promotions/configuration.rb new file mode 100644 index 00000000000..05d9503344c --- /dev/null +++ b/promotions/lib/solidus_promotions/configuration.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "spree/core/environment_extension" + +module SolidusPromotions + class Configuration < Spree::Preferences::Configuration + include Spree::Core::EnvironmentExtension + + class_name_attribute :order_adjuster_class, default: "SolidusPromotions::OrderAdjuster" + + class_name_attribute :coupon_code_handler_class, default: "SolidusPromotions::PromotionHandler::Coupon" + + class_name_attribute :promotion_finder_class, default: "SolidusPromotions::PromotionFinder" + + # Allows providing a different promotion advertiser. + # @!attribute [rw] advertiser_class + # @see Spree::NullPromotionAdvertiser + # @return [Class] an object that conforms to the API of + # the standard promotion advertiser class + # Spree::NullPromotionAdvertiser. + class_name_attribute :advertiser_class, default: "SolidusPromotions::PromotionAdvertiser" + + # In case solidus_legacy_promotions is loaded, we need to define this. + class_name_attribute :shipping_promotion_handler_class, default: "Spree::NullPromotionHandler" + + add_class_set :order_conditions, default: [ + "SolidusPromotions::Conditions::FirstOrder", + "SolidusPromotions::Conditions::FirstRepeatPurchaseSince", + "SolidusPromotions::Conditions::ItemTotal", + "SolidusPromotions::Conditions::DiscountedItemTotal", + "SolidusPromotions::Conditions::MinimumQuantity", + "SolidusPromotions::Conditions::NthOrder", + "SolidusPromotions::Conditions::OneUsePerUser", + "SolidusPromotions::Conditions::OptionValue", + "SolidusPromotions::Conditions::Product", + "SolidusPromotions::Conditions::Store", + "SolidusPromotions::Conditions::Taxon", + "SolidusPromotions::Conditions::UserLoggedIn", + "SolidusPromotions::Conditions::UserRole", + "SolidusPromotions::Conditions::User" + ] + + add_class_set :line_item_conditions, default: [ + "SolidusPromotions::Conditions::LineItemOptionValue", + "SolidusPromotions::Conditions::LineItemProduct", + "SolidusPromotions::Conditions::LineItemTaxon" + ] + add_class_set :shipment_conditions, default: [ + "SolidusPromotions::Conditions::ShippingMethod" + ] + + add_class_set :benefits, default: [ + "SolidusPromotions::Benefits::AdjustLineItem", + "SolidusPromotions::Benefits::AdjustLineItemQuantityGroups", + "SolidusPromotions::Benefits::AdjustShipment", + "SolidusPromotions::Benefits::CreateDiscountedItem" + ] + + add_nested_class_set :promotion_calculators, default: { + "SolidusPromotions::Benefits::AdjustShipment" => [ + "SolidusPromotions::Calculators::FlatRate", + "SolidusPromotions::Calculators::FlexiRate", + "SolidusPromotions::Calculators::Percent", + "SolidusPromotions::Calculators::TieredFlatRate", + "SolidusPromotions::Calculators::TieredPercent", + "SolidusPromotions::Calculators::TieredPercentOnEligibleItemQuantity" + ], + "SolidusPromotions::Benefits::AdjustLineItem" => [ + "SolidusPromotions::Calculators::DistributedAmount", + "SolidusPromotions::Calculators::FlatRate", + "SolidusPromotions::Calculators::FlexiRate", + "SolidusPromotions::Calculators::Percent", + "SolidusPromotions::Calculators::TieredFlatRate", + "SolidusPromotions::Calculators::TieredPercent", + "SolidusPromotions::Calculators::TieredPercentOnEligibleItemQuantity" + ], + "SolidusPromotions::Benefits::AdjustLineItemQuantityGroups" => [ + "SolidusPromotions::Calculators::FlatRate", + "SolidusPromotions::Calculators::Percent", + "SolidusPromotions::Calculators::TieredPercentOnEligibleItemQuantity" + ], + "SolidusPromotions::Benefits::CreateDiscountedItem" => [ + "SolidusPromotions::Calculators::FlatRate", + "SolidusPromotions::Calculators::Percent", + "SolidusPromotions::Calculators::TieredPercentOnEligibleItemQuantity" + ] + } + + class_name_attribute :discount_chooser_class, default: "SolidusPromotions::OrderAdjuster::ChooseDiscounts" + class_name_attribute :promotion_code_batch_mailer_class, + default: "SolidusPromotions::PromotionCodeBatchMailer" + + # @!attribute [rw] promotions_per_page + # @return [Integer] Promotions to show per-page in the admin (default: +25+) + preference :promotions_per_page, :integer, default: 25 + + preference :lanes, :hash, default: { + pre: 0, + default: 1, + post: 2 + } + + preference :recalculate_complete_orders, :boolean, default: true + + preference :sync_order_promotions, :boolean, default: false + + preference :use_new_admin, :boolean, default: false + + def use_new_admin? + SolidusSupport.admin_available? && preferred_use_new_admin + end + end + + class << self + def configuration + @configuration ||= Configuration.new + end + + alias_method :config, :configuration + + def configure + yield configuration + end + end +end diff --git a/promotions/lib/solidus_promotions/engine.rb b/promotions/lib/solidus_promotions/engine.rb new file mode 100644 index 00000000000..97330073d1b --- /dev/null +++ b/promotions/lib/solidus_promotions/engine.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "solidus_core" +require "solidus_support" + +module SolidusPromotions + class Engine < Rails::Engine + include SolidusSupport::EngineExtensions + + isolate_namespace ::SolidusPromotions + + engine_name "solidus_promotions" + + # use rspec for tests + config.generators do |g| + g.test_framework :rspec + end + + initializer "solidus_promotions.assets" do |app| + if SolidusSupport.backend_available? + app.config.assets.precompile << "solidus_promotions/manifest.js" + end + end + + initializer "solidus_promotions.importmap" do |app| + if SolidusSupport.backend_available? + SolidusPromotions.importmap.draw(Engine.root.join("config", "importmap.rb")) + + package_path = Engine.root.join("app/javascript") + app.config.assets.paths << package_path + + if app.config.importmap.sweep_cache + SolidusPromotions.importmap.cache_sweeper(watches: package_path) + ActiveSupport.on_load(:action_controller_base) do + before_action { SolidusPromotions.importmap.cache_sweeper.execute_if_updated } + end + end + end + end + + initializer "solidus_promotions.spree_config", after: "spree.load_config_initializers" do + Spree::Config.adjustment_promotion_source_types << "SolidusPromotions::Benefit" + + Rails.application.config.to_prepare do + Spree::Order.line_item_comparison_hooks << :free_from_order_benefit? + end + end + + initializer "solidus_promotions.core.pub_sub", after: "spree.core.pub_sub" do |app| + app.reloader.to_prepare do + SolidusPromotions::OrderPromotionSubscriber.new.subscribe_to(Spree::Bus) + end + end + + initializer "solidus_promotions.add_admin_order_index_component", after: "solidus_legacy_promotions.add_admin_order_index_component" do + if SolidusSupport.admin_available? + SolidusAdmin::Config.components["orders/index"] = "SolidusPromotions::Orders::Index::Component" + SolidusAdmin::Config.components["promotions/index"] = "SolidusPromotions::Promotions::Index::Component" + SolidusAdmin::Config.components["promotion_categories/index"] = "SolidusPromotions::PromotionCategories::Index::Component" + end + end + + initializer "solidus_promotions.add_backend_menus", after: "spree.backend.environment" do + if SolidusSupport.backend_available? + # Replace the promotions menu from core with ours + Spree::Backend::Config.configure do |config| + config.menu_items = config.menu_items.flat_map do |item| + next item unless item.label.to_sym == :promotions + + [ + Spree::BackendConfiguration::MenuItem.new( + label: :promotions, + icon: config.admin_updated_navbar ? "ri-megaphone-line" : "bullhorn", + condition: -> { can?(:admin, SolidusPromotions::Promotion) }, + url: -> { SolidusPromotions::Engine.routes.url_helpers.admin_promotions_path }, + data_hook: :admin_solidus_promotion_sub_tabs, + children: [ + Spree::BackendConfiguration::MenuItem.new( + label: :promotions, + url: -> { SolidusPromotions::Engine.routes.url_helpers.admin_promotions_path }, + condition: -> { can?(:admin, SolidusPromotions::Promotion) } + ), + Spree::BackendConfiguration::MenuItem.new( + label: :promotion_categories, + url: -> { SolidusPromotions::Engine.routes.url_helpers.admin_promotion_categories_path }, + condition: -> { can?(:admin, SolidusPromotions::PromotionCategory) } + ) + ] + ), + Spree::BackendConfiguration::MenuItem.new( + label: :legacy_promotions, + icon: config.admin_updated_navbar ? "ri-megaphone-line" : "bullhorn", + condition: -> { can?(:admin, SolidusPromotions::Promotion) }, + url: -> { Spree::Core::Engine.routes.url_helpers.admin_promotions_path }, + data_hook: :admin_promotion_sub_tabs, + children: [ + Spree::BackendConfiguration::MenuItem.new( + label: :legacy_promotions, + condition: -> { can?(:admin, Spree::Promotion && Spree::Promotion.any?) }, + url: -> { Spree::Core::Engine.routes.url_helpers.admin_promotions_path }, + ), + Spree::BackendConfiguration::MenuItem.new( + label: :legacy_promotion_categories, + condition: -> { can?(:admin, Spree::PromotionCategory && Spree::Promotion.any?) }, + url: -> { Spree::Core::Engine.routes.url_helpers.admin_promotion_categories_path }, + ) + ] + ) + ] + end + end + end + end + end +end diff --git a/promotions/lib/solidus_promotions/migrate_adjustments.rb b/promotions/lib/solidus_promotions/migrate_adjustments.rb new file mode 100644 index 00000000000..03ae34c3d97 --- /dev/null +++ b/promotions/lib/solidus_promotions/migrate_adjustments.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module SolidusPromotions + class MigrateAdjustments + class << self + def up + sql = if ActiveRecord::Base.connection_db_config.adapter == "mysql2" + <<~SQL + UPDATE spree_adjustments + INNER JOIN spree_promotion_actions ON spree_adjustments.source_id = spree_promotion_actions.id and spree_adjustments.source_type = 'Spree::PromotionAction' + INNER JOIN solidus_promotions_benefits ON solidus_promotions_benefits.original_promotion_action_id = spree_promotion_actions.id + SET source_id = solidus_promotions_benefits.id, + source_type = 'SolidusPromotions::Benefit' + SQL + else + <<~SQL + UPDATE spree_adjustments + SET source_id = solidus_promotions_benefits.id, + source_type = 'SolidusPromotions::Benefit' + FROM spree_promotion_actions + INNER JOIN solidus_promotions_benefits ON solidus_promotions_benefits.original_promotion_action_id = spree_promotion_actions.id + WHERE spree_adjustments.source_id = spree_promotion_actions.id and spree_adjustments.source_type = 'Spree::PromotionAction' + SQL + end + + execute(sql) + end + + def down + sql = if ActiveRecord::Base.connection_db_config.adapter == "mysql2" + <<~SQL + UPDATE spree_adjustments + INNER JOIN solidus_promotions_benefits + INNER JOIN spree_promotion_actions ON spree_adjustments.source_id = solidus_promotions_benefits.id and spree_adjustments.source_type = 'SolidusPromotions::Benefit' + SET source_id = spree_promotion_actions.id, + source_type = 'Spree::PromotionAction' + WHERE solidus_promotions_benefits.original_promotion_action_id = spree_promotion_actions.id + SQL + else + <<~SQL + UPDATE spree_adjustments + SET source_id = spree_promotion_actions.id, + source_type = 'Spree::PromotionAction' + FROM spree_promotion_actions + INNER JOIN solidus_promotions_benefits ON solidus_promotions_benefits.original_promotion_action_id = spree_promotion_actions.id + WHERE spree_adjustments.source_id = solidus_promotions_benefits.id and spree_adjustments.source_type = 'SolidusPromotions::Benefit' + SQL + end + + execute(sql) + end + + private + + def execute(sql) + Spree::Adjustment.transaction do + ActiveRecord::Base.connection.execute(sql) + end + end + end + end +end diff --git a/promotions/lib/solidus_promotions/migrate_order_promotions.rb b/promotions/lib/solidus_promotions/migrate_order_promotions.rb new file mode 100644 index 00000000000..82d61709981 --- /dev/null +++ b/promotions/lib/solidus_promotions/migrate_order_promotions.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module SolidusPromotions + class MigrateOrderPromotions + class << self + def up + sql = <<~SQL + INSERT INTO solidus_promotions_order_promotions ( + order_id, + promotion_id, + promotion_code_id, + created_at, + updated_at + ) + SELECT + spree_orders_promotions.order_id AS order_id, + solidus_promotions_promotions.id AS promotion_id, + solidus_promotions_promotion_codes.id AS promotion_code_id, + spree_orders_promotions.created_at, + spree_orders_promotions.updated_at + FROM spree_orders_promotions + INNER JOIN spree_promotions ON spree_orders_promotions.promotion_id = spree_promotions.id + INNER JOIN solidus_promotions_promotions ON spree_promotions.id = solidus_promotions_promotions.original_promotion_id + LEFT OUTER JOIN spree_promotion_codes ON spree_orders_promotions.promotion_code_id = spree_promotion_codes.id + LEFT OUTER JOIN solidus_promotions_promotion_codes ON spree_promotion_codes.value = solidus_promotions_promotion_codes.value + WHERE NOT EXISTS ( + SELECT NULL + FROM solidus_promotions_order_promotions + WHERE solidus_promotions_order_promotions.order_id = order_id + AND (solidus_promotions_order_promotions.promotion_code_id = promotion_code_id OR promotion_code_id IS NULL) + AND solidus_promotions_order_promotions.promotion_id = promotion_id + ); + SQL + ActiveRecord::Base.connection.execute(sql) + + Spree::OrderPromotion.delete_all + end + + def down + sql = <<~SQL + INSERT INTO spree_orders_promotions ( + order_id, + promotion_id, + promotion_code_id, + created_at, + updated_at + ) + SELECT + solidus_promotions_order_promotions.order_id AS order_id, + spree_promotions.id AS promotion_id, + spree_promotion_codes.id AS promotion_code_id, + solidus_promotions_order_promotions.created_at, + solidus_promotions_order_promotions.updated_at + FROM solidus_promotions_order_promotions + INNER JOIN solidus_promotions_promotions ON solidus_promotions_order_promotions.promotion_id = solidus_promotions_promotions.id + INNER JOIN spree_promotions ON spree_promotions.id = solidus_promotions_promotions.original_promotion_id + LEFT OUTER JOIN solidus_promotions_promotion_codes ON solidus_promotions_order_promotions.promotion_code_id = solidus_promotions_promotion_codes.id + LEFT OUTER JOIN spree_promotion_codes ON spree_promotion_codes.value = solidus_promotions_promotion_codes.value + WHERE NOT EXISTS ( + SELECT NULL + FROM spree_orders_promotions + WHERE spree_orders_promotions.order_id = order_id + AND (spree_orders_promotions.promotion_code_id = promotion_code_id OR promotion_code_id IS NULL) + AND spree_orders_promotions.promotion_id = promotion_id + ); + SQL + ActiveRecord::Base.connection.execute(sql) + + SolidusPromotions::OrderPromotion.delete_all + end + end + end +end diff --git a/promotions/lib/solidus_promotions/promotion_map.rb b/promotions/lib/solidus_promotions/promotion_map.rb new file mode 100644 index 00000000000..8a8ed1005b3 --- /dev/null +++ b/promotions/lib/solidus_promotions/promotion_map.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module SolidusPromotions + # This constants maps rules and actions from the legacy promotion system to conditions and benefits, respectively. + # This is used to migrate promotions from the legacy promotion system to the new one. + # If you have custom rules or actions, you can add them to this hash like so: + + # # lib/my_store/promotion_map.rb + # require "solidus_promotions/promotion_map" + # + # MyStore::PROMOTION_MAP = SolidusPromotions::PROMOTION_MAP.merge( + # conditions: { + # Spree::Promotion::Rules::MyCustomRule => MyStore::Conditions::MyCustomCondition + # }, + # actions: { + # Spree::Promotion::Actions::MyCustomAction => MyStore::Benefits::MyCustomAction + # } + # ) + + # And then use it in a custom rake task like so: + + # # lib/rake/my_store.rake + # namespace :my_store do + # desc "Migrate Spree Promotions to Friendly Promotions using a map" + # task migrate_existing_promotions: :environment do + # require "solidus_promotions/promotion_migrator" + # require "my_store/promotion_map" + # SolidusPromotions::PromotionMigrator.new(MyStore::PROMOTION_MAP).call + # end + # end + + # Note that the key in both the conditions and actions hash should be the class of the rule or action you want to map, while the value can be either + # a class or a lambda that returns a class. If you use a lambda, it will be called with the old action or rule as an argument. + PROMOTION_MAP = { + conditions: { + Spree::Promotion::Rules::ItemTotal => + SolidusPromotions::Conditions::ItemTotal, + Spree::Promotion::Rules::Product => + SolidusPromotions::Conditions::Product, + Spree::Promotion::Rules::User => + SolidusPromotions::Conditions::User, + Spree::Promotion::Rules::FirstOrder => + SolidusPromotions::Conditions::FirstOrder, + Spree::Promotion::Rules::UserLoggedIn => + SolidusPromotions::Conditions::UserLoggedIn, + Spree::Promotion::Rules::OneUsePerUser => + SolidusPromotions::Conditions::OneUsePerUser, + Spree::Promotion::Rules::Taxon => + SolidusPromotions::Conditions::Taxon, + Spree::Promotion::Rules::NthOrder => + SolidusPromotions::Conditions::NthOrder, + Spree::Promotion::Rules::OptionValue => + SolidusPromotions::Conditions::OptionValue, + Spree::Promotion::Rules::FirstRepeatPurchaseSince => + SolidusPromotions::Conditions::FirstRepeatPurchaseSince, + Spree::Promotion::Rules::UserRole => + SolidusPromotions::Conditions::UserRole, + Spree::Promotion::Rules::Store => + SolidusPromotions::Conditions::Store + }, + actions: { + Spree::Promotion::Actions::CreateAdjustment => ->(old_action) { + calculator = case old_action.calculator + when Spree::Calculator::FlatRate + SolidusPromotions::Calculators::DistributedAmount.new(preferences: old_action.calculator.preferences) + when Spree::Calculator::FlatPercentItemTotal + SolidusPromotions::Calculators::Percent.new(preferred_percent: old_action.calculator.preferred_flat_percent) + end + + SolidusPromotions::Benefits::AdjustLineItem.new( + calculator: calculator + ) + }, + Spree::Promotion::Actions::CreateItemAdjustments => ->(old_action) { + preferences = old_action.calculator.preferences + calculator = case old_action.calculator + when Spree::Calculator::FlatRate + SolidusPromotions::Calculators::FlatRate.new(preferences: preferences) + when Spree::Calculator::PercentOnLineItem + SolidusPromotions::Calculators::Percent.new(preferences: preferences) + when Spree::Calculator::FlexiRate + SolidusPromotions::Calculators::FlexiRate.new(preferences: preferences) + when Spree::Calculator::DistributedAmount + SolidusPromotions::Calculators::DistributedAmount.new(preferences: preferences) + when Spree::Calculator::TieredFlatRate + SolidusPromotions::Calculators::TieredFlatRate.new(preferences: preferences) + when Spree::Calculator::TieredPercent + SolidusPromotions::Calculators::TieredPercent.new(preferences: preferences) + end + + SolidusPromotions::Benefits::AdjustLineItem.new( + calculator: calculator + ) + }, + Spree::Promotion::Actions::CreateQuantityAdjustments => ->(old_action) { + preferences = old_action.calculator.preferences + calculator = case old_action.calculator + when Spree::Calculator::FlatRate + SolidusPromotions::Calculators::FlatRate.new(preferences: preferences) + when Spree::Calculator::PercentOnLineItem + SolidusPromotions::Calculators::Percent.new(preferences: preferences) + when Spree::Calculator::FlexiRate + SolidusPromotions::Calculators::FlexiRate.new(preferences: preferences) + when Spree::Calculator::DistributedAmount + SolidusPromotions::Calculators::DistributedAmount.new(preferences: preferences) + when Spree::Calculator::TieredFlatRate + SolidusPromotions::Calculators::TieredFlatRate.new(preferences: preferences) + when Spree::Calculator::TieredPercent + SolidusPromotions::Calculators::TieredPercent.new(preferences: preferences) + end + + SolidusPromotions::Benefits::AdjustLineItemQuantityGroups.new( + preferred_group_size: old_action.preferred_group_size, + calculator: calculator + ) + }, + Spree::Promotion::Actions::FreeShipping => ->(_old_action) { + SolidusPromotions::Benefits::AdjustShipment.new( + calculator: SolidusPromotions::Calculators::Percent.new( + preferred_percent: 100 + ) + ) + } + } + } +end diff --git a/promotions/lib/solidus_promotions/promotion_migrator.rb b/promotions/lib/solidus_promotions/promotion_migrator.rb new file mode 100644 index 00000000000..2cc7ecdf6f9 --- /dev/null +++ b/promotions/lib/solidus_promotions/promotion_migrator.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module SolidusPromotions + class PromotionMigrator + PROMOTION_IGNORED_ATTRIBUTES = ["id", "type", "promotion_category_id", "promotion_id"] + + attr_reader :promotion_map + + def initialize(promotion_map) + @promotion_map = promotion_map + end + + def call + SolidusPromotions::PromotionCategory.destroy_all + Spree::PromotionCategory.all.find_each do |promotion_category| + SolidusPromotions::PromotionCategory.create!(promotion_category.attributes.except("id")) + end + + SolidusPromotions::Promotion.destroy_all + Spree::Promotion.all.find_each do |promotion| + new_promotion = copy_promotion(promotion) + if promotion.promotion_category&.name.present? + new_promotion.category = SolidusPromotions::PromotionCategory.find_by( + name: promotion.promotion_category.name + ) + end + new_promotion.benefits = promotion.actions.flat_map do |old_promotion_action| + generate_new_benefits(old_promotion_action)&.tap do |new_promotion_action| + new_promotion_action.original_promotion_action = old_promotion_action + new_promotion_action.conditions = promotion.rules.flat_map do |old_promotion_rule| + generate_new_promotion_conditions(old_promotion_rule) + end + end + end.compact + new_promotion.save! + copy_promotion_code_batches(new_promotion) + copy_promotion_codes(new_promotion) + end + end + + private + + def copy_promotion_code_batches(new_promotion) + sql = <<~SQL + INSERT INTO solidus_promotions_promotion_code_batches (promotion_id, base_code, number_of_codes, email, error, state, created_at, updated_at, join_characters) + SELECT solidus_promotions_promotions.id AS promotion_id, base_code, number_of_codes, email, error, state, spree_promotion_code_batches.created_at, spree_promotion_code_batches.updated_at, join_characters + FROM spree_promotion_code_batches + INNER JOIN spree_promotions ON spree_promotion_code_batches.promotion_id = spree_promotions.id + INNER JOIN solidus_promotions_promotions ON spree_promotions.id = solidus_promotions_promotions.original_promotion_id + WHERE spree_promotion_code_batches.promotion_id = #{new_promotion.original_promotion_id}; + SQL + SolidusPromotions::PromotionCodeBatch.connection.execute(sql) + end + + def copy_promotion_codes(new_promotion) + sql = <<~SQL + INSERT INTO solidus_promotions_promotion_codes (promotion_id, promotion_code_batch_id, value, created_at, updated_at) + SELECT solidus_promotions_promotions.id AS promotion_id, solidus_promotions_promotion_code_batches.id AS promotion_code_batch_id, value, spree_promotion_codes.created_at, spree_promotion_codes.updated_at + FROM spree_promotion_codes + LEFT OUTER JOIN spree_promotion_code_batches ON spree_promotion_code_batches.id = spree_promotion_codes.promotion_code_batch_id + LEFT OUTER JOIN solidus_promotions_promotion_code_batches ON solidus_promotions_promotion_code_batches.base_code = spree_promotion_code_batches.base_code + INNER JOIN spree_promotions ON spree_promotion_codes.promotion_id = spree_promotions.id + INNER JOIN solidus_promotions_promotions ON spree_promotions.id = solidus_promotions_promotions.original_promotion_id + WHERE spree_promotion_codes.promotion_id = #{new_promotion.original_promotion_id}; + SQL + SolidusPromotions::PromotionCode.connection.execute(sql) + end + + def copy_promotion(old_promotion) + SolidusPromotions::Promotion.new( + old_promotion.attributes.except(*PROMOTION_IGNORED_ATTRIBUTES).merge( + customer_label: old_promotion.name, + original_promotion: old_promotion + ) + ) + end + + def generate_new_benefits(old_promotion_action) + promo_action_config = promotion_map[:actions][old_promotion_action.class] + if promo_action_config.nil? + puts("#{old_promotion_action.class} is not supported") + return nil + end + promo_action_config.call(old_promotion_action) + end + + def generate_new_promotion_conditions(old_promotion_rule) + new_promo_condition_class = promotion_map[:conditions][old_promotion_rule.class] + if new_promo_condition_class.nil? + puts("#{old_promotion_rule.class} is not supported") + [] + elsif new_promo_condition_class.respond_to?(:call) + new_promo_condition_class.call(old_promotion_rule) + else + new_condition = new_promo_condition_class.new(old_promotion_rule.attributes.except(*PROMOTION_IGNORED_ATTRIBUTES)) + new_condition.preload_relations.each do |relation| + new_condition.send(:"#{relation}=", old_promotion_rule.send(relation)) + end + [new_condition] + end + end + end +end diff --git a/promotions/lib/solidus_promotions/testing_support/factories/completed_order_with_solidus_promotion_factory.rb b/promotions/lib/solidus_promotions/testing_support/factories/completed_order_with_solidus_promotion_factory.rb new file mode 100644 index 00000000000..9e76c126523 --- /dev/null +++ b/promotions/lib/solidus_promotions/testing_support/factories/completed_order_with_solidus_promotion_factory.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :completed_order_with_solidus_promotion, parent: :order_with_line_items do + transient do + completed_at { Time.current } + promotion { nil } + end + + after(:create) do |order, evaluator| + promotion = evaluator.promotion || create(:solidus_promotion, code: "test") + promotion_code = promotion.codes.first || create(:solidus_promotion_code, promotion: promotion) + + order.solidus_order_promotions.create!(promotion: promotion, promotion_code: promotion_code) + order.recalculate + order.update_column(:completed_at, evaluator.completed_at) + order.update_column(:state, "complete") + end + end +end diff --git a/promotions/lib/solidus_promotions/testing_support/factories/solidus_order_promotion_factory.rb b/promotions/lib/solidus_promotions/testing_support/factories/solidus_order_promotion_factory.rb new file mode 100644 index 00000000000..818ad59e52f --- /dev/null +++ b/promotions/lib/solidus_promotions/testing_support/factories/solidus_order_promotion_factory.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :solidus_order_promotion, class: "SolidusPromotions::OrderPromotion" do + association :order, factory: :order + association :promotion, factory: :solidus_promotion + end +end diff --git a/promotions/lib/solidus_promotions/testing_support/factories/solidus_promotion_category_factory.rb b/promotions/lib/solidus_promotions/testing_support/factories/solidus_promotion_category_factory.rb new file mode 100644 index 00000000000..41eff151777 --- /dev/null +++ b/promotions/lib/solidus_promotions/testing_support/factories/solidus_promotion_category_factory.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :solidus_promotion_category, class: "SolidusPromotions::PromotionCategory" do + name { "Promotion Category" } + end +end diff --git a/promotions/lib/solidus_promotions/testing_support/factories/solidus_promotion_code_factory.rb b/promotions/lib/solidus_promotions/testing_support/factories/solidus_promotion_code_factory.rb new file mode 100644 index 00000000000..da7278a791f --- /dev/null +++ b/promotions/lib/solidus_promotions/testing_support/factories/solidus_promotion_code_factory.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :solidus_promotion_code, class: "SolidusPromotions::PromotionCode" do + association :promotion, factory: :solidus_promotion + sequence(:value) { |i| "code#{i}" } + end +end diff --git a/promotions/lib/solidus_promotions/testing_support/factories/solidus_promotion_factory.rb b/promotions/lib/solidus_promotions/testing_support/factories/solidus_promotion_factory.rb new file mode 100644 index 00000000000..579fac0ff42 --- /dev/null +++ b/promotions/lib/solidus_promotions/testing_support/factories/solidus_promotion_factory.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :solidus_promotion, class: "SolidusPromotions::Promotion" do + name { "Promo" } + customer_label { "Because we like you" } + + transient do + code { nil } + end + before(:create) do |promotion, evaluator| + if evaluator.code + promotion.codes << build(:solidus_promotion_code, promotion: promotion, value: evaluator.code) + end + end + + trait :with_adjustable_benefit do + transient do + preferred_amount { 10 } + calculator_class { SolidusPromotions::Calculators::FlatRate } + promotion_benefit_class { SolidusPromotions::Benefits::AdjustLineItem } + conditions { [] } + end + + after(:create) do |promotion, evaluator| + calculator = evaluator.calculator_class.new + calculator.preferred_amount = evaluator.preferred_amount + evaluator.promotion_benefit_class.create!(calculator: calculator, promotion: promotion, conditions: evaluator.conditions) + end + end + + factory :solidus_promotion_with_benefit_adjustment, traits: [:with_adjustable_benefit] + + trait :with_line_item_adjustment do + transient do + adjustment_rate { 10 } + end + + with_adjustable_benefit + preferred_amount { adjustment_rate } + end + + factory :solidus_promotion_with_item_adjustment, traits: [:with_line_item_adjustment] + + trait :with_free_shipping do + after(:create) do |promotion| + calculator = SolidusPromotions::Calculators::Percent.new(preferred_percent: 100) + + SolidusPromotions::Benefits::AdjustShipment.create!(promotion: promotion, calculator: calculator) + end + end + + trait :with_order_adjustment do + transient do + weighted_order_adjustment_amount { 10 } + end + + with_adjustable_benefit + preferred_amount { weighted_order_adjustment_amount } + calculator_class { SolidusPromotions::Calculators::DistributedAmount } + end + + factory :solidus_promotion_with_order_adjustment, traits: [:with_order_adjustment] + end +end diff --git a/promotions/lib/solidus_promotions/testing_support/factories/solidus_shipping_rate_discount_factory.rb b/promotions/lib/solidus_promotions/testing_support/factories/solidus_shipping_rate_discount_factory.rb new file mode 100644 index 00000000000..34fcd5334d1 --- /dev/null +++ b/promotions/lib/solidus_promotions/testing_support/factories/solidus_shipping_rate_discount_factory.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :solidus_shipping_rate_discount, class: "SolidusPromotions::ShippingRateDiscount" do + amount { BigDecimal("-4.00") } + shipping_rate + benefit do + promotion = create(:solidus_promotion, name: "10% off shipping!", customer_label: "10% off") + ten_percent = SolidusPromotions::Calculators::Percent.new(preferred_percent: 10) + SolidusPromotions::Benefits::AdjustShipment.create!(promotion: promotion, calculator: ten_percent) + end + label { "10% off" } + end +end diff --git a/promotions/lib/solidus_promotions/testing_support/factory_bot.rb b/promotions/lib/solidus_promotions/testing_support/factory_bot.rb new file mode 100644 index 00000000000..d8bd99178ef --- /dev/null +++ b/promotions/lib/solidus_promotions/testing_support/factory_bot.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "factory_bot" +begin + require "factory_bot_rails" +rescue LoadError +end + +module SolidusPromotions + module TestingSupport + module FactoryBot + SEQUENCES = ["#{::SolidusPromotions::Engine.root}/lib/solidus_promotions/testing_support/sequences.rb"] + FACTORIES = Dir["#{::SolidusPromotions::Engine.root}/lib/solidus_promotions/testing_support/factories/**/*_factory.rb"].sort + PATHS = SEQUENCES + FACTORIES + + def self.definition_file_paths + @paths ||= PATHS.map { |path| path.sub(/.rb\z/, "") } + end + + def self.add_definitions! + ::FactoryBot.definition_file_paths.unshift(*definition_file_paths).uniq! + end + + def self.add_paths_and_load! + add_definitions! + ::FactoryBot.reload + end + end + end +end diff --git a/promotions/lib/tasks/solidus_promotions/migrate_adjustments.rake b/promotions/lib/tasks/solidus_promotions/migrate_adjustments.rake new file mode 100644 index 00000000000..47ab7b4b280 --- /dev/null +++ b/promotions/lib/tasks/solidus_promotions/migrate_adjustments.rake @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +namespace :solidus_promotions do + namespace :migrate_adjustments do + desc "Migrate adjustments with Spree::Benefit sources to SolidusPromotions::Benefit sources" + task up: :environment do + require "solidus_promotions/migrate_adjustments" + SolidusPromotions::MigrateAdjustments.up + end + + desc "Migrate adjustments with SolidusPromotions::Benefit sources to Spree::Benefit sources" + task down: :environment do + require "solidus_promotions/migrate_adjustments" + SolidusPromotions::MigrateAdjustments.down + end + end +end diff --git a/promotions/lib/tasks/solidus_promotions/migrate_existing_promotions.rake b/promotions/lib/tasks/solidus_promotions/migrate_existing_promotions.rake new file mode 100644 index 00000000000..812c9e73547 --- /dev/null +++ b/promotions/lib/tasks/solidus_promotions/migrate_existing_promotions.rake @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "solidus_promotions/promotion_migrator" + +namespace :solidus_promotions do + desc "Migrate Spree Promotions to Friendly Promotions using a map" + task migrate_existing_promotions: :environment do + require "solidus_promotions/promotion_map" + + SolidusPromotions::PromotionMigrator.new(SolidusPromotions::PROMOTION_MAP).call + end +end diff --git a/promotions/lib/tasks/solidus_promotions/migrate_order_promotions.rake b/promotions/lib/tasks/solidus_promotions/migrate_order_promotions.rake new file mode 100644 index 00000000000..8640d90fb12 --- /dev/null +++ b/promotions/lib/tasks/solidus_promotions/migrate_order_promotions.rake @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +namespace :solidus_promotions do + namespace :migrate_order_promotions do + desc "Migrate order promotions from Spree::OrderPromotion sources to SolidusPromotions::FriendlyOrderPromotion sources" + task up: :environment do + require "solidus_promotions/migrate_order_promotions" + SolidusPromotions::MigrateOrderPromotions.up + end + + desc "Migrate order promotions from SolidusPromotions::FriendlyOrderPromotion sources to Spree::OrderPromotion sources" + task down: :environment do + require "solidus_promotions/migrate_order_promotions" + SolidusPromotions::MigrateOrderPromotions.down + end + end +end diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_line_item.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_line_item.html.erb new file mode 100644 index 00000000000..924939b992c --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_line_item.html.erb @@ -0,0 +1,6 @@ +<%= render( + "solidus_promotions/admin/benefit_fields/calculator_fields", + benefit: benefit, + param_prefix: param_prefix, + form: form +) %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_line_item_quantity_groups.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_line_item_quantity_groups.html.erb new file mode 100644 index 00000000000..660f91dd905 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_line_item_quantity_groups.html.erb @@ -0,0 +1,13 @@ +<%= fields_for param_prefix, benefit do |form| %> +
+ <%= form.label :preferred_group_size %> + <%= form.number_field :preferred_group_size, class: "fullwidth", min: 1 %> +
+<% end %> + +<%= render( + "solidus_promotions/admin/benefit_fields/calculator_fields", + benefit: benefit, + param_prefix: param_prefix, + form: form +) %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_shipment.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_shipment.html.erb new file mode 100644 index 00000000000..924939b992c --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_shipment.html.erb @@ -0,0 +1,6 @@ +<%= render( + "solidus_promotions/admin/benefit_fields/calculator_fields", + benefit: benefit, + param_prefix: param_prefix, + form: form +) %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_calculator_fields.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_calculator_fields.erb new file mode 100644 index 00000000000..75a0f9a088d --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_calculator_fields.erb @@ -0,0 +1,11 @@ + +<% calculator = benefit.calculator %> +<% type_name = calculator.type.demodulize.underscore %> +

+ <%= calculator.description %> +

+<% if lookup_context.exists?("fields", ["solidus_promotions/admin/calculator_fields/#{type_name}"], true) %> + <%= render "solidus_promotions/admin/calculator_fields/#{type_name}/fields", calculator: calculator, prefix: param_prefix, form: form %> +<% else %> + <%= render "solidus_promotions/admin/calculator_fields/default_fields", calculator: calculator, prefix: param_prefix, form: form %> +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_create_discounted_item.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_create_discounted_item.html.erb new file mode 100644 index 00000000000..6b56fd69017 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefit_fields/_create_discounted_item.html.erb @@ -0,0 +1,21 @@ +<%= fields_for param_prefix, benefit do |form| %> +
+ <%= form.label :preferred_variant_id %> + <%= form.text_field :preferred_variant_id, is: "variant-picker", class: "fullwidth" %> +
+
+ <%= form.label :preferred_quantity %> + <%= form.number_field :preferred_quantity, class: "fullwidth" %> +
+
+ <%= form.label :preferred_necessary_quantity %> + <%= form.number_field :preferred_necessary_quantity, class: "fullwidth" %> +
+<% end %> + +<%= render( + "solidus_promotions/admin/benefit_fields/calculator_fields", + benefit: benefit, + param_prefix: param_prefix, + form: form +) %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefits/_benefit.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_benefit.html.erb new file mode 100644 index 00000000000..bcbd3fada5e --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_benefit.html.erb @@ -0,0 +1,50 @@ +
+
+ <%= benefit.model_name.human %> + + + <% if can?(:destroy, benefit) %> + <%= link_to_with_icon 'trash', '', solidus_promotions.admin_promotion_benefit_path(@promotion, benefit), method: :delete, class: 'delete' %> + <% end %> + +
+
+
+
+ <%= turbo_frame_tag @promotion, dom_id(benefit) do %> + + <%= render "solidus_promotions/admin/benefits/calculator_select", + path: solidus_promotions.edit_admin_promotion_benefit_path(@promotion, benefit), + benefit: benefit %> + + <%= + form_with( + model: benefit, + scope: :benefit, + url: solidus_promotions.admin_promotion_benefit_path(@promotion, benefit), + data: { turbo: false } + ) do |form| %> + <%= render 'solidus_promotions/admin/benefits/form', form: form %> +
+
+ <%= button_tag t(:update, scope: [:solidus_promotions, :crud]), class: "btn btn-secondary float-right" %> +
+
+ <% end %> + <% end %> +
+
+
+ <%= label_tag :conditions %> +
+ <%= render partial: 'solidus_promotions/admin/conditions/condition', collection: benefit.conditions %> + + <%= turbo_frame_tag benefit, "new_condition" do %> + <%= link_to t(:add_condition, scope: :solidus_promotions), solidus_promotions.new_admin_promotion_benefit_condition_path(@promotion, benefit), class: 'btn btn-secondary' %> + <% end %> +
+
+
+
+
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefits/_calculator_select.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_calculator_select.html.erb new file mode 100644 index 00000000000..9305d9460b3 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_calculator_select.html.erb @@ -0,0 +1,16 @@ +<%= form_with model: benefit, scope: :benefit, url: path, method: :get do |form| %> + <%= form.hidden_field :type %> +
+ <%= form.label :calculator_type %> + <%= + form.select :calculator_type, + options_for_benefit_calculator_types(form.object), + { + include_blank: t(:choose_benefit_calculator, scope: 'solidus_promotions') + }, + class: 'custom-select fullwidth', + onchange: 'this.form.requestSubmit()', + required: true + %> +
+<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefits/_form.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_form.html.erb new file mode 100644 index 00000000000..4ba12979351 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_form.html.erb @@ -0,0 +1,3 @@ +<%= form.hidden_field :type %> +<%= form.hidden_field :calculator_type %> +<%= render form.object, benefit: form.object, param_prefix: form.object_name, form: form %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefits/_new_benefit.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_new_benefit.html.erb new file mode 100644 index 00000000000..4a5bcc0aab8 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_new_benefit.html.erb @@ -0,0 +1,33 @@ +<%= turbo_frame_tag benefit.promotion, "new_benefit" do %> +
+
+ <%= t(:add_benefit, scope: :solidus_promotions) %> +
+
+
+
+ <%= render 'solidus_promotions/admin/benefits/type_select', benefit: benefit %> + <% if benefit.available_calculators.any? %> + <%= render 'solidus_promotions/admin/benefits/calculator_select', path: solidus_promotions.new_admin_promotion_benefit_path(@promotion), benefit: benefit %> + <% if benefit.calculator %> + <%= + form_with( + model: benefit, + scope: :benefit, + url: solidus_promotions.admin_promotion_benefits_path(@promotion), + data: { turbo: false } + ) do |form| %> + <%= render 'form', form: form %> +
+
+ <%= button_tag t(:add, scope: [:solidus_promotions, :crud]), class: "btn btn-secondary float-right" %> +
+
+ <% end %> + <% end %> + <% end %> +
+
+
+
+<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefits/_type_select.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_type_select.html.erb new file mode 100644 index 00000000000..b6c72c8caa3 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefits/_type_select.html.erb @@ -0,0 +1,16 @@ +<%= form_with model: benefit, scope: :benefit, url: solidus_promotions.new_admin_promotion_benefit_path(@promotion), method: :get do |form| %> +
+ <%= form.label :type %> + <%= admin_hint SolidusPromotions::Benefit.human_attribute_name(:type), t(:type, scope: [:solidus_promotions, :hints, "solidus_promotions/benefit"]) %> + <%= + form.select :type, + options_for_benefit_types(form.object), + { + include_blank: t(:choose_benefit, scope: 'solidus_promotions') + }, + class: 'custom-select w-100', + onchange: 'this.form.requestSubmit()', + required: true + %> +
+<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefits/edit.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefits/edit.html.erb new file mode 100644 index 00000000000..f0f5d60daa7 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefits/edit.html.erb @@ -0,0 +1,19 @@ +<%= turbo_frame_tag @promotion, dom_id(@benefit) do %> + <%= render 'calculator_select', path: solidus_promotions.edit_admin_promotion_benefit_path(@promotion, @benefit), benefit: @benefit %> + + <%= + form_with( + model: @benefit, + scope: :benefit, + url: solidus_promotions.admin_promotion_benefit_path(@promotion, @benefit), + ) do |form| %> + <%= render "form", form: form %> + +
+
+ <%= button_tag "Update", class: "btn btn-secondary float-right" %> +
+
+ + <% end %> +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/benefits/new.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/benefits/new.html.erb new file mode 100644 index 00000000000..d94f76796aa --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/benefits/new.html.erb @@ -0,0 +1 @@ +<%= render "new_benefit", benefit: @benefit %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/_default_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/_default_fields.html.erb new file mode 100644 index 00000000000..526f824b66b --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/_default_fields.html.erb @@ -0,0 +1,6 @@ +<% calculator.admin_form_preference_names.map do |name| %> + <%= render "solidus_promotions/admin/shared/preference_fields/#{calculator.preference_type(name)}", + name: "#{prefix}[calculator_attributes][preferred_#{name}]", + value: calculator.get_preference(name), + label: t(name.to_s, scope: 'spree', default: name.to_s.humanize) %> +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/distributed_amount/_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/distributed_amount/_fields.html.erb new file mode 100644 index 00000000000..ceaafc8ede8 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/distributed_amount/_fields.html.erb @@ -0,0 +1,18 @@ +
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_amount %> + <%= render "solidus_promotions/admin/shared/number_with_currency", f: f, amount_attr: :preferred_amount, currency_attr: :preferred_currency %> + <% end %> +
+ +
+

+ <%= admin_hint( + calculator.model_name.human, + calculator.class.human_attribute_name(:explanation) + ) %> + + This amount will be the total discount spread amongst all + of the line items. +

+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/flat_rate/_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/flat_rate/_fields.html.erb new file mode 100644 index 00000000000..1e70359cfae --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/flat_rate/_fields.html.erb @@ -0,0 +1,6 @@ +
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_amount %> + <%= render "solidus_promotions/admin/shared/number_with_currency", f: f, amount_attr: :preferred_amount, currency_attr: :preferred_currency %> + <% end %> +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/flexi_rate/_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/flexi_rate/_fields.html.erb new file mode 100644 index 00000000000..25e97df795f --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/flexi_rate/_fields.html.erb @@ -0,0 +1,24 @@ +
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_first_item %> + <%= render "solidus_promotions/admin/shared/number_with_currency", f: f, amount_attr: :preferred_first_item, currency: f.object.preferred_currency %> + <% end %> +
+
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_additional_item %> + <%= render "solidus_promotions/admin/shared/number_with_currency", f: f, amount_attr: :preferred_additional_item, currency: f.object.preferred_currency %> + <% end %> +
+
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_max_items %> + <%= f.number_field :preferred_max_items, step: 1, min: 1, default: 1, class: "form-control" %> + <% end %> +
+
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_currency %> + <%= f.select :preferred_currency, Spree::Config.available_currencies.map(&:iso_code), {selected: f.object.preferred_currency }, { is: "select-two", class: "w-100"} %> + <% end %> +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/percent/_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/percent/_fields.html.erb new file mode 100644 index 00000000000..e51e2fcb8c0 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/percent/_fields.html.erb @@ -0,0 +1,6 @@ +
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_percent %> + <%= f.number_field :preferred_percent, step: 1, min: 1, max: 100, default: 1, class: "form-control" %> + <% end %> +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_flat_rate/_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_flat_rate/_fields.html.erb new file mode 100644 index 00000000000..59adf8d5d6c --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_flat_rate/_fields.html.erb @@ -0,0 +1,32 @@ +
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_base_amount %> + <%= render "solidus_promotions/admin/shared/number_with_currency", f: f, amount_attr: :preferred_base_amount, currency_attr: :preferred_currency %> + <% end %> +
+ +
+ <%= label_tag nil, calculator.class.human_attribute_name(:tiers) %> +
+
+
+ <%= SolidusPromotions::Calculators::TieredFlatRate.human_attribute_name(:order_item_total) %> +
+
+ <%= SolidusPromotions::Calculators::TieredFlatRate.human_attribute_name(:discount) %> +
+
+ +
+
+ + <% form.object.calculator.preferred_tiers.each do |tier| %> + <%= render "solidus_promotions/admin/calculator_fields/tiered_flat_rate/tier_fields", tier: tier, form: form, currency: calculator.preferred_currency %> + <% end %> +
+ <%= link_to t(:add_tier, scope: [:solidus_promotions, :admin, :promotions, :calculator]), "#", class: "btn btn-outline-primary", data: { action: "click->calculator-tiers#add_association" } %> +
+
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_flat_rate/_tier_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_flat_rate/_tier_fields.html.erb new file mode 100644 index 00000000000..e6a65c0cd88 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_flat_rate/_tier_fields.html.erb @@ -0,0 +1,31 @@ +
+
+
+
+
+ <%= ::Money::Currency.find(currency).symbol %> +
+ +
+
+
+
+
+ <%= ::Money::Currency.find(currency).symbol %> +
+ +
+
+ +
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent/_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent/_fields.html.erb new file mode 100644 index 00000000000..e762eb8c6df --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent/_fields.html.erb @@ -0,0 +1,47 @@ +
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_base_percent %> + <%= f.number_field :preferred_base_percent, step: 1, min: 0, max: 100, default: 1, class: "form-control" %> + <% end %> +
+ +
+ <%= label_tag( + "#{prefix}[calculator_attributes][preferred_currency]", + t('spree.currency') + ) %> + <%= select_tag( + "#{prefix}[calculator_attributes][preferred_currency]", + options_for_select( + Spree::Config.available_currencies, + calculator.preferred_currency || Spree::Config[:currency] + ), + { is: "select-two", class: 'fullwidth' } + ) %> +
+ +
+ <%= label_tag nil, calculator.class.human_attribute_name(:tiers) %> +
+
+
+ <%= SolidusPromotions::Calculators::TieredPercent.human_attribute_name(:order_item_total) %> +
+
+ <%= SolidusPromotions::Calculators::TieredPercent.human_attribute_name(:percent) %> +
+
+ +
+
+ + <% form.object.calculator.preferred_tiers.each do |tier| %> + <%= render "solidus_promotions/admin/calculator_fields/tiered_percent/tier_fields", tier: tier, form: form %> + <% end %> +
+ <%= link_to t(:add_tier, scope: [:solidus_promotions, :admin, :promotions, :calculator]), "#", class: "btn btn-outline-primary", data: { action: "click->calculator-tiers#add_association" } %> +
+
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent/_tier_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent/_tier_fields.html.erb new file mode 100644 index 00000000000..859fcb3ec85 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent/_tier_fields.html.erb @@ -0,0 +1,33 @@ +
+
+
+
+
+ $ +
+ +
+
+
+
+ +
+ % +
+
+
+ +
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent_on_eligible_item_quantity/_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent_on_eligible_item_quantity/_fields.html.erb new file mode 100644 index 00000000000..fa071573dfe --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent_on_eligible_item_quantity/_fields.html.erb @@ -0,0 +1,36 @@ +
+ <%= fields_for "#{prefix}[calculator_attributes]", calculator do |f| %> + <%= f.label :preferred_base_percent %> + <%= f.number_field :preferred_base_percent, step: 1, min: 0, max: 100, default: 1, class: "form-control" %> + <% end %> +
+ +
+ <%= label_tag( + "#{prefix}[calculator_attributes][preferred_currency]", + t('spree.currency') + ) %> + <%= select_tag( + "#{prefix}[calculator_attributes][preferred_currency]", + options_for_select( + Spree::Config.available_currencies, + calculator.preferred_currency || Spree::Config[:currency] + ), + { is: "select-two", class: 'fullwidth' } + ) %> +
+ +
+ <%= label_tag nil, calculator.class.human_attribute_name(:tiers) %> +
+ + <% form.object.calculator.preferred_tiers.each do |tier| %> + <%= render "solidus_promotions/admin/calculator_fields/tiered_percent_on_eligible_item_quantity/tier_fields", tier: tier, form: form %> + <% end %> +
+ <%= link_to t(:add_tier, scope: [:solidus_promotions, :admin, :promotions, :calculator]), "#", class: "btn btn-outline-primary", data: { action: "click->calculator-tiers#add_association" } %> +
+
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent_on_eligible_item_quantity/_tier_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent_on_eligible_item_quantity/_tier_fields.html.erb new file mode 100644 index 00000000000..9d5520daff0 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent_on_eligible_item_quantity/_tier_fields.html.erb @@ -0,0 +1,35 @@ +
+
+
+
+
+ ∑ +
+ +
+
+
+
+ +
+ % +
+
+
+ +
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_first_order.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_first_order.html.erb new file mode 100644 index 00000000000..0d3d9bdccd7 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_first_order.html.erb @@ -0,0 +1,3 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_first_repeat_purchase_since.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_first_repeat_purchase_since.html.erb new file mode 100644 index 00000000000..0cde958d458 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_first_repeat_purchase_since.html.erb @@ -0,0 +1,7 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+ <%= label_tag "#{param_prefix}[preferred_days_ago]", condition.class.human_attribute_name(:preferred_days_ago) %> + <%= number_field_tag "#{param_prefix}[preferred_days_ago]", condition.preferred_days_ago, class: 'fullwidth' %> +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_item_total.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_item_total.html.erb new file mode 100644 index 00000000000..e61b78d1934 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_item_total.html.erb @@ -0,0 +1,15 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+ +
+ <%= label_tag "#{param_prefix}[preferred_operator]", condition.class.human_attribute_name(:preferred_operator) %> + <%= select_tag "#{param_prefix}[preferred_operator]", options_for_select(condition.class.operator_options, condition.preferred_operator), {class: 'custom-select select_item_total fullwidth'} %> +
+ +
+ <%= fields_for param_prefix, condition do |f| %> + <%= f.label :preferred_amount %> + <%= render "solidus_promotions/admin/shared/number_with_currency", f: f, amount_attr: :preferred_amount, currency_attr: :preferred_currency %> + <% end %> +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_option_value.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_option_value.html.erb new file mode 100644 index 00000000000..59d8818c1b4 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_option_value.html.erb @@ -0,0 +1,25 @@ + +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+ +
+
<%= label_tag nil, Spree::Product.model_name.human %>
+
<%= label_tag nil, plural_resource_name(Spree::OptionValue) %>
+
+ +
+
+ + <% form.object.preferred_eligible_values.each do |product_option_values| %> + <%= render "solidus_promotions/admin/condition_fields/line_item_option_value/option_value_fields", product_option_values: product_option_values, form: form %> + <% end %> +
+ <%= link_to t(:add_product, scope: [:solidus_promotions, :line_item_option_value_condition]), "#", class: "btn btn-outline-primary", data: { action: "click->product-option-values#add_row" } %> +
+
+
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_product.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_product.html.erb new file mode 100644 index 00000000000..c62a0bb20ca --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_product.html.erb @@ -0,0 +1,21 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+
+
+ <%= label_tag "#{param_prefix}_product_ids_string", t('solidus_promotions.product_condition.choose_products') %> + <%= hidden_field_tag "#{param_prefix}[product_ids_string]", condition.product_ids.join(","), class: "fullwidth", is: "product-picker" %> +
+
+
+
+ <%= label_tag("#{param_prefix}_preferred_match_policy", condition.class.human_attribute_name(:preferred_match_policy)) %> + <%= select_tag( + "#{param_prefix}[preferred_match_policy]", + options_for_select(I18n.t("solidus_promotions.conditions.line_item_product.match_policies").to_a.map(&:reverse), condition.preferred_match_policy), + class: "custom-select fullwidth" + ) %> +
+
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_taxon.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_taxon.html.erb new file mode 100644 index 00000000000..f8fb37adced --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_taxon.html.erb @@ -0,0 +1,17 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+ <%= label_tag "#{param_prefix}_taxon_ids_string", t("solidus_promotions.taxon_condition.choose_taxons") %> + <%= hidden_field_tag "#{param_prefix}[taxon_ids_string]", condition.taxon_ids.join(","), is: "taxon-picker", class: "fullwidth", id: "product_taxon_ids" %> +
+
+
+ <%= label_tag("#{param_prefix}_preferred_match_policy", condition.class.human_attribute_name(:preferred_match_policy)) %> + <%= select_tag( + "#{param_prefix}[preferred_match_policy]", + options_for_select(I18n.t("solidus_promotions.conditions.line_item_taxon.match_policies").to_a.map(&:reverse), condition.preferred_match_policy), + class: "custom-select fullwidth", + ) %> +
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_minimum_quantity.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_minimum_quantity.html.erb new file mode 100644 index 00000000000..a81a8763b3d --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_minimum_quantity.html.erb @@ -0,0 +1,5 @@ +
+ <% field_name = "#{param_prefix}[preferred_minimum_quantity]" %> + <%= label_tag field_name, condition.model_name.human %> + <%= number_field_tag field_name, condition.preferred_minimum_quantity, class: "fullwidth", min: 1 %> +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_nth_order.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_nth_order.html.erb new file mode 100644 index 00000000000..0f79be580b6 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_nth_order.html.erb @@ -0,0 +1,15 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+
+
+ <%= SolidusPromotions::Conditions::NthOrder.human_attribute_name(:form_text) %> +
+
+
+
+ <%= number_field_tag "#{param_prefix}[preferred_nth_order]", condition.preferred_nth_order, class: 'fullwidth' %> +
+
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_one_use_per_user.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_one_use_per_user.html.erb new file mode 100644 index 00000000000..0d3d9bdccd7 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_one_use_per_user.html.erb @@ -0,0 +1,3 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_option_value.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_option_value.html.erb new file mode 100644 index 00000000000..7f6581bef82 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_option_value.html.erb @@ -0,0 +1,63 @@ +<% context = local_assigns[:context] || :order %> + +<% if context == :order %> +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+ +
+
<%= label_tag nil, Spree::Product.model_name.human %>
+
<%= label_tag nil, plural_resource_name(Spree::OptionValue) %>
+
+ +
+
+ + <% form.object.preferred_eligible_values.each do |product_option_values| %> + <%= render "solidus_promotions/admin/condition_fields/line_item_option_value/option_value_fields", product_option_values: product_option_values, form: form %> + <% end %> +
+ <%= link_to t(:add_product, scope: [:solidus_promotions, :option_value_condition]), "#", class: "btn btn-outline-primary", data: { action: "click->product-option-values#add_row" } %> +
+
+
+ +
+ <%= form.label :preferred_line_item_applicable do %> + <%= form.check_box :preferred_line_item_applicable %> + <%= condition.class.human_attribute_name(:preferred_line_item_applicable) %> + <% end %> +
+
+<% else %> +

+ <%= SolidusPromotions::Conditions::LineItemOptionValue.human_attribute_name(:description) %> +

+ + + + + + + + + <% condition.preferred_eligible_values.each do |product_id, option_value_ids| %> + + + + + <% end %> + +
+ <%= Spree::Product.model_name.human %> + + <%= Spree::OptionValue.model_name.human(count: :other) %> +
+ <%= Spree::Product.find(product_id).name %> + + <%= Spree::OptionValue.where(id: option_value_ids).map(&:name).join(", ") %> +
+<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_product.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_product.html.erb new file mode 100644 index 00000000000..94b0970b39d --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_product.html.erb @@ -0,0 +1,27 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+ <%= fields_for param_prefix, condition do |form| %> +
+ <%= form.label :product_ids_string, t('solidus_promotions.product_condition.choose_products') %> + <%= form.hidden_field :product_ids_string, value: condition.product_ids.join(","), class: "fullwidth", is: "product-picker" %> +
+ +
+ <% + match_policy_options = options_for_select( + SolidusPromotions::Conditions::Product::MATCH_POLICIES.map { |s| [t("solidus_promotions.product_condition.match_#{s}"),s] }, + condition.preferred_match_policy + ) + %> + <% select = form.select :preferred_match_policy, match_policy_options %> + <%= form.label :preferred_match_policy, t('solidus_promotions.product_condition.label', select: select).html_safe %> +
+ +
+ <%= form.label :preferred_line_item_applicable do %> + <%= form.check_box :preferred_line_item_applicable %> + <%= condition.class.human_attribute_name(:preferred_line_item_applicable) %> + <% end %> +
+ <% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_shipping_method.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_shipping_method.html.erb new file mode 100644 index 00000000000..26e4ba93d66 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_shipping_method.html.erb @@ -0,0 +1,10 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+ <%= label_tag "#{param_prefix}_preferred_shipping_method_ids", SolidusPromotions::Conditions::ShippingMethod.human_attribute_name(:preferred_shipping_method_ids) %> + <%= select_tag "#{param_prefix}[preferred_shipping_method_ids]", + options_from_collection_for_select( + Spree::ShippingMethod.all, :id, :name, condition.preferred_shipping_method_ids + ), class: 'select2 fullwidth', multiple: true %> +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_store.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_store.html.erb new file mode 100644 index 00000000000..e4890ce40cc --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_store.html.erb @@ -0,0 +1,9 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+ <%= label_tag "#{param_prefix}[store_ids][]", Spree::Store.model_name.human(count: :other) %> + <%= select_tag "#{param_prefix}[store_ids][]", + options_from_collection_for_select(Spree::Store.all, :id, :name, condition.store_ids), + multiple: true, is: "select-two", class: "fullwidth" %> +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon.html.erb new file mode 100644 index 00000000000..f768606f85f --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon.html.erb @@ -0,0 +1,27 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+<%= fields_for param_prefix, condition do |form| %> +
+ <%= form.label :taxon_ids_string, t('solidus_promotions.taxon_condition.choose_taxons') %> + <%= form.hidden_field :taxon_ids_string, value: condition.taxon_ids.join(","), is: "taxon-picker", class: "fullwidth" %> +
+ +
+ <% + match_policy_options = options_for_select( + SolidusPromotions::Conditions::Taxon::MATCH_POLICIES.map { |s| [t("solidus_promotions.taxon_condition.match_#{s}"),s] }, + condition.preferred_match_policy + ) + %> + <% select = form.select :preferred_match_policy, match_policy_options %> + <%= form.label :preferred_match_policy, t('solidus_promotions.taxon_condition.label', select: select).html_safe %> +
+ +
+ <%= form.label :preferred_line_item_applicable do %> + <%= form.check_box :preferred_line_item_applicable %> + <%= condition.class.human_attribute_name(:preferred_line_item_applicable) %> + <% end %> +
+<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_user.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_user.html.erb new file mode 100644 index 00000000000..8aeb22c8b19 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_user.html.erb @@ -0,0 +1,7 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+
+ +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_user_logged_in.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_user_logged_in.html.erb new file mode 100644 index 00000000000..0d3d9bdccd7 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_user_logged_in.html.erb @@ -0,0 +1,3 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_user_role.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_user_role.html.erb new file mode 100644 index 00000000000..a087ef4f8b9 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/_user_role.html.erb @@ -0,0 +1,15 @@ +

+ <%= condition.class.human_attribute_name(:description) %> +

+
+ <%= label_tag "#{param_prefix}_preferred_role_ids", t('solidus_promotions.user_role_condition.choose_roles') %> + <%= select_tag "#{param_prefix}[preferred_role_ids]", + options_from_collection_for_select( + Spree::Role.all, :id, :name, condition.preferred_role_ids + ), class: 'fullwidth', is: 'select-two', multiple: true %> +
+
+ +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/line_item_option_value/_option_value_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/line_item_option_value/_option_value_fields.html.erb new file mode 100644 index 00000000000..b8081a854fa --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/condition_fields/line_item_option_value/_option_value_fields.html.erb @@ -0,0 +1,21 @@ +
+
+ +
+
+ " + > +
+ +
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/conditions/_condition.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/conditions/_condition.html.erb new file mode 100644 index 00000000000..03273b0a93d --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/conditions/_condition.html.erb @@ -0,0 +1,26 @@ +
+
+ <%= condition.class.model_name.human %> + + <% if can?(:destroy, condition) %> + <%= link_to_with_icon 'trash', '', solidus_promotions.admin_promotion_benefit_condition_path(@promotion, condition.benefit, condition), method: :delete, class: 'delete' %> + <% end %> + +
+
+ <%= form_with model: condition, scope: :condition, url: solidus_promotions.admin_promotion_benefit_condition_path(@promotion, condition.benefit, condition), method: :patch do |form| %> + <% param_prefix = "promotion[conditions_attributes][#{condition.id}]" %> + <%= hidden_field_tag "#{param_prefix}[id]", condition.id %> + <%= render partial: "spree/shared/error_messages", locals: { target: condition } %> + <%= render condition, condition: condition, param_prefix: "condition", form: form %> + + <% if condition.updateable? %> +
+
+ <%= form.submit(t(:update, scope: [:solidus_promotions, :crud]), class: "btn btn-secondary float-right") %> +
+
+ <% end %> + <% end %> +
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/conditions/_new_condition.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/conditions/_new_condition.html.erb new file mode 100644 index 00000000000..1505eb2a254 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/conditions/_new_condition.html.erb @@ -0,0 +1,26 @@ +<%= turbo_frame_tag condition.benefit, "new_condition" do %> +
+
+ <%= t(:add_condition, scope: :solidus_promotions) %> +
+
+ <%= render 'solidus_promotions/admin/conditions/type_select', benefit: condition.benefit %> + <% flash.each do |severity, message| %> + <%= content_tag(:div, "", data: { controller: :flash, severity: severity, message: message }) %> + <% end %> + <% if condition.valid? %> + <%= form_with model: condition, scope: "condition", url: solidus_promotions.admin_promotion_benefit_conditions_path(@promotion, condition.benefit), data: { turbo: false } do |form| %> + <%= hidden_field_tag "condition[type]", condition.class.name %> + <%= render condition, condition: condition, param_prefix: "condition", form: form %> + +
+
+ <%= form.submit(t(:add, scope: [:solidus_promotions, :crud]), class: "btn btn-secondary float-right") %> +
+
+ + <% end %> + <% end %> +
+
+<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/conditions/_type_select.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/conditions/_type_select.html.erb new file mode 100644 index 00000000000..cfc7a5807ad --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/conditions/_type_select.html.erb @@ -0,0 +1,20 @@ +<%= form_with( + model: @condition || benefit.conditions.build, + scope: :condition, + url: solidus_promotions.new_admin_promotion_benefit_condition_path(@promotion, benefit), + method: :get, + class: "mb-3" + ) do |form| %> + <%= form.label :type %> + <%= admin_hint SolidusPromotions::Condition.human_attribute_name(:type), t(:conditions, scope: [:solidus_promotions, :hints, "solidus_promotions/benefit"]) %> + <%= + form.select :type, + options_for_condition_types(benefit, @condition), + { + include_blank: t(:choose_condition, scope: 'solidus_promotions') + }, + class: 'custom-select fullwidth', + onchange: 'this.form.requestSubmit()', + required: true + %> +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/conditions/new.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/conditions/new.html.erb new file mode 100644 index 00000000000..250ca85edd3 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/conditions/new.html.erb @@ -0,0 +1 @@ +<%= render "new_condition", condition: @condition %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/_form.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/_form.html.erb new file mode 100644 index 00000000000..ca965cc93d0 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/_form.html.erb @@ -0,0 +1,14 @@ +<%= render partial: 'spree/shared/error_messages', locals: { target: @promotion_category } %> + +
+
+ <%= f.field_container :name do %> + <%= f.label :name %> + <%= f.text_field :name, class: 'fullwidth' %> + <% end %> + <%= f.field_container :code do %> + <%= f.label :code %> + <%= f.text_field :code, class: 'fullwidth' %> + <% end %> +
+
diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/edit.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/edit.html.erb new file mode 100644 index 00000000000..eafb44bbc6f --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/edit.html.erb @@ -0,0 +1,10 @@ +<% admin_breadcrumb(link_to plural_resource_name(SolidusPromotions::Promotion), solidus_promotions.admin_promotions_path) %> +<% admin_breadcrumb(link_to plural_resource_name(SolidusPromotions::PromotionCategory), solidus_promotions.admin_promotion_categories_path) %> +<% admin_breadcrumb(@promotion_category.name) %> + +<%= form_for @promotion_category, url: object_url, method: :put do |f| %> +
+ <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> +
+<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/index.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/index.html.erb new file mode 100644 index 00000000000..176862b74f9 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/index.html.erb @@ -0,0 +1,47 @@ +<% admin_breadcrumb(link_to plural_resource_name(SolidusPromotions::Promotion), solidus_promotions.admin_promotions_path) %> +<% admin_breadcrumb(plural_resource_name(SolidusPromotions::PromotionCategory)) %> + +<% content_for :page_actions do %> + <% if can?(:create, SolidusPromotions::PromotionCategory) %> +
  • + <%= link_to t('solidus_promotions.new_promotion_category'), solidus_promotions.new_admin_promotion_category_path, class: 'btn btn-primary' %> +
  • + <% end %> +<% end %> + +<% if @promotion_categories.any? %> + + + + + + + + + + + + + <% @promotion_categories.each do |category| %> + + + + + + <% end %> + +
    <%= SolidusPromotions::PromotionCategory.human_attribute_name :name %><%= SolidusPromotions::PromotionCategory.human_attribute_name :code %>
    <%= category.name %><%= category.code %> + <% if can?(:update, category) %> + <%= link_to_edit category, no_text: true %> + <% end %> + <% if can?(:destroy, category) %> + <%= link_to_delete category, no_text: true %> + <% end %> +
    +<% else %> +
    + <%= render 'spree/admin/shared/no_objects_found', + resource: SolidusPromotions::PromotionCategory, + new_resource_url: new_object_url %> +
    +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/new.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/new.html.erb new file mode 100644 index 00000000000..b7388072151 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_categories/new.html.erb @@ -0,0 +1,10 @@ +<% admin_breadcrumb(link_to plural_resource_name(SolidusPromotions::Promotion), solidus_promotions.admin_promotions_path) %> +<% admin_breadcrumb(link_to plural_resource_name(SolidusPromotions::PromotionCategory), solidus_promotions.admin_promotion_categories_path) %> +<% admin_breadcrumb(t('solidus_promotions.new_promotion_category')) %> + +<%= form_for :promotion_category, url: collection_url do |f| %> +
    + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +
    +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/_form_fields.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/_form_fields.html.erb new file mode 100644 index 00000000000..7010098e2c1 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/_form_fields.html.erb @@ -0,0 +1,22 @@ +
    + <%= batch.label :base_code, class: "required" %> + <%= batch.text_field :base_code, class: "fullwidth", required: true %> +
    +
    + <%= batch.label :number_of_codes, class: "required" %> + <%= batch.number_field :number_of_codes, class: "fullwidth", min: 1, required: true %> +
    +
    + <%= batch.label :join_characters %> + <%= batch.text_field :join_characters, class: "fullwidth" %> +
    +<% unless promotion_id %> +
    + <%= f.label :per_code_usage_limit %> + <%= f.text_field :per_code_usage_limit, class: "fullwidth" %> +
    +<% end %> +
    + <%= batch.label :email %> + <%= batch.text_field :email, class: "fullwidth" %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/download.csv.ruby b/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/download.csv.ruby new file mode 100644 index 00000000000..7b3e70ef68e --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/download.csv.ruby @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +CSV.generate do |csv| + csv << ["Code"] + @promotion_code_batch.promotion_codes.order(:id).pluck(:value).each do |value| + csv << [value] + end +end diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/index.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/index.html.erb new file mode 100644 index 00000000000..32f34022e08 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/index.html.erb @@ -0,0 +1,65 @@ +<% admin_breadcrumb(link_to plural_resource_name(SolidusPromotions::Promotion), solidus_promotions.admin_promotions_path) %> +<% admin_breadcrumb(link_to @promotion.name, solidus_promotions.edit_admin_promotion_path(@promotion.id)) %> +<% admin_breadcrumb(plural_resource_name(SolidusPromotions::PromotionCodeBatch)) %> + +<% content_for :page_actions do %> +
  • + <% if can?(:create, SolidusPromotions::PromotionCodeBatch) %> + <%= link_to t('solidus_promotions.new_promotion_code_batch'), new_object_url, class: 'btn btn-primary' %> + <% end %> +
  • +<% end %> + +<% if @promotion_code_batches.any? %> + + + + + + + + + + + <% @promotion_code_batches.each do |promotion_code_batch| %> + + + + + + + <% end %> + +
    <%= SolidusPromotions::PromotionCodeBatch.human_attribute_name(:base_code) %><%= SolidusPromotions::PromotionCodeBatch.human_attribute_name(:total_codes) %><%= SolidusPromotions::PromotionCodeBatch.human_attribute_name(:status) %><%= SolidusPromotions::PromotionCodeBatch.human_attribute_name(:email) %>
    <%= promotion_code_batch.base_code %><%= promotion_code_batch.number_of_codes %> + <% if promotion_code_batch.error.present? %> + <%= t( + "solidus_promotions.promotion_code_batches.errored", + error: promotion_code_batch.error + ) %> + <% elsif promotion_code_batch.finished? %> + <%= t( + "solidus_promotions.promotion_code_batches.finished", + number_of_codes: promotion_code_batch.number_of_codes + ) %> + <%= link_to( + t('solidus_promotions.download_promotion_codes_list'), + admin_promotion_promotion_code_batch_download_path( + promotion_code_batch_id: promotion_code_batch.id, + format: :csv + ) + ) %> + <% else %> + <%= t( + "solidus_promotions.promotion_code_batches.processing", + number_of_codes: promotion_code_batch.number_of_codes, + number_of_codes_processed: promotion_code_batch.promotion_codes.count + ) %> + <% end %> + <%= promotion_code_batch.email %>
    +<% else %> +
    + <%= render 'spree/admin/shared/no_objects_found', + resource: SolidusPromotions::PromotionCodeBatch, + new_resource_url: new_object_url %> +
    +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/new.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/new.html.erb new file mode 100644 index 00000000000..6f2aa307018 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_code_batches/new.html.erb @@ -0,0 +1,8 @@ +<% admin_breadcrumb(link_to plural_resource_name(SolidusPromotions::Promotion), solidus_promotions.admin_promotions_path) %> +<% admin_breadcrumb(link_to @promotion.name, solidus_promotions.admin_promotion_path(@promotion.id)) %> +<% admin_breadcrumb(plural_resource_name(SolidusPromotions::PromotionCodeBatch)) %> +<%= form_for :promotion_code_batch, url: collection_url do |batch| %> + <%= batch.hidden_field :promotion_id, value: params[:promotion_id] %> + <%= render partial: 'form_fields', locals: {batch: batch, promotion_id: params[:promotion_id]} %> + <%= batch.submit t('spree.actions.create'), class: 'btn btn-primary' %> +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_codes/index.csv.ruby b/promotions/lib/views/backend/solidus_promotions/admin/promotion_codes/index.csv.ruby new file mode 100644 index 00000000000..288b953b196 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_codes/index.csv.ruby @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +CSV.generate do |csv| + csv << ["Code"] + @promotion_codes.order(:id).pluck(:value).each do |value| + csv << [value] + end +end diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_codes/index.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotion_codes/index.html.erb new file mode 100644 index 00000000000..803cd2a5570 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_codes/index.html.erb @@ -0,0 +1,32 @@ +<% admin_breadcrumb link_to plural_resource_name(SolidusPromotions::Promotion), solidus_promotions.admin_promotions_path %> +<% admin_breadcrumb link_to(@promotion.name, solidus_promotions.edit_admin_promotion_path(@promotion)) %> +<% admin_breadcrumb plural_resource_name(SolidusPromotions::PromotionCode) %> + +<% content_for :page_actions do %> +
  • + <% if can?(:create, SolidusPromotions::PromotionCode) && !@promotion.apply_automatically? %> + <%= link_to t('solidus_promotions.create_promotion_code'), solidus_promotions.new_admin_promotion_promotion_code_path(promotion_id: @promotion.id), class: 'btn btn-primary' %> + <% end %> + + <%= link_to t('solidus_promotions.download_promotion_codes_list'), solidus_promotions.admin_promotion_promotion_codes_path(promotion_id: @promotion.id, format: :csv), class: 'btn btn-primary' %> +
  • +<% end %> + +
    + <%= page_entries_info(@promotion_codes) %> +
    + + + + + + + <% @promotion_codes.each do |promotion_code| %> + + + + <% end %> + +
    <%= SolidusPromotions::PromotionCode.human_attribute_name :value %>
    <%= promotion_code.value %>
    + +<%= paginate @promotion_codes, theme: "solidus_admin" %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotion_codes/new.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotion_codes/new.html.erb new file mode 100644 index 00000000000..17e216de760 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotion_codes/new.html.erb @@ -0,0 +1,31 @@ +<% admin_breadcrumb link_to plural_resource_name(SolidusPromotions::Promotion), solidus_promotions.admin_promotions_path %> +<% admin_breadcrumb link_to(@promotion.name, solidus_promotions.edit_admin_promotion_path(@promotion)) %> +<% admin_breadcrumb plural_resource_name(SolidusPromotions::PromotionCode) %> + +<% content_for :page_actions do %> +
  • + <%= link_to t('solidus_promotions.view_promotion_codes_list'), admin_promotion_promotion_codes_path(promotion_id: @promotion.id), class: 'btn btn-primary' %> + + <%= link_to t('solidus_promotions.download_promotion_codes_list'), admin_promotion_promotion_codes_path(promotion_id: @promotion.id, format: :csv), class: 'btn btn-primary' %> +
  • +<% end %> + +<%= form_for [:admin, @promotion, @promotion_code], method: :post do |f| %> +
    + <%= render partial: 'spree/shared/error_messages', locals: { target: @promotion_code } %> + +
    +
    + <%= f.field_container :value do %> + <%= f.label :value, class: 'required' %> + <%= f.text_field :value, class: 'fullwidth', required: true %> + <% end %> +
    +
    + +
    + <%= f.submit t('spree.actions.create'), class: 'btn btn-primary' %> + <%= link_to t('spree.actions.cancel'), solidus_promotions.admin_promotion_promotion_codes_url(@promotion), class: 'button' %> +
    +
    +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotions/_form.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotions/_form.html.erb new file mode 100644 index 00000000000..a752bd8ede6 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotions/_form.html.erb @@ -0,0 +1,124 @@ +<%= render partial: 'spree/shared/error_messages', locals: { target: @promotion } %> +
    +
    +
    +
    +
    + <%= f.field_container :name do %> + <%= f.label :name, class: 'required' %> + <%= f.text_field :name, class: 'fullwidth', required: true %> + <% end %> + + <%= f.field_container :customer_label do %> + <%= f.label :customer_label, class: 'required' %> + <%= f.text_field :customer_label, class: 'fullwidth', required: true %> + <% end %> + + <%= f.field_container :description do %> + <%= f.label :description %>
    + <%= f.text_area :description, rows: 7, class: 'fullwidth' %> + + <%= t('spree.character_limit') %> + + <% end %> + + <%= f.field_container :category do %> + <%= f.label :promotion_category_id, SolidusPromotions::PromotionCategory.model_name.human %>
    + <%= + f.collection_select(:promotion_category_id, @promotion_categories, :id, :name, { include_blank: t('solidus_promotions.match_choices.none') }, + { class: 'custom-select fullwidth' }) + %> + <% end %> +
    +
    +
    + +
    + <%= f.field_container :overall_usage_limit do %> + <%= f.label :usage_limit %>
    + <%= f.number_field :usage_limit, min: 0, class: 'fullwidth' %>
    + + <%= t('solidus_promotions.current_promotion_usage', count: @promotion.usage_count) %> + + <% end %> + + <% if @promotion.persisted? %> + <%= f.field_container :per_code_usage_limit do %> + <%= f.label :per_code_usage_limit %>
    + <%= f.number_field :per_code_usage_limit, min: 0, class: 'fullwidth' %>
    + <% end %> + <% end %> + +
    + <%= f.label :starts_at %> + <%= f.field_hint :starts_at %> + <%= + f.text_field :starts_at, + value: datepicker_field_value(@promotion.starts_at, with_time: true), + placeholder: t(".starts_at_placeholder"), + class: 'datepicker datepicker-from fullwidth', + data: { :'enable-time' => true, :'default-hour' => 0 } + %> +
    + +
    + <%= f.label :expires_at %> + <%= f.field_hint :expires_at %> + <%= + f.text_field :expires_at, + value: datepicker_field_value(@promotion.expires_at, + with_time: true), + placeholder: t(".expires_at_placeholder"), + class: 'datepicker datepicker-to fullwidth', + data: { :'enable-time' => true, :'default-hour' => 0 } + %> +
    +
    + <%= f.field_container :lane do %> + <%= f.label :lane %>
    + <%= + f.select(:lane, SolidusPromotions::Promotion.lane_options, {}, { class: 'custom-select fullwidth' }) + %> + <% end %> +
    +
    +
    +
    + +
    + <%= t '.activation' %> + +
    + <%= f.field_container :apply_automatically do %> + <%= f.label :apply_automatically do %> + <%= f.check_box :apply_automatically, disabled: f.object.codes.any? || f.object.path.present? %> + <%= SolidusPromotions::Promotion.human_attribute_name(:apply_automatically) %> + <%= f.field_hint :promo_code_will_be_disabled %> + <% end %> + <% end %> +
    + + <% if f.object.new_record? || f.object.present? %> +
    + <%= f.field_container :path do %> + <%= f.label :path %> + <%= f.text_field :path, class: "fullwidth", disabled: f.object.apply_automatically || f.object.codes.present? %> + <% end %> +
    + <% end %> + +
    + <% if f.object.new_record? %> +
    + <%= label_tag :single_code, SolidusPromotions::PromotionCode.model_name.human %> + <%= text_field_tag :single_code, @promotion.codes.first.try!(:value), class: "fullwidth", disabled: f.object.apply_automatically || f.object.path.present? %> +
    + <% else %> +
    +

    + <%= t('.codes_present') %> +

    +
    + <% end %> +
    +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotions/_table.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotions/_table.html.erb new file mode 100644 index 00000000000..a2bcb191861 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotions/_table.html.erb @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + <% promotions.each do |promotion| %> + + + + + + + + + + + + <% end %> + +
    <%= sort_link @search, :name %><%= SolidusPromotions::Promotion.human_attribute_name(:code) %><%= SolidusPromotions::Promotion.human_attribute_name(:status) %><%= SolidusPromotions::Promotion.human_attribute_name(:usage) %><%= sort_link @search, :starts_at %><%= sort_link @search, :expires_at %><%= SolidusPromotions::Promotion.human_attribute_name(:lane) %><%= sort_link @search, :updated_at %>
    <%= promotion.name %> + <%= (promotion.codes.size == 1) ? promotion.codes.pluck(:value).first : t('solidus_promotions.number_of_codes', count: promotion.codes.size) %> + + + <%= t(admin_promotion_status(promotion), scope: 'solidus_promotions.admin.promotion_status') %> + + + <% if promotion.usage_limit.nil? %> + <%= promotion.discounted_orders.exists? ? t(:say_yes, scope: :spree) : t(:say_no, scope: :spree) %> + <% else %> + <%= promotion.usage_count %> / <%= promotion.usage_limit %> + <% end %> + + <%= l(promotion.starts_at, format: :short) if promotion.starts_at %> + + <%= l(promotion.expires_at, format: :short) if promotion.expires_at %> + + <%= SolidusPromotions::Promotion.human_enum_name(:lane, promotion.lane) %> + + <%= l(promotion.updated_at, format: :short) %> + + <% if can?(:edit, promotion) && !promotion.discarded? %> + <%= link_to_edit promotion, no_text: true %> + <% end %> + <% if can?(:destroy, promotion) && !promotion.discarded? %> + <%= link_to_delete promotion, no_text: true %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotions/_table_filter.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotions/_table_filter.html.erb new file mode 100644 index 00000000000..0863615de3e --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotions/_table_filter.html.erb @@ -0,0 +1,78 @@ +<% content_for :table_filter_title do %> + <%= t('spree.search') %> +<% end %> + +<% content_for :table_filter do %> +
    + <%= search_form_for [:admin, search] do |f| %> +
    +
    +
    + <%= label_tag :q_name_cont, SolidusPromotions::Promotion.human_attribute_name(:name) %> + <%= f.text_field :name_cont, tabindex: 1, class: "w-100" %> +
    +
    + +
    +
    + <%= label_tag :q_customer_label_cont, SolidusPromotions::Promotion.human_attribute_name(:customer_label) %> + <%= f.text_field :customer_label_cont, tabindex: 1, class: "w-100" %> +
    +
    + +
    +
    + <%= label_tag :q_codes_value_cont, SolidusPromotions::Promotion.human_attribute_name(:code) %> + <%= f.text_field :codes_value_cont, tabindex: 1, class: "w-100" %> +
    +
    + +
    +
    + <%= label_tag :q_path_cont, SolidusPromotions::Promotion.human_attribute_name(:path) %> + <%= f.text_field :path_cont, tabindex: 1, class: "w-100" %> +
    +
    + +
    +
    + <%= label_tag :q_active, SolidusPromotions::Promotion.human_attribute_name(:active) %> + <%= f.text_field :active, value: params[:q][:active], + class: 'datepicker datepicker-from fullwidth', + data: { :'enable-time' => true, :'default-hour' => 0 } %> +
    +
    + +
    +
    + +
    +
    + +
    +
    + <%= label_tag :q_promotion_category_id_eq, SolidusPromotions::PromotionCategory.model_name.human %>
    + <%= f.collection_select(:promotion_category_id_eq, @promotion_categories, :id, :name, { include_blank: t(:all, scope: :spree) }, { class: 'custom-select fullwidth' }) %> +
    +
    + +
    +
    + <%= label_tag :q_lane_eq, SolidusPromotions::Promotion.human_attribute_name(:lane) %>
    + <%= f.select(:lane_eq, SolidusPromotions::Promotion.lane_options, { include_blank: t(:all, scope: :spree) }, { class: 'custom-select fullwidth' }) %> +
    +
    + +
    + +
    +
    + <%= button_tag t('spree.filter_results'), class: 'btn btn-primary' %> +
    +
    + <% end %> +
    +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotions/edit.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotions/edit.html.erb new file mode 100644 index 00000000000..b1d363ad740 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotions/edit.html.erb @@ -0,0 +1,41 @@ +<% admin_layout "full-width" %> + +<% admin_breadcrumb(link_to plural_resource_name(SolidusPromotions::Promotion), solidus_promotions.admin_promotions_path) %> +<% admin_breadcrumb(@promotion.name) %> + +<%= content_for :head do %> + <%= javascript_importmap_tags "backend/solidus_promotions", importmap: SolidusPromotions.importmap %> + <%#= stylesheet_link_tag 'solidus_promotions/promotions' %> +<% end %> + +<% content_for :page_actions do %> +
  • + <% if can?(:show, SolidusPromotions::PromotionCode) %> + <%= link_to t('solidus_promotions.view_promotion_codes_list'), solidus_promotions.admin_promotion_promotion_codes_path(promotion_id: @promotion.id), class: 'btn btn-primary' %> + + <%= link_to t('solidus_promotions.download_promotion_codes_list'), solidus_promotions.admin_promotion_promotion_codes_path(promotion_id: @promotion.id, format: :csv), class: 'btn btn-primary' %> + <% end %> + + <% if can?(:show, SolidusPromotions::PromotionCodeBatch) %> + <%= link_to plural_resource_name(SolidusPromotions::PromotionCodeBatch), solidus_promotions.admin_promotion_promotion_code_batches_path(promotion_id: @promotion.id), class: 'btn btn-primary' %> + <% end %> +
  • +<% end %> + +<%= form_for @promotion, url: object_url, method: :put do |f| %> + <%= render partial: 'form', locals: { f: f } %> + <% if can?(:update, @promotion) %> + <%= render partial: 'spree/admin/shared/edit_resource_links' %> + <% end %> +<% end %> + +
    + <%= t("benefits", scope: :solidus_promotions) %> + <% if @promotion.benefits.any? %> + <%= render partial: 'solidus_promotions/admin/benefits/benefit', collection: @promotion.benefits %> + <% end %> + + <%= turbo_frame_tag @promotion, "new_benefit" do %> + <%= link_to t(:add_benefit, scope: :solidus_promotions), solidus_promotions.new_admin_promotion_benefit_path(@promotion), class: 'btn btn-secondary' %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotions/index.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotions/index.html.erb new file mode 100644 index 00000000000..96ead7703ed --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotions/index.html.erb @@ -0,0 +1,23 @@ +<% admin_breadcrumb(plural_resource_name(SolidusPromotions::Promotion)) %> + +<% content_for :page_actions do %> + <% if can? :create, SolidusPromotions::Promotion %> +
  • + <%= link_to t('solidus_promotions.new_promotion'), solidus_promotions.new_admin_promotion_path, class: 'btn btn-primary' %> +
  • + <% end %> +<% end %> + +<%= render "table_filter", search: @search %> + +<%= paginate @promotions, theme: "solidus_admin" %> + +<% if @promotions.length > 0 %> + <%= render "table", promotions: @promotions %> +<% else %> +
    + <%= render 'spree/admin/shared/no_objects_found', + resource: SolidusPromotions::Promotion, + new_resource_url: new_object_url %> +
    +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/promotions/new.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/promotions/new.html.erb new file mode 100644 index 00000000000..7e0795c13ba --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/promotions/new.html.erb @@ -0,0 +1,9 @@ +<% admin_layout "full-width" %> + +<% admin_breadcrumb(link_to plural_resource_name(SolidusPromotions::Promotion), solidus_promotions.admin_promotions_path) %> +<% admin_breadcrumb(t('solidus_promotions.new_promotion')) %> + +<%= form_for @promotion, url: collection_url do |f| %> + <%= render partial: 'form', locals: { f: f } %> + <%= render partial: 'spree/admin/shared/new_resource_links' %> +<% end %> diff --git a/promotions/lib/views/backend/solidus_promotions/admin/shared/_number_with_currency.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/shared/_number_with_currency.html.erb new file mode 100644 index 00000000000..80177ace2db --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/shared/_number_with_currency.html.erb @@ -0,0 +1,20 @@ +<% amount_attr ||= :amount %> +<% currency_attr ||= :currency %> +<% currency ||= nil %> +<% required ||= nil %> + +"> +
    + +
    + <%= f.text_field amount_attr, value: number_to_currency(f.object.public_send(amount_attr), unit: '', delimiter: ''), class: 'form-control number-with-currency-amount', required: required %> + <% if currency %> +
    + + <%= ::Money::Currency.find(currency).iso_code %> + +
    + <% else %> + <%= f.select currency_attr, Spree::Config.available_currencies.map(&:iso_code), {selected: f.object.send(currency_attr) || Spree::Config.currency}, {required: required, class: 'number-with-currency-select'} %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/shared/_promotion_sub_menu.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/shared/_promotion_sub_menu.html.erb new file mode 100644 index 00000000000..9afb7e55488 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/shared/_promotion_sub_menu.html.erb @@ -0,0 +1,14 @@ +
      + <% if can? :admin, SolidusPromotions::Promotion %> + <%= tab url: solidus_promotions.admin_promotions_path, label: :promotions %> + <% end %> + <% if can? :admin, SolidusPromotions::PromotionCategory %> + <%= tab url: solidus_promotions.admin_promotion_categories_path, label: :promotion_categories %> + <% end %> + <% if can?(:admin, Spree::Promotion) && Spree::Promotion.any? %> + <%= tab url: spree.admin_promotions_path, label: :legacy_promotions %> + <% end %> + <% if can?(:admin, Spree::PromotionCategory) && Spree::PromotionCategory.any? %> + <%= tab url: spree.admin_promotion_categories_path, label: :legacy_promotion_categories %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_boolean.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_boolean.html.erb new file mode 100644 index 00000000000..6aff0e9a950 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_boolean.html.erb @@ -0,0 +1,14 @@ +<% label = local_assigns[:label].presence %> +<% html_options = local_assigns[:html_options] || {} %> + +
    + +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_decimal.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_decimal.html.erb new file mode 100644 index 00000000000..23eb327f956 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_decimal.html.erb @@ -0,0 +1,12 @@ +<% label = local_assigns[:label].presence %> +<% html_options = {class: 'input_decimal fullwidth'}.update(local_assigns[:html_options] || {}) %> + +
    + <% if local_assigns[:form] %> + <%= form.label attribute, label %> + <%= form.text_field attribute, html_options %> + <% else %> + <%= label_tag name, label %> + <%= text_field_tag name, value, html_options %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_encrypted_string.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_encrypted_string.html.erb new file mode 100644 index 00000000000..05c040a4a81 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_encrypted_string.html.erb @@ -0,0 +1,12 @@ +<% label = local_assigns[:label].presence %> +<% html_options = {class: 'input_string fullwidth'}.merge(local_assigns[:html_options] || {}) %> + +
    + <% if local_assigns[:form] %> + <%= form.label attribute, label %> + <%= form.text_field attribute, html_options %> + <% else %> + <%= label_tag name, label %> + <%= text_field_tag name, value, html_options %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_integer.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_integer.html.erb new file mode 100644 index 00000000000..8e71759c4a5 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_integer.html.erb @@ -0,0 +1,12 @@ +<% label = local_assigns[:label].presence %> +<% html_options = {class: 'input_integer fullwidth'}.update(local_assigns[:html_options] || {}) %> + +
    + <% if local_assigns[:form] %> + <%= form.label attribute, label %> + <%= form.number_field attribute, html_options %> + <% else %> + <%= label_tag name, label %> + <%= number_field_tag name, value, html_options %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_password.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_password.html.erb new file mode 100644 index 00000000000..67c74528ca3 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_password.html.erb @@ -0,0 +1,12 @@ +<% label = local_assigns[:label].presence %> +<% html_options = {class: 'password_string fullwidth'}.update(local_assigns[:html_options] || {}) %> + +
    + <% if local_assigns[:form] %> + <%= form.label attribute, label %> + <%= form.password_field attribute, html_options %> + <% else %> + <%= label_tag name, label %> + <%= password_field_tag name, value, html_options %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_string.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_string.html.erb new file mode 100644 index 00000000000..1117724431c --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_string.html.erb @@ -0,0 +1,12 @@ +<% label = local_assigns[:label].presence %> +<% html_options = {class: 'input_string fullwidth'}.update(local_assigns[:html_options] || {}) %> + +
    + <% if local_assigns[:form] %> + <%= form.label attribute, label %> + <%= form.text_field attribute, html_options %> + <% else %> + <%= label_tag name, label %> + <%= text_field_tag name, value, html_options %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_text.html.erb b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_text.html.erb new file mode 100644 index 00000000000..bb11e1b5071 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_text.html.erb @@ -0,0 +1,12 @@ +<% label = local_assigns[:label].presence %> +<% html_options = {cols: 85, rows: 15}.update(local_assigns[:html_options] || {}) %> + +
    + <% if local_assigns[:form] %> + <%= form.label attribute, label %> + <%= form.text_area attribute, html_options %> + <% else %> + <%= label_tag name, label %> + <%= text_area_tag name, value, html_options %> + <% end %> +
    diff --git a/promotions/lib/views/backend/solidus_promotions/promotion_code_batch_mailer/promotion_code_batch_errored.text.erb b/promotions/lib/views/backend/solidus_promotions/promotion_code_batch_mailer/promotion_code_batch_errored.text.erb new file mode 100644 index 00000000000..bb6b68eb69d --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/promotion_code_batch_mailer/promotion_code_batch_errored.text.erb @@ -0,0 +1,2 @@ +<%= t(".message", error: @promotion_code_batch.error) %> +<%= @promotion_code_batch.promotion.name %> diff --git a/promotions/lib/views/backend/solidus_promotions/promotion_code_batch_mailer/promotion_code_batch_finished.text.erb b/promotions/lib/views/backend/solidus_promotions/promotion_code_batch_mailer/promotion_code_batch_finished.text.erb new file mode 100644 index 00000000000..9db0326bb62 --- /dev/null +++ b/promotions/lib/views/backend/solidus_promotions/promotion_code_batch_mailer/promotion_code_batch_finished.text.erb @@ -0,0 +1,2 @@ +<%= t(".message", number_of_codes: 10) %> +<%= @promotion_code_batch.promotion.name %> diff --git a/promotions/solidus_promotions.gemspec b/promotions/solidus_promotions.gemspec new file mode 100644 index 00000000000..7f67644ee32 --- /dev/null +++ b/promotions/solidus_promotions.gemspec @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "../core/lib/spree/core/version" + +Gem::Specification.new do |spec| + spec.platform = Gem::Platform::RUBY + spec.name = "solidus_promotions" + spec.version = Spree.solidus_version + spec.summary = "New promotion system for Solidus" + spec.description = spec.summary + + spec.authors = ["Martin Meyerhoff", "Solidus Team"] + spec.email = "contact@solidus.io" + spec.homepage = "https://github.com/solidusio/solidus/blob/main/promotions/README.md" + + spec.license = "BSD-3-Clause" + + spec.metadata["homepage_uri"] = spec.homepage + + spec.required_ruby_version = ">= 3.0.0" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + files = Dir.chdir(__dir__) { `git ls-files -z`.split("\x0") } + spec.files = files.grep_v(%r{^(test|spec|features)/}) + + spec.add_dependency "importmap-rails", "~> 1.2" + spec.add_dependency "ransack-enum", "~> 1.0" + spec.add_dependency "solidus_core", [">= 4.0.0", "< 5"] + spec.add_dependency "solidus_support", "~> 0.5" + spec.add_dependency "stimulus-rails", "~> 1.2" + spec.add_dependency "turbo-rails", ">= 1.4" +end diff --git a/promotions/spec/jobs/solidus_promotions/promotion_code_batch_job_spec.rb b/promotions/spec/jobs/solidus_promotions/promotion_code_batch_job_spec.rb new file mode 100644 index 00000000000..1a94ae4a642 --- /dev/null +++ b/promotions/spec/jobs/solidus_promotions/promotion_code_batch_job_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "rails_helper" +RSpec.describe SolidusPromotions::PromotionCodeBatchJob, type: :job do + let(:email) { "test@email.com" } + let(:code_batch) do + SolidusPromotions::PromotionCodeBatch.create!( + promotion: create(:solidus_promotion), + base_code: "test", + number_of_codes: 10, + email: email + ) + end + + context "with a successful build" do + before do + allow(SolidusPromotions::PromotionCodeBatchMailer) + .to receive(:promotion_code_batch_finished) + .and_call_original + end + + def codes + SolidusPromotions::PromotionCode.pluck(:value) + end + + context "with the default join character" do + it "uses the default join characters", :aggregate_failures do + subject.perform(code_batch) + codes.each do |code| + expect(code).to match(/^test_/) + end + end + end + + context "with a custom join character" do + let(:code_batch) do + SolidusPromotions::PromotionCodeBatch.create!( + promotion: create(:solidus_promotion), + base_code: "test", + number_of_codes: 10, + email: email, + join_characters: "-" + ) + end + + it "uses the custom join characters", :aggregate_failures do + subject.perform(code_batch) + codes.each do |code| + expect(code).to match(/^test-/) + end + end + end + + context "with an email address" do + it "sends an email" do + subject.perform(code_batch) + expect(SolidusPromotions::PromotionCodeBatchMailer) + .to have_received(:promotion_code_batch_finished) + end + end + + context "with no email address" do + let(:email) { nil } + + it "sends an email" do + subject.perform(code_batch) + expect(SolidusPromotions::PromotionCodeBatchMailer) + .not_to have_received(:promotion_code_batch_finished) + end + end + end + + context "with a failed build" do + before do + allow_any_instance_of(SolidusPromotions::PromotionCode::BatchBuilder) + .to receive(:build_promotion_codes) + .and_raise("Error") + + allow(SolidusPromotions::PromotionCodeBatchMailer) + .to receive(:promotion_code_batch_errored) + .and_call_original + + expect { subject.perform(code_batch) } + .to raise_error RuntimeError + end + + context "with an email address" do + it "sends an email" do + expect(SolidusPromotions::PromotionCodeBatchMailer) + .to have_received(:promotion_code_batch_errored) + end + end + + context "with no email address" do + let(:email) { nil } + + it "sends an email" do + expect(SolidusPromotions::PromotionCodeBatchMailer) + .not_to have_received(:promotion_code_batch_errored) + end + end + end +end diff --git a/promotions/spec/lib/solidus_promotions/configuration_spec.rb b/promotions/spec/lib/solidus_promotions/configuration_spec.rb new file mode 100644 index 00000000000..de82e68f8c1 --- /dev/null +++ b/promotions/spec/lib/solidus_promotions/configuration_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Configuration do + subject(:config) { SolidusPromotions.config } + + it "has a nice accessor" do + expect(subject).to be_a(described_class) + end + + it "is an instance of Spree::Configuration" do + expect(subject).to be_a(Spree::Preferences::Configuration) + end + + describe ".promotion_chooser_class" do + it "is the promotion chooser" do + expect(subject.discount_chooser_class).to eq(SolidusPromotions::OrderAdjuster::ChooseDiscounts) + end + end + + describe ".advertiser_class" do + it "is the standard advertiser" do + expect(subject.advertiser_class).to eq(SolidusPromotions::PromotionAdvertiser) + end + end + + describe ".promotion_calculators" do + subject { config.promotion_calculators } + + it { is_expected.to be_a(Spree::Core::NestedClassSet) } + end + + describe ".order_conditions" do + subject { config.order_conditions } + + it { is_expected.to be_a(Spree::Core::ClassConstantizer::Set) } + end + + describe ".line_item_conditions" do + subject { config.line_item_conditions } + + it { is_expected.to be_a(Spree::Core::ClassConstantizer::Set) } + end + + describe ".shipment_conditions" do + subject { config.line_item_conditions } + + it { is_expected.to be_a(Spree::Core::ClassConstantizer::Set) } + end + + describe ".sync_order_promotions" do + subject { config.sync_order_promotions } + + it { is_expected.to be false } + + it "can be set to true" do + config.sync_order_promotions = true + expect(subject).to be true + config.sync_order_promotions = false + end + end + + describe ".recalculate_complete_orders" do + subject { config.recalculate_complete_orders } + + it { is_expected.to be true } + + it "can be set to false" do + config.recalculate_complete_orders = false + expect(subject).to be false + config.recalculate_complete_orders = true + end + end + + describe ".configure" do + it "yields self" do + expect { |b| config.configure(&b) }.to yield_with_args(config) + end + end +end diff --git a/promotions/spec/lib/solidus_promotions/migrate_adjustments_spec.rb b/promotions/spec/lib/solidus_promotions/migrate_adjustments_spec.rb new file mode 100644 index 00000000000..11894b7cd19 --- /dev/null +++ b/promotions/spec/lib/solidus_promotions/migrate_adjustments_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" +require "solidus_promotions/promotion_migrator" +require "solidus_promotions/promotion_map" +require "solidus_promotions/migrate_adjustments" + +RSpec.describe SolidusPromotions::MigrateAdjustments do + let(:promotion) { create(:promotion, :with_adjustable_action) } + let(:order) { create(:order_with_line_items) } + let(:line_item) { order.line_items.first } + let(:tax_rate) { create(:tax_rate) } + + before do + line_item.adjustments.create!( + source: tax_rate, + amount: -3, + label: "Business tax", + included: true, + order: order + ) + line_item.adjustments.create!( + source: promotion.actions.first, + amount: -2, + label: "Promotion (Because we like you)", + order: order + ) + SolidusPromotions::PromotionMigrator.new( + SolidusPromotions::PROMOTION_MAP + ).call + end + + describe ".up" do + subject { described_class.up } + + it "migrates our adjustment" do + spree_promotion_action = Spree::PromotionAction.first + solidus_promotion_benefit = SolidusPromotions::Benefit.first + expect { subject }.to change { + Spree::Adjustment.promotion.first.source + }.from(spree_promotion_action).to(solidus_promotion_benefit) + end + + it "will not touch tax adjustments" do + expect { subject }.not_to change { + Spree::Adjustment.tax.first.attributes + } + end + end + + describe ".down" do + subject { described_class.down } + + before do + described_class.up + end + + it "migrates our adjustment" do + spree_promotion_action = Spree::PromotionAction.first + solidus_promotion_benefit = SolidusPromotions::Benefit.first + expect { subject }.to change { + Spree::Adjustment.promotion.first.source + }.from(solidus_promotion_benefit).to(spree_promotion_action) + end + + it "will not touch tax adjustments" do + expect { subject }.not_to change { + Spree::Adjustment.tax.first.attributes + } + end + end +end diff --git a/promotions/spec/lib/solidus_promotions/migrate_order_promotions_spec.rb b/promotions/spec/lib/solidus_promotions/migrate_order_promotions_spec.rb new file mode 100644 index 00000000000..f3033191020 --- /dev/null +++ b/promotions/spec/lib/solidus_promotions/migrate_order_promotions_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" +require "solidus_promotions/promotion_migrator" +require "solidus_promotions/promotion_map" +require "solidus_promotions/migrate_order_promotions" + +RSpec.describe SolidusPromotions::MigrateOrderPromotions do + let(:promotion) { create(:promotion, :with_adjustable_action) } + let(:order) { create(:order_with_line_items) } + let(:line_item) { order.line_items.first } + let(:promotion_code) { create(:promotion_code, promotion: promotion) } + let!(:order_promotion) { order.order_promotions.create!(promotion: promotion, promotion_code: promotion_code) } + + before do + SolidusPromotions::PromotionMigrator.new( + SolidusPromotions::PROMOTION_MAP + ).call + end + + describe ".up" do + subject { described_class.up } + + it "deletes the spree order promotion" do + expect { subject }.to change { + Spree::OrderPromotion.count + }.from(1).to(0) + end + + it "creates our order promotion" do + expect { subject }.to change { + SolidusPromotions::OrderPromotion.count + }.from(0).to(1) + + order_promotion = SolidusPromotions::OrderPromotion.first + expect(order_promotion.order).to eq(order) + expect(order_promotion.promotion).to eq(SolidusPromotions::Promotion.first) + expect(order_promotion.promotion_code.value).to eq(promotion_code.value) + end + + context "with an order promotion without a promotion code" do + let!(:order_promotion) { order.order_promotions.create!(promotion: promotion) } + + it "deletes the spree order promotion" do + expect { subject }.to change { + Spree::OrderPromotion.count + }.from(1).to(0) + end + + it "creates our order promotion" do + expect { subject }.to change { + SolidusPromotions::OrderPromotion.count + }.from(0).to(1) + + order_promotion = SolidusPromotions::OrderPromotion.first + expect(order_promotion.order).to eq(order) + expect(order_promotion.promotion).to eq(SolidusPromotions::Promotion.first) + expect(order_promotion.promotion_code).to be nil + end + + context "if the order promotion already exists" do + before do + order.solidus_order_promotions.create!( + promotion: SolidusPromotions::Promotion.first, + promotion_code: nil + ) + end + + it "deletes the spree order promotion" do + expect { subject }.to change { + Spree::OrderPromotion.count + }.from(1).to(0) + end + + it "does not create the already existing spree promotion code" do + expect { subject }.not_to change { + SolidusPromotions::OrderPromotion.count + }.from(1) + + order_promotion = SolidusPromotions::OrderPromotion.last + expect(order_promotion.order).to eq(order) + expect(order_promotion.promotion).to eq(SolidusPromotions::Promotion.first) + expect(order_promotion.promotion_code).to be nil + end + end + end + + context "if the order promotion already exists" do + before do + order.solidus_order_promotions.create( + promotion: SolidusPromotions::Promotion.first, + promotion_code: SolidusPromotions::PromotionCode.first + ) + end + it "deletes the spree order promotion" do + expect { subject }.to change { + Spree::OrderPromotion.count + }.from(1).to(0) + end + + it "does not create the already existing solidus promotion code" do + expect { subject }.not_to change { + SolidusPromotions::OrderPromotion.count + }.from(1) + + order_promotion = SolidusPromotions::OrderPromotion.first + expect(order_promotion.order).to eq(order) + expect(order_promotion.promotion).to eq(SolidusPromotions::Promotion.first) + expect(order_promotion.promotion_code).to eq(SolidusPromotions::PromotionCode.first) + end + end + end + + describe ".down" do + subject { described_class.down } + + before do + described_class.up + end + + it "creates the spree order promotion" do + expect { subject }.to change { + Spree::OrderPromotion.count + }.from(0).to(1) + + order_promotion = Spree::OrderPromotion.first + expect(order_promotion.order).to eq(order) + expect(order_promotion.promotion).to eq(promotion) + expect(order_promotion.promotion_code).to eq(promotion_code) + end + + it "creates our order promotion" do + expect { subject }.to change { + SolidusPromotions::OrderPromotion.count + }.from(1).to(0) + end + + context "with an order promotion without a promotion code" do + let!(:order_promotion) { order.order_promotions.create!(promotion: promotion) } + + it "migrates our order promotion" do + expect { subject }.to change { + Spree::OrderPromotion.count + }.from(0).to(1) + order_promotion = Spree::OrderPromotion.first + expect(order_promotion.order).to eq(order) + expect(order_promotion.promotion).to eq(Spree::Promotion.first) + expect(order_promotion.promotion_code).to be nil + end + + it "creates our order promotion" do + expect { subject }.to change { + SolidusPromotions::OrderPromotion.count + }.from(1).to(0) + end + + context "if the order promotion already exists" do + before do + order.order_promotions.create!( + promotion: promotion, + promotion_code: nil + ) + end + + it "deletes the solidus order promotion" do + expect { subject }.to change { + SolidusPromotions::OrderPromotion.count + }.from(1).to(0) + end + + it "does not create the already existing spree promotion code" do + expect { subject }.not_to change { + Spree::OrderPromotion.count + }.from(1) + + order_promotion = Spree::OrderPromotion.last + expect(order_promotion.order).to eq(order) + expect(order_promotion.promotion).to eq(promotion) + expect(order_promotion.promotion_code).to be nil + end + end + end + + context "if the order promotion already exists" do + before do + order.order_promotions.create( + promotion: promotion, + promotion_code: promotion_code + ) + end + it "deletes the spree order promotion" do + expect { subject }.to change { + SolidusPromotions::OrderPromotion.count + }.from(1).to(0) + end + + it "does not create the already existing solidus promotion code" do + expect { subject }.not_to change { + Spree::OrderPromotion.count + }.from(1) + + order_promotion = Spree::OrderPromotion.first + expect(order_promotion.order).to eq(order) + expect(order_promotion.promotion).to eq(promotion) + expect(order_promotion.promotion_code).to eq(promotion_code) + end + end + end +end diff --git a/promotions/spec/lib/solidus_promotions/promotion_migrator_spec.rb b/promotions/spec/lib/solidus_promotions/promotion_migrator_spec.rb new file mode 100644 index 00000000000..85b9a8e5cb6 --- /dev/null +++ b/promotions/spec/lib/solidus_promotions/promotion_migrator_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" +require "solidus_promotions/promotion_migrator" +require "solidus_promotions/promotion_map" + +RSpec.describe SolidusPromotions::PromotionMigrator do + let(:promotion_map) { SolidusPromotions::PROMOTION_MAP } + let!(:spree_promotion) { create(:promotion, :with_action, :with_item_total_rule, apply_automatically: true) } + + subject(:promotion_migrator) { SolidusPromotions::PromotionMigrator.new(promotion_map).call } + + it "stores original promotion and original promotion action" do + subject + expect(SolidusPromotions::Promotion.first.original_promotion).to eq(spree_promotion) + expect(SolidusPromotions::Benefit.first.original_promotion_action).to eq(spree_promotion.promotion_actions.first) + end + + context "when an existing promotion has a promotion category" do + let(:spree_promotion_category) { create(:promotion_category, name: "Sith") } + let(:spree_promotion) { create(:promotion, promotion_category: spree_promotion_category) } + + it "creates promotion categories that match the old promotion categories" do + expect { subject }.to change { SolidusPromotions::PromotionCategory.count }.by(1) + promotion_category = SolidusPromotions::PromotionCategory.first + expect(promotion_category.name).to eq("Sith") + end + end + + context "when an existing promotion has promotion codes" do + let(:spree_promotion) { create(:promotion, code: "ANDOR LIFE") } + + it "creates codes for the new promotion, identical to the previous one" do + expect { subject }.to change { SolidusPromotions::PromotionCode.count }.by(1) + promotion_code = SolidusPromotions::PromotionCode.first + expect(promotion_code.value).to eq("andor life") + end + end + + context "when an existing promotion has promotion codes with promotion code batches" do + let!(:promotion_code_batch) do + Spree::PromotionCodeBatch.new(promotion: spree_promotion, base_code: "DISNEY4LIFE", number_of_codes: 1) + end + + let!(:promotion_code) { create(:promotion_code, promotion: spree_promotion, promotion_code_batch: promotion_code_batch) } + let(:spree_promotion) { create(:promotion) } + + it "creates the promotion code batch copy" do + expect { subject }.to change { SolidusPromotions::PromotionCodeBatch.count }.by(1) + promotion_code_batch = SolidusPromotions::PromotionCodeBatch.first + expect(promotion_code_batch.base_code).to eq("DISNEY4LIFE") + end + end + + context "if our rules and actions are missing from the promotion map" do + let(:promotion_map) do + { + rules: {}, + actions: {} + } + end + + it "still creates the promotion, but without rules or actions" do + subject + expect(Spree::Promotion.count).not_to be_zero + expect(SolidusPromotions::Promotion.count).to eq(Spree::Promotion.count) + end + end +end diff --git a/promotions/spec/lib/solidus_promotions/testing_support/factories_spec.rb b/promotions/spec/lib/solidus_promotions/testing_support/factories_spec.rb new file mode 100644 index 00000000000..f36ebebb0b4 --- /dev/null +++ b/promotions/spec/lib/solidus_promotions/testing_support/factories_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Friendly Factories" do + it "has a bunch of working factories" do + [ + :solidus_promotion, + :solidus_promotion_with_benefit_adjustment, + :solidus_promotion_with_item_adjustment, + :solidus_promotion_with_order_adjustment, + :solidus_shipping_rate_discount + ].each do |factory| + expect { FactoryBot.create(factory) }.not_to raise_exception + end + end +end diff --git a/promotions/spec/mailers/solidus_promotions/promotion_code_batch_mailer_spec.rb b/promotions/spec/mailers/solidus_promotions/promotion_code_batch_mailer_spec.rb new file mode 100644 index 00000000000..9d963782d7c --- /dev/null +++ b/promotions/spec/mailers/solidus_promotions/promotion_code_batch_mailer_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::PromotionCodeBatchMailer, type: :mailer do + let(:promotion) { create(:solidus_promotion, name: "Promotion Test") } + let(:code_batch) do + SolidusPromotions::PromotionCodeBatch.create!( + promotion_id: promotion.id, + base_code: "test", + number_of_codes: 10, + email: "test@email.com" + ) + end + + describe "#promotion_code_batch_finished" do + subject { described_class.promotion_code_batch_finished(code_batch) } + + it "sends the email to the email attached to the promotion code batch " do + expect(subject.to).to eq([code_batch.email]) + end + + it "contains the number of codes created" do + expect(subject.body).to include("All 10 codes have been created") + end + + it "contains the name of the promotion" do + expect(subject.body).to include(promotion.name) + end + end + + describe "#promotion_code_batch_errored" do + subject { described_class.promotion_code_batch_errored(code_batch) } + + before { code_batch.update(error: "Test error") } + + it "sends the email to the email attached to the promotion code batch " do + expect(subject.to).to eq([code_batch.email]) + end + + it "contains the error" do + expect(subject.body).to include("Test error") + end + + it "contains the name of the promotion" do + expect(subject.body).to include(promotion.name) + end + end +end diff --git a/promotions/spec/models/promotion/integration_spec.rb b/promotions/spec/models/promotion/integration_spec.rb new file mode 100644 index 00000000000..c469ca61229 --- /dev/null +++ b/promotions/spec/models/promotion/integration_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require "rails_helper" +require "solidus_promotions/promotion_map" +require "solidus_promotions/promotion_migrator" + +RSpec.describe "Promotion System" do + context "A promotion that creates line item adjustments" do + let(:shirt) { create(:product, name: "Shirt") } + let(:pants) { create(:product, name: "Pants") } + let!(:promotion) { create(:solidus_promotion, name: "20% off Shirts", benefits: [benefit], apply_automatically: true) } + let(:order) { create(:order) } + + before do + benefit.conditions << condition + order.contents.add(shirt.master, 1) + order.contents.add(pants.master, 1) + end + + context "with an order-level condition" do + let(:condition) { SolidusPromotions::Conditions::Product.new(products: [shirt], preferred_line_item_applicable: false) } + + context "with an line item level benefit" do + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 20) } + let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(calculator: calculator) } + + it "creates one line item level adjustment" do + expect(order.adjustments).to be_empty + expect(order.total).to eq(31.98) + expect(order.item_total).to eq(39.98) + expect(order.item_total_before_tax).to eq(31.98) + expect(order.line_items.flat_map(&:adjustments).length).to eq(2) + end + end + + context "with an automation" do + let(:goodie) { create(:variant, price: 4) } + let(:benefit) { SolidusPromotions::Benefits::CreateDiscountedItem.new(preferred_variant_id: goodie.id, calculator: hundred_percent) } + let(:hundred_percent) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 100) } + let(:condition) { SolidusPromotions::Conditions::Product.new(products: [shirt], preferred_line_item_applicable: true) } + + it "creates a new discounted line item" do + expect(order.adjustments).to be_empty + expect(order.line_items.count).to eq(3) + expect(order.total).to eq(39.98) + expect(order.item_total).to eq(43.98) + expect(order.item_total_before_tax).to eq(39.98) + expect(order.line_items.flat_map(&:adjustments).length).to eq(1) + end + + context "when a second base item is added" do + before do + order.contents.add(shirt.master) + end + + it "creates a new discounted line item" do + expect(order.adjustments).to be_empty + expect(order.line_items.count).to eq(3) + expect(order.total).to eq(59.97) + expect(order.item_total).to eq(67.97) + expect(order.item_total_before_tax).to eq(59.97) + expect(order.line_items.flat_map(&:adjustments).length).to eq(1) + end + end + + context "when the goodie becomes unavailable" do + before do + order.contents.remove(shirt.master) + end + + it "removes the discounted line item" do + expect(order.adjustments).to be_empty + expect(order.line_items.length).to eq(1) + expect(order.promo_total).to eq(0) + expect(order.total).to eq(19.99) + expect(order.item_total).to eq(19.99) + expect(order.item_total_before_tax).to eq(19.99) + expect(order.line_items.flat_map(&:adjustments).length).to eq(0) + end + end + + context "with a line-item level promotion in the lane before it" do + let!(:other_promotion) { create(:solidus_promotion, :with_adjustable_benefit, lane: :pre, apply_automatically: true) } + + it "creates a new discounted line item" do + order.recalculate + expect(order.adjustments).to be_empty + expect(order.line_items.count).to eq(3) + expect(order.total).to eq(19.98) + expect(order.item_total).to eq(43.98) + expect(order.item_total_before_tax).to eq(19.98) + expect(order.line_items.flat_map(&:adjustments).length).to eq(3) + expect(order.line_items.detect { |line_item| line_item.managed_by_order_benefit == benefit }.adjustments.length).to eq(1) + expect(order.line_items.detect { |line_item| line_item.managed_by_order_benefit == benefit }.adjustments.first.amount).to eq(-4) + end + end + end + end + + context "with a line-item level condition" do + let(:condition) { SolidusPromotions::Conditions::LineItemProduct.new(products: [shirt]) } + + context "with an line item level benefit" do + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 20) } + let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(calculator: calculator) } + + it "creates one line item level adjustment" do + expect(order.adjustments).to be_empty + expect(order.total).to eq(35.98) + expect(order.item_total).to eq(39.98) + expect(order.item_total_before_tax).to eq(35.98) + expect(order.line_items.flat_map(&:adjustments).length).to eq(1) + end + end + end + end + + context "with two promotions that should stack" do + let(:shirt) { create(:product, name: "Shirt", price: 30) } + let(:pants) { create(:product, name: "Pants", price: 40) } + let(:discounted_item_total_condition_amount) { 60 } + let(:discounted_item_total_condition) do + SolidusPromotions::Conditions::DiscountedItemTotal.new(preferred_amount: discounted_item_total_condition_amount) + end + let(:discounted_item_total_benefit) do + SolidusPromotions::Benefits::AdjustLineItem.new(calculator: discounted_item_total_calculator, conditions: [discounted_item_total_condition]) + end + let(:discounted_item_total_calculator) do + SolidusPromotions::Calculators::DistributedAmount.new(preferred_amount: 10) + end + let!(:distributed_amount_promo) do + create( + :solidus_promotion, + benefits: [discounted_item_total_benefit], + apply_automatically: true, + lane: :post + ) + end + + let(:shirts_condition) { SolidusPromotions::Conditions::LineItemProduct.new(products: [shirt]) } + let(:shirts_calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 20) } + let(:shirts_benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(calculator: shirts_calculator, conditions: [shirts_condition]) } + let!(:shirts_promotion) do + create( + :solidus_promotion, + benefits: [shirts_benefit], + name: "20% off shirts", + apply_automatically: true + ) + end + let(:order) { create(:order) } + + before do + order.contents.add(shirt.master, 1) + order.contents.add(pants.master, 1) + end + + it "does all the right things" do + expect(order.adjustments).to be_empty + # shirt: 30 USD - 20% = 24 USD + # Remaining total: 64 USD + # 10 USD distributed off: 54 USD + expect(order.total).to eq(54.00) + expect(order.item_total).to eq(70.00) + expect(order.item_total_before_tax).to eq(54) + expect(order.line_items.flat_map(&:adjustments).length).to eq(3) + end + + context "if the post lane promotion is ineligible" do + let(:discounted_item_total_condition_amount) { 68 } + + it "does all the right things" do + expect(order.adjustments).to be_empty + # shirt: 30 USD - 20% = 24 USD + # Remaining total: 64 USD + # The 10 off promotion does not apply because now the order total is below 68 + expect(order.total).to eq(64.00) + expect(order.item_total).to eq(70.00) + expect(order.item_total_before_tax).to eq(64) + expect(order.line_items.flat_map(&:adjustments).length).to eq(1) + end + end + end + + context "with a migrated spree_promotion that is attached to the current order" do + let(:shirt) { create(:variant) } + let(:spree_promotion) { create(:promotion, :with_adjustable_action, code: true) } + let(:order) { create(:order_with_line_items, line_items_attributes: [{ variant: shirt }]) } + + before do + Spree::Config.promotions = SolidusLegacyPromotions::Configuration.new + Spree::Config.order_contents_class = "Spree::OrderContents" + SolidusPromotions.config.sync_order_promotions = true + promotion_code = spree_promotion.codes.first + order.order_promotions << Spree::OrderPromotion.new( + promotion_code: promotion_code, + promotion: spree_promotion + ) + Spree::PromotionHandler::Cart.new(order).activate + order.recalculate + expect(order.line_items.first.adjustments.first.source).to eq(spree_promotion.promotion_actions.first) + promotion_map = SolidusPromotions::PROMOTION_MAP + SolidusPromotions::PromotionMigrator.new(promotion_map).call + expect(SolidusPromotions::Promotion.count).to eq(1) + + Spree::Config.promotions = SolidusPromotions::Configuration.new + Spree::Config.order_contents_class = "Spree::SimpleOrderContents" + end + + after do + SolidusPromotions.config.sync_order_promotions = false + end + + subject { order.recalculate } + + it "replaces existing adjustments with adjustments for the equivalent solidus promotion" do + expect { subject }.not_to change { order.all_adjustments.count } + end + + it "does not change the amount of any adjustments" do + expect { subject }.not_to change { order.reload.all_adjustments.sum(&:amount) } + end + end + + context "with a shipment-level condition" do + let!(:address) { create(:address) } + let(:shipping_zone) { create(:global_zone) } + let(:store) { create(:store) } + let!(:ups_ground) { create(:shipping_method, zones: [shipping_zone], cost: 23) } + let!(:dhl_saver) { create(:shipping_method, zones: [shipping_zone], cost: 37) } + let(:variant) { create(:variant, price: 13) } + let(:promotion) { create(:solidus_promotion, name: "20 percent off UPS Ground", apply_automatically: true) } + let(:condition) { SolidusPromotions::Conditions::ShippingMethod.new(preferred_shipping_method_ids: [ups_ground.id]) } + let(:order) { Spree::Order.create!(store: store) } + + before do + promotion.benefits << benefit + benefit.conditions << condition + + order.contents.add(variant, 1) + order.ship_address = address + order.bill_address = address + + order.create_proposed_shipments + + order.shipments.first.selected_shipping_rate_id = order.shipments.first.shipping_rates.detect do |r| + r.shipping_method == shipping_method + end.id + + order.recalculate + end + + context "with a line item level benefit" do + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 20) } + let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(calculator: calculator) } + let(:shipping_method) { ups_ground } + + it "creates adjustments" do + expect(order.adjustments).to be_empty + expect(order.total).to eq(33.40) + expect(order.item_total).to eq(13) + expect(order.item_total_before_tax).to eq(10.40) + expect(order.promo_total).to eq(-2.60) + expect(order.line_items.flat_map(&:adjustments).length).to eq(1) + expect(order.shipments.flat_map(&:adjustments)).to be_empty + expect(order.shipments.flat_map(&:shipping_rates).flat_map(&:discounts)).to be_empty + end + end + + context "with a shipment level benefit" do + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 20) } + let(:benefit) { SolidusPromotions::Benefits::AdjustShipment.new(calculator: calculator) } + + context "when the order is eligible" do + let(:shipping_method) { ups_ground } + + it "creates adjustments" do + expect(order.adjustments).to be_empty + expect(order.total).to eq(31.40) + expect(order.item_total).to eq(13) + expect(order.item_total_before_tax).to eq(13) + expect(order.promo_total).to eq(-4.6) + expect(order.line_items.flat_map(&:adjustments)).to be_empty + expect(order.shipments.flat_map(&:adjustments)).not_to be_empty + expect(order.shipments.flat_map(&:shipping_rates).flat_map(&:discounts)).not_to be_empty + end + end + + context "when the order is not eligible" do + let(:shipping_method) { dhl_saver } + + it "creates no adjustments" do + expect(order.adjustments).to be_empty + expect(order.total).to eq(50) + expect(order.item_total).to eq(13) + expect(order.item_total_before_tax).to eq(13) + expect(order.promo_total).to eq(0) + expect(order.line_items.flat_map(&:adjustments)).to be_empty + expect(order.shipments.flat_map(&:adjustments)).to be_empty + expect(order.shipments.flat_map(&:shipping_rates).flat_map(&:discounts)).not_to be_empty + end + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/benefit_spec.rb b/promotions/spec/models/solidus_promotions/benefit_spec.rb new file mode 100644 index 00000000000..3c8f5503f8c --- /dev/null +++ b/promotions/spec/models/solidus_promotions/benefit_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Benefit do + it { is_expected.to belong_to(:promotion) } + it { is_expected.to have_one(:calculator) } + it { is_expected.to have_many(:shipping_rate_discounts) } + it { is_expected.to have_many(:conditions) } + + it { is_expected.to respond_to :discount } + it { is_expected.to respond_to :can_discount? } + + describe "#can_adjust?" do + subject { described_class.new.can_discount?(double) } + + it "raises a NotImplementedError" do + expect { subject }.to raise_exception(NotImplementedError) + end + end + + describe "#destroy" do + subject { benefit.destroy } + let(:benefit) { promotion.benefits.first } + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, apply_automatically: true) } + + it "destroys the benefit" do + expect { subject }.to change { SolidusPromotions::Benefit.count }.by(-1) + end + + context "when the benefit has adjustments on an incomplete order" do + let(:order) { create(:order_with_line_items) } + + before do + order.recalculate + end + + it "destroys the benefit" do + expect { subject }.to change { SolidusPromotions::Benefit.count }.by(-1) + end + + it "destroys the adjustments" do + expect { subject }.to change { Spree::Adjustment.count }.by(-1) + end + + context "when the benefit has adjustments on a complete order" do + let(:order) { create(:order_ready_to_complete) } + + before do + order.recalculate + order.complete! + end + + it "raises an error" do + expect { subject }.not_to change { SolidusPromotions::Benefit.count } + expect(benefit.errors.full_messages).to include("Benefit has been applied to complete orders. It cannot be destroyed.") + end + end + end + end + + describe "#preload_relations" do + let(:benefit) { described_class.new } + subject { benefit.preload_relations } + + it { is_expected.to eq([:calculator]) } + end + + describe "#discount" do + subject { benefit.discount(discountable) } + + let(:variant) { create(:variant) } + let(:order) { create(:order) } + let(:discountable) { Spree::LineItem.new(order: order, variant: variant, price: 10, quantity: 1) } + let(:promotion) { SolidusPromotions::Promotion.new(customer_label: "20 Perzent off") } + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 20) } + let(:benefit) { described_class.new(promotion: promotion, calculator: calculator) } + + it "returns an discount to the discountable" do + expect(subject).to eq( + SolidusPromotions::ItemDiscount.new( + item: discountable, + label: "Promotion (20 Perzent off)", + source: benefit, + amount: -2 + ) + ) + end + + context "if the calculator returns nil" do + before do + allow(calculator).to receive(:compute).and_return(nil) + end + + it "returns nil" do + expect(subject).to be nil + end + end + + context "if the calculator returns zero" do + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 0) } + + it "returns nil" do + expect(subject).to be nil + end + end + end + + describe ".original_promotion_action" do + let(:spree_promotion) { create :promotion, :with_adjustable_action } + let(:spree_promotion_action) { spree_promotion.actions.first } + let(:solidus_promotion) { create :solidus_promotion, :with_adjustable_benefit } + let(:solidus_promotion_benefit) { solidus_promotion.benefits.first } + + subject { solidus_promotion_benefit.original_promotion_action } + + it "can be migrated from spree" do + solidus_promotion_benefit.original_promotion_action = spree_promotion_action + expect(subject).to eq(spree_promotion_action) + end + + it "is ok to be new" do + expect(subject).to be_nil + end + end + + describe "#level" do + subject { described_class.new.level } + + it "raises an error" do + expect { subject }.to raise_exception(NotImplementedError) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/benefits/adjust_line_item_quantity_groups_spec.rb b/promotions/spec/models/solidus_promotions/benefits/adjust_line_item_quantity_groups_spec.rb new file mode 100644 index 00000000000..d3a6630e230 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/benefits/adjust_line_item_quantity_groups_spec.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Benefits::AdjustLineItemQuantityGroups do + let(:action) { described_class.create!(calculator: calculator, promotion: promotion) } + + let(:order) do + create( + :order_with_line_items, + line_items_attributes: line_items_attributes + ) + end + + let(:line_items_attributes) do + [ + { price: 10, quantity: quantity } + ] + end + + let(:quantity) { 1 } + let(:promotion) { create(:solidus_promotion, apply_automatically: true) } + + describe "#compute_amount" do + subject { action.compute_amount(line_item) } + + context "with a flat rate adjustment" do + let(:calculator) { SolidusPromotions::Calculators::FlatRate.new(preferred_amount: 5) } + + context "with a quantity group of 2" do + let(:line_item) { order.line_items.first } + + before { action.preferred_group_size = 2 } + + context "and an item with a quantity of 0" do + let(:quantity) { 0 } + it { is_expected.to eq 0 } + end + + context "and an item with a quantity of 1" do + let(:quantity) { 1 } + it { is_expected.to eq 0 } + end + + context "and an item with a quantity of 2" do + let(:quantity) { 2 } + it { is_expected.to eq(-10) } + end + + context "and an item with a quantity of 3" do + let(:quantity) { 3 } + it { is_expected.to eq(-10) } + end + + context "and an item with a quantity of 4" do + let(:quantity) { 4 } + it { is_expected.to eq(-20) } + end + end + + context "with a quantity group of 3" do + before { action.preferred_group_size = 3 } + + context "and 2x item A, 1x item B and 1x item C" do + let(:line_items_attributes) do + [ + { price: 10, quantity: 2 }, + { price: 10, quantity: 1 }, + { price: 10, quantity: 1 } + ] + end + + describe "the adjustment for the first item" do + let(:line_item) { order.line_items.first } + it { is_expected.to eq(-10) } + end + describe "the adjustment for the second item" do + let(:line_item) { order.line_items.second } + it { is_expected.to eq(-5) } + end + describe "the adjustment for the third item" do + let(:line_item) { order.line_items.third } + it { is_expected.to eq 0 } + end + end + end + + context "with multiple orders using the same action" do + let(:other_order) do + create( + :order_with_line_items, + line_items_attributes: [ + { quantity: 3 } + ] + ) + end + + let(:line_item) { other_order.line_items.first } + + before do + action.preferred_group_size = 2 + end + + it { is_expected.to eq(-10) } + end + end + + context "with a percentage based adjustment" do + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 10) } + + let(:line_items_attributes) do + [ + { price: 10, quantity: 1 }.merge(line_one_options), + { price: 10, quantity: 1 }.merge(line_two_options) + ] + end + + let(:line_one_options) { {} } + let(:line_two_options) { {} } + + context "with a quantity group of 3" do + before do + action.preferred_group_size = 3 + end + + context "and 2x item A and 1x item B" do + let(:line_one_options) { { quantity: 2 } } + + describe "the adjustment for the first item" do + let(:line_item) { order.line_items.first } + it { is_expected.to eq(-2) } + end + describe "the adjustment for the second item" do + let(:line_item) { order.line_items.second } + it { is_expected.to eq(-1) } + end + end + + context "and the items cost different amounts" do + let(:line_one_options) { { quantity: 3 } } + let(:line_two_options) { { price: 20 } } + + describe "the adjustment for the first item" do + let(:line_item) { order.line_items.first } + it { is_expected.to eq(-3) } + end + describe "the adjustment for the second item" do + let(:line_item) { order.line_items.second } + it { is_expected.to eq 0 } + end + end + end + end + + context "with a tiered percentage based adjustment" do + let(:tiers) do + { + 20 => 20, + 40 => 30 + } + end + + let(:calculator) do + SolidusPromotions::Calculators::TieredPercent.create(preferred_base_percent: 10, preferred_tiers: tiers) + end + let(:line_items_attributes) do + [ + { price: 10, quantity: 1 }.merge(line_one_options), + { price: 10, quantity: 1 }.merge(line_two_options) + ] + end + + let(:line_one_options) { {} } + let(:line_two_options) { {} } + + context "with a quantity group of 3" do + before do + action.preferred_group_size = 3 + end + + context "and 2x item A and 1x item B" do + let(:line_one_options) { { quantity: 2 } } + + context "when amount falls within the first tier" do + describe "the adjustment for the first item" do + let(:line_item) { order.line_items.first } + it { is_expected.to eq(-4) } + end + describe "the adjustment for the second item" do + let(:line_item) { order.line_items.second } + it { is_expected.to eq(-2) } + end + end + + context "when amount falls within the second tier" do + let(:line_two_options) { { price: 20 } } + + describe "the adjustment for the first item" do + let(:line_item) { order.line_items.first } + it { is_expected.to eq(-6) } + end + + describe "the adjustment for the second item" do + let(:line_item) { order.line_items.second } + it { is_expected.to eq(-6) } + end + end + end + end + end + end + + # Regression test for https://github.com/solidusio/solidus/pull/1591 + context "with unsaved line_item changes" do + let(:calculator) { SolidusPromotions::Calculators::FlatRate.new(preferred_amount: 5) } + let(:line_item) { order.line_items.first } + + before do + order.line_items.first.promo_total = -11 + action.compute_amount(line_item) + end + + it "doesn't reload the line_items association" do + expect(order.line_items.first.promo_total).to eq(-11) + end + end + + # Regression test for https://github.com/solidusio/solidus/pull/1591 + context "applied to the order" do + let(:calculator) { SolidusPromotions::Calculators::FlatRate.new(preferred_amount: 10) } + + before do + action + order.recalculate + end + + it "updates the order totals" do + expect(order).to have_attributes( + total: 100, + adjustment_total: -10 + ) + end + + context "after updating item quantity" do + before do + order.line_items.first.update!(quantity: 2, price: 30) + order.recalculate + end + + it "updates the order totals" do + expect(order).to have_attributes( + total: 140, + adjustment_total: -20 + ) + end + end + + context "after updating promotion amount" do + before do + calculator.update!(preferred_amount: 5) + order.recalculate + end + + it "updates the order totals" do + expect(order).to have_attributes( + total: 105, + adjustment_total: -5 + ) + end + end + end + + describe SolidusPromotions::Benefits::AdjustLineItemQuantityGroups::Item do + let!(:item) { FactoryBot.create :line_item, order: order, quantity: quantity, price: 10 } + let(:quantity) { 5 } + + subject { described_class.new(item) } + + it "has a reference to the parent order" do + expect(subject.order.id).to eq order.id + end + + it "uses the `line_item.price` as a `line_item.amount`" do + expect(subject.amount).to eq item.price + end + + it "has a currency" do + expect(subject.currency).to eq item.currency + end + end +end diff --git a/promotions/spec/models/solidus_promotions/benefits/adjust_line_item_spec.rb b/promotions/spec/models/solidus_promotions/benefits/adjust_line_item_spec.rb new file mode 100644 index 00000000000..d3d70b33b4a --- /dev/null +++ b/promotions/spec/models/solidus_promotions/benefits/adjust_line_item_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Benefits::AdjustLineItem do + subject(:action) { described_class.new } + + describe "name" do + subject(:name) { action.model_name.human } + + it { is_expected.to eq("Discount matching line items") } + end + + describe ".to_partial_path" do + subject { described_class.new.to_partial_path } + + it { is_expected.to eq("solidus_promotions/admin/benefit_fields/adjust_line_item") } + end + + describe "#level" do + subject { described_class.new.level } + + it { is_expected.to eq(:line_item) } + end +end diff --git a/promotions/spec/models/solidus_promotions/benefits/adjust_shipment_spec.rb b/promotions/spec/models/solidus_promotions/benefits/adjust_shipment_spec.rb new file mode 100644 index 00000000000..82e5486319e --- /dev/null +++ b/promotions/spec/models/solidus_promotions/benefits/adjust_shipment_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Benefits::AdjustShipment do + subject(:action) { described_class.new } + + describe "name" do + subject(:name) { action.model_name.human } + + it { is_expected.to eq("Discount matching shipments") } + end + + describe "#can_discount?" do + subject { action.can_discount?(promotable) } + + context "with a line item" do + let(:promotable) { Spree::Order.new } + + it { is_expected.to be false } + end + + context "with a shipment" do + let(:promotable) { Spree::Shipment.new } + + it { is_expected.to be true } + end + + context "with a shipping rate" do + let(:promotable) { Spree::ShippingRate.new } + + it { is_expected.to be true } + end + end + + describe "#level" do + subject { described_class.new.level } + + it { is_expected.to eq(:shipment) } + end +end diff --git a/promotions/spec/models/solidus_promotions/benefits/create_discounted_item_spec.rb b/promotions/spec/models/solidus_promotions/benefits/create_discounted_item_spec.rb new file mode 100644 index 00000000000..2972505b7c1 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/benefits/create_discounted_item_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Benefits::CreateDiscountedItem do + it { is_expected.to respond_to(:preferred_variant_id) } + + describe "#perform" do + let(:order) { create(:order_with_line_items) } + let(:promotion) { create(:solidus_promotion) } + let(:benefit) { SolidusPromotions::Benefits::CreateDiscountedItem.new(preferred_variant_id: goodie.id, calculator: hundred_percent, promotion: promotion) } + let(:hundred_percent) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 100) } + let(:goodie) { create(:variant) } + subject { benefit.perform(order) } + + it "creates a line item with a hundred percent discount" do + expect { subject }.to change { order.line_items.count }.by(1) + created_item = order.line_items.detect { |line_item| line_item.managed_by_order_benefit == benefit } + expect(created_item.discountable_amount).to be_zero + end + + it "never calls the order recalculator" do + expect(order).not_to receive(:recalculate) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/calculators/distributed_amount_spec.rb b/promotions/spec/models/solidus_promotions/calculators/distributed_amount_spec.rb new file mode 100644 index 00000000000..0fcafc3c088 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/calculators/distributed_amount_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" +require "shared_examples/calculator_shared_examples" + +RSpec.describe SolidusPromotions::Calculators::DistributedAmount, type: :model do + let(:calculator) { described_class.new(preferred_amount: 15, preferred_currency: currency) } + let!(:promotion) do + create :solidus_promotion, apply_automatically: true, name: "15 spread", benefits: [benefit] + end + let(:conditions) { [] } + let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.create(calculator: calculator, conditions: conditions) } + let(:order) { create(:order_with_line_items, line_items_attributes: line_items_attributes) } + let(:currency) { "USD" } + + context "applied to an order" do + let(:line_items_attributes) { [{ price: 20 }, { price: 30 }, { price: 100 }] } + + before do + order.recalculate + end + + it "correctly distributes the entire discount" do + expect(order.promo_total).to eq(-15) + expect(order.line_items.map(&:adjustment_total)).to eq([-2, -3, -10]) + end + + context "with product promotion condition" do + let(:first_product) { order.line_items.first.product } + let(:conditions) do + [ + SolidusPromotions::Conditions::LineItemProduct.new(products: [first_product]) + ] + end + + before do + order.recalculate + end + + it "still distributes the entire discount" do + expect(order.promo_total).to eq(-15) + expect(order.line_items.map(&:adjustment_total)).to eq([-15, 0, 0]) + end + end + end + + describe "#compute_line_item" do + subject { calculator.compute_line_item(order.line_items.first) } + + let(:line_items_attributes) { [{ price: 50 }, { price: 50 }, { price: 50 }] } + + context "when the order currency matches the store's currency" do + let(:currency) { "USD" } + + it { is_expected.to eq 5 } + it { is_expected.to be_a BigDecimal } + end + + context "when the order currency does not match the store's currency" do + let(:currency) { "CAD" } + + it { is_expected.to eq 0 } + end + end +end diff --git a/promotions/spec/models/solidus_promotions/calculators/flat_rate_spec.rb b/promotions/spec/models/solidus_promotions/calculators/flat_rate_spec.rb new file mode 100644 index 00000000000..fba69bacf17 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/calculators/flat_rate_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" +require "shared_examples/calculator_shared_examples" + +RSpec.describe SolidusPromotions::Calculators::FlatRate, type: :model do + subject { calculator.compute(line_item) } + + let(:line_item) { mock_model(Spree::LineItem, order: order) } + let(:order) { mock_model(Spree::Order, currency: order_currency) } + let(:calculator) do + described_class.new( + preferred_amount: preferred_amount, + preferred_currency: preferred_currency + ) + end + + it_behaves_like "a calculator with a description" + + context "compute" do + describe "when preferred currency matches order" do + let(:preferred_currency) { "GBP" } + let(:order_currency) { "GBP" } + let(:preferred_amount) { 25 } + + it { is_expected.to eq(25.0) } + end + + describe "when preferred currency does not match order" do + let(:preferred_currency) { "GBP" } + let(:order_currency) { "USD" } + let(:preferred_amount) { 25 } + + it { is_expected.to be_zero } + end + + describe "when preferred currency does not match order" do + let(:preferred_currency) { "" } + let(:order_currency) { "USD" } + let(:preferred_amount) { 25 } + + it { is_expected.to be_zero } + end + + describe "when preferred currency and order currency use different casing" do + let(:preferred_currency) { "gbP" } + let(:order_currency) { "GBP" } + let(:preferred_amount) { 25 } + + it { is_expected.to eq(25.0) } + end + end +end diff --git a/promotions/spec/models/solidus_promotions/calculators/flexi_rate_spec.rb b/promotions/spec/models/solidus_promotions/calculators/flexi_rate_spec.rb new file mode 100644 index 00000000000..d8112638378 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/calculators/flexi_rate_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "rails_helper" +require "shared_examples/calculator_shared_examples" + +RSpec.describe SolidusPromotions::Calculators::FlexiRate, type: :model do + let(:calculator) do + described_class.new( + preferred_first_item: first_item, + preferred_additional_item: additional_item, + preferred_max_items: max_items + ) + end + let(:line_item) do + mock_model( + Spree::LineItem, quantity: quantity + ) + end + let(:first_item) { 0 } + let(:additional_item) { 0 } + let(:max_items) { 0 } + + let(:line_item) do + mock_model( + Spree::LineItem, quantity: quantity + ) + end + + it_behaves_like "a calculator with a description" + + context "compute" do + subject { calculator.compute(line_item) } + + context "with all amounts 0" do + context "with quantity 0" do + let(:quantity) { 0 } + + it { is_expected.to eq 0 } + end + + context "with quantity 1" do + let(:quantity) { 1 } + + it { is_expected.to eq 0 } + end + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq 0 } + end + + context "with quantity 10" do + let(:quantity) { 10 } + + it { is_expected.to eq 0 } + end + end + + context "when first_item has a value" do + let(:first_item) { 1.23 } + + context "with quantity 0" do + let(:quantity) { 0 } + + it { is_expected.to eq 0 } + end + + context "with quantity 1" do + let(:quantity) { 1 } + + it { is_expected.to eq 1.23 } + end + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq 1.23 } + end + + context "with quantity 10" do + let(:quantity) { 10 } + + it { is_expected.to eq 1.23 } + end + end + + context "when additional_items has a value" do + let(:additional_item) { 1.23 } + + context "with quantity 0" do + let(:quantity) { 0 } + + it { is_expected.to eq 0 } + end + + context "with quantity 1" do + let(:quantity) { 1 } + + it { is_expected.to eq 0 } + end + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq 1.23 } + end + + context "with quantity 10" do + let(:quantity) { 10 } + + it { is_expected.to eq 11.07 } + end + end + + context "when first_item and additional_items has a value" do + let(:first_item) { 1.13 } + let(:additional_item) { 2.11 } + + context "with quantity 0" do + let(:quantity) { 0 } + + it { is_expected.to eq 0 } + end + + context "with quantity 1" do + let(:quantity) { 1 } + + it { is_expected.to eq 1.13 } + end + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq 3.24 } + end + + context "with quantity 10" do + let(:quantity) { 10 } + + it { is_expected.to eq 20.12 } + end + + context "with max_items 5" do + let(:max_items) { 5 } + + context "with quantity 0" do + let(:quantity) { 0 } + + it { is_expected.to eq 0 } + end + + context "with quantity 1" do + let(:quantity) { 1 } + + it { is_expected.to eq 1.13 } + end + + context "with quantity 2" do + let(:quantity) { 2 } + + it { is_expected.to eq 3.24 } + end + + context "with quantity 5" do + let(:quantity) { 5 } + + it { is_expected.to eq 9.57 } + end + + context "with quantity 10" do + let(:quantity) { 10 } + + it { is_expected.to eq 9.57 } + end + end + end + end + + it "allows creation of new object with all the attributes" do + attributes = { preferred_first_item: 1, preferred_additional_item: 1, preferred_max_items: 1 } + calculator = described_class.new(attributes) + expect(calculator).to have_attributes(attributes) + end +end diff --git a/promotions/spec/models/solidus_promotions/calculators/percent_spec.rb b/promotions/spec/models/solidus_promotions/calculators/percent_spec.rb new file mode 100644 index 00000000000..aa03277db72 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/calculators/percent_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" +require "shared_examples/calculator_shared_examples" + +RSpec.describe SolidusPromotions::Calculators::Percent, type: :model do + context "compute" do + let(:currency) { "USD" } + let(:order) { double(currency: currency) } + let(:line_item) { double("Spree::LineItem", discountable_amount: 100, order: order) } + + before { subject.preferred_percent = 15 } + + it "computes based on item price and quantity" do + expect(subject.compute(line_item)).to eq 15 + end + end + + it_behaves_like "a calculator with a description" +end diff --git a/promotions/spec/models/solidus_promotions/calculators/tiered_flat_rate_spec.rb b/promotions/spec/models/solidus_promotions/calculators/tiered_flat_rate_spec.rb new file mode 100644 index 00000000000..8307f211439 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/calculators/tiered_flat_rate_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "rails_helper" +require "shared_examples/calculator_shared_examples" + +RSpec.describe SolidusPromotions::Calculators::TieredFlatRate, type: :model do + let(:calculator) { described_class.new } + + it_behaves_like "a calculator with a description" + + describe "#valid?" do + subject { calculator.valid? } + + context "when tiers is a hash" do + context "and the key is not a positive number" do + before { calculator.preferred_tiers = { "nope" => 20 } } + + it { is_expected.to be false } + end + + context "and the key is an integer" do + before { calculator.preferred_tiers = { 20 => 20 } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20") => BigDecimal("20") }) + end + end + + context "and the key is a float" do + before { calculator.preferred_tiers = { 20.5 => 20.5 } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20.5") => BigDecimal("20.5") }) + end + end + + context "and the key is a string number" do + before { calculator.preferred_tiers = { "20" => 20 } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20") => BigDecimal("20") }) + end + end + + context "and the key is a numeric string with spaces" do + before { calculator.preferred_tiers = { " 20 " => 20 } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20") => BigDecimal("20") }) + end + end + + context "and the key is a string number with decimals" do + before { calculator.preferred_tiers = { "20.5" => "20.5" } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20.5") => BigDecimal("20.5") }) + end + end + end + end + + describe "#compute" do + subject { calculator.compute(line_item) } + + let(:order) do + create( + :order_with_line_items, + line_items_count: 1, + line_items_price: amount + ) + end + let(:line_item) { order.line_items.first } + let(:preferred_currency) { "USD" } + + before do + calculator.preferred_base_amount = 10 + calculator.preferred_tiers = { + 100 => 15, + 200 => 20 + } + calculator.preferred_currency = preferred_currency + end + + context "when amount falls within the first tier" do + let(:amount) { 50 } + + it { is_expected.to eq 10 } + end + + context "when amount falls within the second tier" do + let(:amount) { 150 } + + it { is_expected.to eq 15 } + end + + context "when the order's currency does not match the calculator" do + let(:preferred_currency) { "CAD" } + let(:amount) { 50 } + + it { is_expected.to eq 0 } + end + + context "with a shipment" do + subject { calculator.compute(shipment) } + + let(:shipment) { Spree::Shipment.new(order: order, amount: shipping_cost) } + let(:line_item_count) { 1 } + let(:amount) { 10 } + + context "for multiple line items" do + context "when amount falls within the first tier" do + let(:shipping_cost) { 110 } + + it { is_expected.to eq 15 } + end + + context "when amount falls within the second tier" do + let(:shipping_cost) { 210 } + + it { is_expected.to eq 20 } + end + + context "when the order's currency does not match the calculator" do + let(:preferred_currency) { "CAD" } + let(:shipping_cost) { 110 } + + it { is_expected.to eq 0 } + end + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity_spec.rb b/promotions/spec/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity_spec.rb new file mode 100644 index 00000000000..c9c20ccb6ea --- /dev/null +++ b/promotions/spec/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Calculators::TieredPercentOnEligibleItemQuantity do + let(:order) do + create(:order_with_line_items, line_items_attributes: [first_item_attrs, second_item_attrs, third_item_attrs]) + end + + let(:first_item_attrs) { { variant: shirt, quantity: 2, price: 50 } } + let(:second_item_attrs) { { variant: pants, quantity: 3 } } + let(:third_item_attrs) { { variant: mug, quantity: 1 } } + + let(:shirt) { create(:variant) } + let(:pants) { create(:variant) } + let(:mug) { create(:variant) } + + let(:clothes) { create(:taxon, products: [shirt.product, pants.product]) } + + let(:promotion) { create(:solidus_promotion, name: "10 Percent on 5 apparel, 15 percent on 10", benefits: [benefit]) } + let(:clothes_only) { SolidusPromotions::Conditions::Taxon.new(taxons: [clothes]) } + let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(calculator: calculator, conditions: [clothes_only]) } + let(:calculator) { described_class.new(preferred_base_percent: 10, preferred_tiers: { 10 => 15.0 }) } + + let(:line_item) { order.line_items.detect { _1.variant == shirt } } + + subject { promotion.benefits.first.calculator.compute(line_item) } + + # 2 Shirts at 50, 100 USD. 10 % == 10 + it { is_expected.to eq(10) } + + context "if we have 12" do + let(:first_item_attrs) { { variant: shirt, quantity: 7, price: 50 } } + let(:second_item_attrs) { { variant: pants, quantity: 5 } } + + # 7 Shirts at 50, 350 USD, 15 % == 52.5 + it { is_expected.to eq(52.5) } + end + + context "if the order's currency is different" do + before do + order.currency = "GBP" + order.save! + end + + it { is_expected.to eq(0) } + end +end diff --git a/promotions/spec/models/solidus_promotions/calculators/tiered_percent_spec.rb b/promotions/spec/models/solidus_promotions/calculators/tiered_percent_spec.rb new file mode 100644 index 00000000000..88ccf6ef278 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/calculators/tiered_percent_spec.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require "rails_helper" +require "shared_examples/calculator_shared_examples" + +RSpec.describe SolidusPromotions::Calculators::TieredPercent, type: :model do + let(:calculator) { described_class.new } + + it_behaves_like "a calculator with a description" + + describe "#valid?" do + subject { calculator.valid? } + + context "when base percent is less than zero" do + before { calculator.preferred_base_percent = -1 } + + it { is_expected.to be false } + end + + context "when base percent is greater than 100" do + before { calculator.preferred_base_percent = 110 } + + it { is_expected.to be false } + end + + context "when tiers is a hash" do + context "and the key is not a positive number" do + before { calculator.preferred_tiers = { "nope" => 20 } } + + it { is_expected.to be false } + end + + context "and one of the values is not a percent" do + before { calculator.preferred_tiers = { 10 => 110 } } + + it { is_expected.to be false } + end + + context "and the key is an integer" do + before { calculator.preferred_tiers = { 20 => 20 } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20") => BigDecimal("20") }) + end + end + + context "and the key is a float" do + before { calculator.preferred_tiers = { 20.5 => 20.5 } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20.5") => BigDecimal("20.5") }) + end + end + + context "and the key is a string number" do + before { calculator.preferred_tiers = { "20" => 20 } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20") => BigDecimal("20") }) + end + end + + context "and the key is a numeric string with spaces" do + before { calculator.preferred_tiers = { " 20 " => 20 } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20") => BigDecimal("20") }) + end + end + + context "and the key is a string number with decimals" do + before { calculator.preferred_tiers = { "20.5" => "20.5" } } + + it "converts successfully" do + expect(subject).to be true + expect(calculator.preferred_tiers).to eq({ BigDecimal("20.5") => BigDecimal("20.5") }) + end + end + end + end + + describe "#compute" do + let(:order) do + create( + :order_with_line_items, + line_items_count: line_item_count, + line_items_price: price + ) + end + let(:price) { 10 } + let(:preferred_currency) { "USD" } + + before do + calculator.preferred_base_percent = 10 + calculator.preferred_tiers = { + 20 => 15, + 30 => 20 + } + calculator.preferred_currency = preferred_currency + end + + context "with a line item" do + subject { calculator.compute(line_item) } + + let(:line_item) { order.line_items.first } + + context "for multiple line items" do + context "when amount falls within the first tier" do + let(:line_item_count) { 1 } + + it { is_expected.to eq 1.0 } + end + + context "when amount falls within the second tier" do + let(:line_item_count) { 2 } + + it { is_expected.to eq 1.5 } + end + + context "when amount falls within the third tier" do + let(:line_item_count) { 3 } + + it { is_expected.to eq 2.0 } + end + end + + context "for a single line item" do + let(:line_item_count) { 1 } + + context "when amount falls within the first tier" do + let(:price) { 10 } + + it { is_expected.to eq 1.0 } + end + + context "when amount falls within the second tier" do + let(:price) { 20 } + + it { is_expected.to eq 3.0 } + end + + context "when amount falls within the third tier" do + let(:price) { 30 } + + it { is_expected.to eq 6.0 } + end + end + + context "when the order's currency does not match the calculator" do + let(:preferred_currency) { "JPY" } + let(:line_item_count) { 1 } + let(:price) { 15 } + + it { is_expected.to eq 0 } + + it "rounds based on currency" do + allow(order).to receive_messages currency: "JPY" + expect(subject).to eq(2) + end + end + end + + context "with a shipment" do + subject { calculator.compute(shipment) } + + let(:shipment) { Spree::Shipment.new(order: order, amount: shipping_cost) } + let(:line_item_count) { 1 } + let(:shipping_cost) { 10 } + + context "for multiple line items" do + context "when amount falls within the first tier" do + let(:line_item_count) { 1 } + + it { is_expected.to eq 1.0 } + end + + context "when amount falls within the second tier" do + let(:line_item_count) { 2 } + + it { is_expected.to eq 1.5 } + end + + context "when amount falls within the third tier" do + let(:line_item_count) { 3 } + + it { is_expected.to eq 2.0 } + end + end + + context "for a single line item" do + let(:line_item_count) { 1 } + + context "when amount falls within the first tier" do + let(:price) { 10 } + + it { is_expected.to eq 1.0 } + end + + context "when amount falls within the second tier" do + let(:price) { 20 } + let(:shipping_cost) { 20 } + + it { is_expected.to eq 3.0 } + end + + context "when amount falls within the third tier" do + let(:price) { 30 } + let(:shipping_cost) { 30 } + + it { is_expected.to eq 6.0 } + end + end + + context "when the order's currency does not match the calculator" do + let(:preferred_currency) { "CAD" } + + it { is_expected.to eq 0 } + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/condition_product.rb b/promotions/spec/models/solidus_promotions/condition_product.rb new file mode 100644 index 00000000000..c7791db6be6 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/condition_product.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::ConditionProduct do + it { is_expected.to belong_to(:product).optional } + it { is_expected.to belong_to(:condition).optional } +end diff --git a/promotions/spec/models/solidus_promotions/condition_spec.rb b/promotions/spec/models/solidus_promotions/condition_spec.rb new file mode 100644 index 00000000000..04135d7ab42 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/condition_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Condition do + it { is_expected.to belong_to(:benefit).optional } + let(:bad_test_condition_class) { Class.new(SolidusPromotions::Condition) } + let(:test_condition_class) do + Class.new(SolidusPromotions::Condition) do + def self.model_name + ActiveModel::Name.new(self, nil, "test_condition") + end + + def eligible?(_promotable, _options = {}) + true + end + end + end + + let(:benefit) { create(:solidus_promotion, :with_adjustable_benefit).benefits.first } + + describe "preferences" do + subject { described_class.new.preferences } + + it { is_expected.to be_a(Hash) } + end + + describe "#preload_relations" do + let(:condition) { described_class.new } + subject { condition.preload_relations } + + it { is_expected.to be_empty } + end + + it "forces developer to implement eligible? method" do + expect { bad_test_condition_class.new.eligible?("promotable") }.to raise_error NotImplementedError + expect { test_condition_class.new.eligible?("promotable") }.not_to raise_error + end + + it "validates unique conditions for a promotion benefit" do + # Because of Rails' STI, we can't use the anonymous class here + promotion = create(:solidus_promotion, :with_adjustable_benefit) + promotion_benefit = promotion.benefits.first + condition_one = SolidusPromotions::Conditions::FirstOrder.new(benefit: benefit) + condition_one.benefit_id = promotion_benefit.id + condition_one.save! + + condition_two = SolidusPromotions::Conditions::FirstOrder.new(benefit: benefit) + condition_two.benefit_id = promotion_benefit.id + expect(condition_two).not_to be_valid + expect(condition_two.errors.full_messages).to include("Benefit already contains this condition type") + end + + it "generates its own partial path" do + condition = test_condition_class.new + expect(condition.to_partial_path).to eq "solidus_promotions/admin/condition_fields/test_condition" + end +end diff --git a/promotions/spec/models/solidus_promotions/condition_store_spec.rb b/promotions/spec/models/solidus_promotions/condition_store_spec.rb new file mode 100644 index 00000000000..61bddb0bd15 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/condition_store_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::ConditionStore do + it { is_expected.to belong_to(:store).optional } + it { is_expected.to belong_to(:condition).optional } +end diff --git a/promotions/spec/models/solidus_promotions/condition_taxon_spec.rb b/promotions/spec/models/solidus_promotions/condition_taxon_spec.rb new file mode 100644 index 00000000000..4b03f58ce0c --- /dev/null +++ b/promotions/spec/models/solidus_promotions/condition_taxon_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::ConditionTaxon do + it { is_expected.to belong_to(:taxon).optional } + it { is_expected.to belong_to(:condition).optional } +end diff --git a/promotions/spec/models/solidus_promotions/condition_user_spec.rb b/promotions/spec/models/solidus_promotions/condition_user_spec.rb new file mode 100644 index 00000000000..ffb0c34bb35 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/condition_user_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::ConditionUser do + it { is_expected.to belong_to(:user).optional } + it { is_expected.to belong_to(:condition).optional } +end diff --git a/promotions/spec/models/solidus_promotions/conditions/discounted_item_total_spec.rb b/promotions/spec/models/solidus_promotions/conditions/discounted_item_total_spec.rb new file mode 100644 index 00000000000..7746a52b9ce --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/discounted_item_total_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::DiscountedItemTotal, type: :model do + let(:condition) do + described_class.new( + preferred_amount: preferred_amount, + preferred_operator: preferred_operator + ) + end + let(:order) { instance_double("Spree::Order", discountable_item_total: item_total, currency: order_currency) } + let(:preferred_amount) { 50 } + let(:order_currency) { "USD" } + let(:preferred_operator) { "gt" } + + context "preferred operator set to gt" do + context "item total is greater than preferred amount" do + let(:item_total) { 51 } + + it "is eligible when item total is greater than preferred amount" do + expect(condition).to be_eligible(order) + end + + context "when the order is a different currency" do + let(:order_currency) { "CAD" } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + end + + context "when item total is equal to preferred amount" do + let(:item_total) { 50 } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + + it "set an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "This coupon code can't be applied to orders less than or equal to $50.00." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :item_total_less_than_or_equal + end + end + + context "when item total is lower than preferred amount" do + let(:item_total) { 49 } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + + it "set an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "This coupon code can't be applied to orders less than or equal to $50.00." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :item_total_less_than_or_equal + end + end + end + + context "preferred operator set to gte" do + let(:preferred_operator) { "gte" } + + context "total is greater than preferred amount" do + let(:item_total) { 51 } + + it "is eligible when item total is greater than preferred amount" do + expect(condition).to be_eligible(order) + end + + context "when the order is a different currency" do + let(:order_currency) { "CAD" } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + end + + context "item total is equal to preferred amount" do + let(:item_total) { 50 } + + it "is eligible" do + expect(condition).to be_eligible(order) + end + + context "when the order is a different currency" do + let(:order_currency) { "CAD" } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + end + + context "when item total is lower than preferred amount" do + let(:item_total) { 49 } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + + it "set an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "This coupon code can't be applied to orders less than $50.00." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :item_total_less_than + end + end + end + + describe "#to_partial_path" do + it "uses the item total partial path" do + expect(condition.to_partial_path).to eq "solidus_promotions/admin/condition_fields/item_total" + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/first_order_spec.rb b/promotions/spec/models/solidus_promotions/conditions/first_order_spec.rb new file mode 100644 index 00000000000..a539eeb2303 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/first_order_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::FirstOrder, type: :model do + let(:condition) { described_class.new } + let(:order) { mock_model(Spree::Order, user: nil, email: nil) } + let(:user) { mock_model(Spree::LegacyUser) } + + describe "#level" do + it "is order" do + expect(condition.level).to eq(:order) + end + end + + describe ".to_partial_path" do + subject { condition.to_partial_path } + + it { is_expected.to eq("solidus_promotions/admin/condition_fields/first_order") } + end + + context "without a user or email" do + it { expect(condition).to be_eligible(order) } + + it "does not set an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to be_nil + end + end + + context "first order" do + context "for a signed user" do + context "with no completed orders" do + before do + allow(user).to receive_message_chain(:orders, complete: []) + end + + specify do + allow(order).to receive_messages(user: user) + expect(condition).to be_eligible(order) + end + + it "is eligible when user passed in payload data" do + expect(condition).to be_eligible(order, user: user) + end + end + + context "with completed orders" do + before do + allow(order).to receive_messages(user: user) + end + + it "is eligible when checked against first completed order" do + allow(user).to receive_message_chain(:orders, complete: [order]) + expect(condition).to be_eligible(order) + end + + context "with another order" do + before { allow(user).to receive_message_chain(:orders, complete: [mock_model(Spree::Order)]) } + + it { expect(condition).not_to be_eligible(order) } + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "This coupon code can only be applied to your first order." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :not_first_order + end + end + end + end + + context "for a guest user" do + let(:email) { "user@solidus.io" } + + before { allow(order).to receive_messages email: "user@solidus.io" } + + context "with no other orders" do + it { expect(condition).to be_eligible(order) } + end + + context "with another order" do + before { allow(condition).to receive_messages(orders_by_email: [mock_model(Spree::Order)]) } + + it { expect(condition).not_to be_eligible(order) } + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "This coupon code can only be applied to your first order." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :not_first_order + end + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/first_repeat_purchase_since_spec.rb b/promotions/spec/models/solidus_promotions/conditions/first_repeat_purchase_since_spec.rb new file mode 100644 index 00000000000..d42f85641ba --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/first_repeat_purchase_since_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::FirstRepeatPurchaseSince do + describe "#applicable?" do + subject { described_class.new.applicable?(promotable) } + + context "when the promotable is an order" do + let(:promotable) { Spree::Order.new } + + it { is_expected.to be true } + end + + context "when the promotable is not a order" do + let(:promotable) { Spree::LineItem.new } + + it { is_expected.to be false } + end + end + + describe "eligible?" do + subject { instance.eligible?(order) } + + let(:instance) { described_class.new } + + before do + instance.preferred_days_ago = 365 + end + + context "when the order does not have a user" do + let(:order) { Spree::Order.new } + + it { is_expected.to be false } + end + + context "when the order has a user" do + let(:order) { create :order } + let(:user) { order.user } + + context "when the user has completed orders" do + let(:order_completion_date_1) { 1.day.ago } + let(:order_completion_date_2) { 1.day.ago } + + before do + old_order_1 = create :completed_order_with_totals, user: user + old_order_1.update(completed_at: order_completion_date_1) + + old_order_2 = create :completed_order_with_totals, user: user + old_order_2.update(completed_at: order_completion_date_2) + end + + context "the last completed order was greater than the preferred days ago" do + let(:order_completion_date_1) { 14.months.ago } + let(:order_completion_date_2) { 13.months.ago } + + it { is_expected.to be true } + end + + context "the last completed order was less than the preferred days ago" do + let(:order_completion_date_1) { 14.months.ago } + let(:order_completion_date_2) { 11.months.ago } + + it { is_expected.to be false } + end + end + + context "when the user has no completed orders " do + it { is_expected.to be false } + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/item_total_spec.rb b/promotions/spec/models/solidus_promotions/conditions/item_total_spec.rb new file mode 100644 index 00000000000..ab4c3a6217e --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/item_total_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::ItemTotal, type: :model do + let(:condition) do + described_class.new( + preferred_amount: preferred_amount, + preferred_operator: preferred_operator + ) + end + let(:order) { instance_double("Spree::Order", item_total: item_total, currency: order_currency) } + let(:preferred_amount) { 50 } + let(:order_currency) { "USD" } + + context "preferred operator set to gt" do + let(:preferred_operator) { "gt" } + + context "item total is greater than preferred amount" do + let(:item_total) { 51 } + + it "is eligible when item total is greater than preferred amount" do + expect(condition).to be_eligible(order) + end + + context "when the order is a different currency" do + let(:order_currency) { "CAD" } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + end + + context "when item total is equal to preferred amount" do + let(:item_total) { 50 } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + + it "set an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "This coupon code can't be applied to orders less than or equal to $50.00." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :item_total_less_than_or_equal + end + end + + context "when item total is lower than preferred amount" do + let(:item_total) { 49 } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + + it "set an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "This coupon code can't be applied to orders less than or equal to $50.00." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :item_total_less_than_or_equal + end + end + end + + context "preferred operator set to gte" do + let(:preferred_operator) { "gte" } + + context "total is greater than preferred amount" do + let(:item_total) { 51 } + + it "is eligible when item total is greater than preferred amount" do + expect(condition).to be_eligible(order) + end + + context "when the order is a different currency" do + let(:order_currency) { "CAD" } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + end + + context "item total is equal to preferred amount" do + let(:item_total) { 50 } + + it "is eligible" do + expect(condition).to be_eligible(order) + end + + context "when the order is a different currency" do + let(:order_currency) { "CAD" } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + end + + context "when item total is lower than preferred amount" do + let(:item_total) { 49 } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + + it "set an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "This coupon code can't be applied to orders less than $50.00." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :item_total_less_than + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/line_item_product_spec.rb b/promotions/spec/models/solidus_promotions/conditions/line_item_product_spec.rb new file mode 100644 index 00000000000..44df3b51c4a --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/line_item_product_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::LineItemProduct, type: :model do + let(:condition) { described_class.new(condition_options) } + let(:condition_options) { {} } + + describe "#level" do + it "is line_item" do + expect(condition.level).to eq(:line_item) + end + end + + describe "#eligible?(line_item)" do + subject { condition.eligible?(line_item, {}) } + + let(:condition_line_item) { Spree::LineItem.new(product: condition_product) } + let(:other_line_item) { Spree::LineItem.new(product: other_product) } + + let(:condition_options) { super().merge(products: [condition_product]) } + let(:condition_product) { mock_model(Spree::Product) } + let(:other_product) { mock_model(Spree::Product) } + + it "is eligible if there are no products" do + expect(condition).to be_eligible(condition_line_item) + end + + context "for product in condition" do + let(:line_item) { condition_line_item } + + it { is_expected.to be_truthy } + + it "has no error message" do + subject + expect(condition.eligibility_errors.full_messages).to be_empty + end + end + + context "for product not in condition" do + let(:line_item) { other_line_item } + + it { is_expected.to be_falsey } + + it "has the right error message" do + subject + expect(condition.eligibility_errors.full_messages.first).to eq( + "You need to add an applicable product before applying this coupon code." + ) + end + end + + context "if match policy is inverse" do + let(:condition_options) { super().merge(preferred_match_policy: "exclude") } + + context "for product in condition" do + let(:line_item) { condition_line_item } + + it { is_expected.to be_falsey } + + it "has the right error message" do + subject + expect(condition.eligibility_errors.full_messages.first).to eq( + "Your cart contains a product that prevents this coupon code from being applied." + ) + end + end + + context "for product not in condition" do + let(:line_item) { other_line_item } + + it { is_expected.to be_truthy } + + it "has no error message" do + subject + expect(condition.eligibility_errors.full_messages).to be_empty + end + end + end + end + + describe "#preload_relations" do + subject { condition.preload_relations } + it { is_expected.to eq([:products]) } + end + + describe "#product_ids_string" do + it "returns a string of product ids" do + condition.products = [create(:product), create(:product)] + expect(condition.product_ids_string).to eq("#{condition.products[0].id},#{condition.products[1].id}") + end + end + + describe "#product_ids_string=" do + it "sets products based on a string of product ids" do + product_one = create(:product) + product_two = create(:product) + condition.product_ids_string = "#{product_one.id},#{product_two.id}" + expect(condition.products).to eq([product_one, product_two]) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/line_item_taxon_spec.rb b/promotions/spec/models/solidus_promotions/conditions/line_item_taxon_spec.rb new file mode 100644 index 00000000000..34c2515f497 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/line_item_taxon_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::LineItemTaxon, type: :model do + let(:taxon) { create :taxon, name: "first" } + let(:taxon2) { create :taxon, name: "second" } + let(:order) { create :order_with_line_items } + let(:product) { order.products.first } + let(:promotion) { create :solidus_promotion, :with_adjustable_benefit } + let(:benefit) { promotion.benefits.first } + + let(:condition) do + described_class.create!(benefit: benefit) + end + + it { is_expected.to be_updateable } + + describe "#preload_relations" do + subject { condition.preload_relations } + it { is_expected.to eq([:taxons]) } + end + + describe "#eligible?" do + let(:line_item) { order.line_items.first! } + let(:order) { create :order_with_line_items } + let(:taxon) { create :taxon, name: "first" } + + context "with an invalid match policy" do + before do + condition.preferred_match_policy = "invalid" + condition.save!(validate: false) + line_item.product.taxons << taxon + condition.taxons << taxon + end + + it "raises" do + expect { + condition.eligible?(line_item) + }.to raise_error('unexpected match policy: "invalid"') + end + end + + context "when a product has a taxon of a taxon condition" do + before do + product.taxons << taxon + condition.taxons << taxon + condition.save! + end + + it "is eligible" do + expect(condition).to be_eligible(line_item) + end + end + + context "when a product has a taxon child of a taxon condition" do + before do + taxon.children << taxon2 + product.taxons << taxon2 + condition.taxons << taxon + condition.save! + end + + it "is eligible" do + expect(condition).to be_eligible(line_item) + end + + context "with 'exclude' match policy" do + before do + condition.update(preferred_match_policy: :exclude) + end + + it "is not eligible" do + expect(condition).not_to be_eligible(line_item) + end + end + end + + context "when a product does not have taxon or child taxon of a taxon condition" do + before do + product.taxons << taxon2 + condition.taxons << taxon + condition.save! + end + + it "is not eligible" do + expect(condition).not_to be_eligible(line_item) + end + + context "with 'exclude' match policy" do + before do + condition.update(preferred_match_policy: :exclude) + end + + it "is not eligible" do + expect(condition).to be_eligible(line_item) + end + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/minimum_quantity_spec.rb b/promotions/spec/models/solidus_promotions/conditions/minimum_quantity_spec.rb new file mode 100644 index 00000000000..d4655125dbf --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/minimum_quantity_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +RSpec.describe SolidusPromotions::Conditions::MinimumQuantity do + let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.new } + subject(:quantity_condition) { described_class.new(preferred_minimum_quantity: 2, benefit: benefit) } + + describe "#valid?" do + before { benefit.conditions << quantity_condition } + + it { is_expected.to be_valid } + + context "when minimum quantity is zero" do + subject(:quantity_condition) { described_class.new(preferred_minimum_quantity: 0) } + + it { is_expected.not_to be_valid } + end + end + + describe "#applicable?" do + subject { quantity_condition.applicable?(promotable) } + + context "when promotable is an order" do + let(:promotable) { Spree::Order.new } + + it { is_expected.to be true } + end + + context "when promotable is a line item" do + let(:promotable) { Spree::LineItem.new } + + it { is_expected.to be false } + end + end + + describe "#eligible?" do + subject { quantity_condition.eligible?(order) } + + let(:order) do + create( + :order_with_line_items, + line_items_count: line_items.length, + line_items_attributes: line_items + ) + end + let(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.new } + + before { benefit.conditions << quantity_condition } + + context "when only the quantity condition is applied" do + context "when the quantity is less than the minimum" do + let(:line_items) { [{ quantity: 1 }] } + + it { is_expected.to be false } + end + + context "when the quantity is equal to the minimum" do + let(:line_items) { [{ quantity: 2 }] } + + it { is_expected.to be true } + end + + context "when the quantity is greater than the minimum" do + let(:line_items) { [{ quantity: 4 }] } + + it { is_expected.to be true } + end + end + + context "when another condition limits the applicable items" do + let(:carry_on) { create(:variant) } + let(:other_carry_on) { create(:variant) } + let(:everywhere_bag) { create(:product).master } + + let(:product_condition) { + SolidusPromotions::Conditions::LineItemProduct.new( + products: [carry_on.product, other_carry_on.product], + preferred_match_policy: "any" + ) + } + + before { benefit.conditions << product_condition } + + context "when the applicable quantity is less than the minimum" do + let(:line_items) do + [ + { variant: carry_on, quantity: 1 }, + { variant: everywhere_bag, quantity: 1 } + ] + end + + it { is_expected.to be false } + end + + context "when the applicable quantity is greater than the minimum" do + let(:line_items) do + [ + { variant: carry_on, quantity: 1 }, + { variant: other_carry_on, quantity: 1 }, + { variant: everywhere_bag, quantity: 1 } + ] + end + + it { is_expected.to be true } + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/nth_order_spec.rb b/promotions/spec/models/solidus_promotions/conditions/nth_order_spec.rb new file mode 100644 index 00000000000..7a82a4afc63 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/nth_order_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::NthOrder do + describe "#applicable?" do + subject { described_class.new.applicable?(promotable) } + + context "when the promotable is an order" do + let(:promotable) { Spree::Order.new } + + it { is_expected.to be true } + end + + context "when the promotable is not a order" do + let(:promotable) { "not an order" } + + it { is_expected.to be false } + end + end + + describe "eligible?" do + subject { instance.eligible?(order) } + + let(:instance) { described_class.new } + + before do + instance.preferred_nth_order = 2 + end + + context "when the order does not have a user" do + let(:order) { Spree::Order.new } + + it { is_expected.to be false } + end + + context "when the order has a user" do + let(:order) { create :order } + let(:user) { order.user } + + context "when the user has completed orders" do + before do + old_order = create :completed_order_with_totals, user: user + old_order.update(completed_at: 1.day.ago) + end + + context "when this order will be the 'nth' order" do + it { is_expected.to be true } + end + + context "when this order is completed and is still the 'nth' order" do + before do + order.update(completed_at: Time.current) + end + + it { is_expected.to be true } + end + + context "when this order will not be the 'nth' order" do + before do + instance.preferred_nth_order = 100 + end + + it { is_expected.to be false } + end + end + + context "when the user has no completed orders " do + it { is_expected.to be false } + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/one_use_per_user_spec.rb b/promotions/spec/models/solidus_promotions/conditions/one_use_per_user_spec.rb new file mode 100644 index 00000000000..20fe909ff4a --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/one_use_per_user_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::OneUsePerUser, type: :model do + let(:condition) { described_class.new } + + describe "#eligible?(order)" do + subject { condition.eligible?(order) } + + let(:order) { instance_double("Spree::Order", user: user) } + let(:user) { instance_double("Spree::LegacyUser") } + let(:promotion) { stub_model SolidusPromotions::Promotion, used_by?: used_by } + let(:used_by) { false } + + before { condition.promotion = promotion } + + context "when the order is assigned to a user" do + context "when the user has used this promotion before" do + let(:used_by) { true } + + it { is_expected.to be false } + + it "sets an error message" do + subject + expect(condition.eligibility_errors.full_messages.first) + .to eq "This coupon code can only be used once per user." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :limit_once_per_user + end + end + + context "when the user has not used this promotion before" do + it { is_expected.to be true } + end + end + + context "when the order is not assigned to a user" do + let(:user) { nil } + + it { is_expected.to be false } + + it "sets an error message" do + subject + expect(condition.eligibility_errors.full_messages.first) + .to eq "You need to login before applying this coupon code." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :no_user_specified + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/option_value_spec.rb b/promotions/spec/models/solidus_promotions/conditions/option_value_spec.rb new file mode 100644 index 00000000000..29d00b7b34f --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/option_value_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::OptionValue do + let(:condition) { described_class.new } + + describe "#preferred_eligible_values" do + subject { condition.preferred_eligible_values } + + it "assigns a nicely formatted hash" do + condition.preferred_eligible_values = { "5" => "1,2", "6" => "1" } + expect(subject).to eq({ 5 => [1, 2], 6 => [1] }) + end + end + + describe "#eligible?(order)" do + subject { condition.eligible?(promotable) } + + let(:variant) { create :variant } + let(:line_item) { create :line_item, variant: variant } + let(:promotable) { line_item.order } + + context "when there are any applicable line items" do + before do + condition.preferred_eligible_values = { line_item.product.id => [ + line_item.variant.option_values.pick(:id) + ] } + end + + it { is_expected.to be true } + end + + context "when there are no applicable line items" do + before do + condition.preferred_eligible_values = { 99 => [99] } + end + + it { is_expected.to be false } + end + end + + describe "#eligible?(line_item)" do + subject { condition.eligible?(promotable) } + + let(:variant) { create :variant } + let(:line_item) { create :line_item, variant: variant } + let(:promotable) { line_item } + + context "when there are any applicable line items" do + before do + condition.preferred_eligible_values = { line_item.product.id => [ + line_item.variant.option_values.pick(:id) + ] } + end + + it { is_expected.to be true } + end + + context "when there are no applicable line items" do + before do + condition.preferred_eligible_values = { 99 => [99] } + end + + it { is_expected.to be false } + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/product_spec.rb b/promotions/spec/models/solidus_promotions/conditions/product_spec.rb new file mode 100644 index 00000000000..1b12face5fb --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/product_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::Product, type: :model do + let(:condition_options) { {} } + let(:condition) { described_class.new(condition_options) } + + it { is_expected.to have_many(:products) } + + describe "#level" do + it "is order" do + expect(condition.level).to eq(:order) + end + end + + describe "#applicable?" do + let(:promotable) { Spree::Order.new } + + subject { condition.applicable?(promotable) } + + it { is_expected.to be true } + + context "with a line item" do + let(:promotable) { Spree::LineItem.new } + + it { is_expected.to be true } + + context "with line item applicable set to false" do + let(:condition_options) { { preferred_line_item_applicable: false } } + + it { is_expected.to be false } + end + end + + context "with a shipment" do + let(:promotable) { Spree::Shipment.new } + + it { is_expected.to be false } + end + end + + describe "#eligible?(order)" do + let(:order) { create(:order) } + let(:product_one) { create(:product) } + let(:product_two) { create(:product) } + let(:product_three) { create(:product) } + let(:order_products) { [] } + let(:eligible_products) { [] } + + before do + order_products.each do |product| + order.contents.add(product.master, 1) + end + + condition.products = eligible_products + end + + it "is eligible if there are no products" do + expect(condition).to be_eligible(order) + end + + context "with 'any' match policy" do + let(:condition_options) { super().merge(preferred_match_policy: "any") } + let(:order_products) { [product_one, product_two] } + let(:eligible_products) { [product_two, product_three] } + + it "is eligible if any of the products is in eligible products" do + expect(condition).to be_eligible(order) + end + + context "when none of the products are eligible products" do + let(:order_products) { [product_one] } + + it { expect(condition).not_to be_eligible(order) } + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "You need to add an applicable product before applying this coupon code." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :no_applicable_products + end + end + end + + context "with 'all' match policy" do + let(:condition_options) { super().merge(preferred_match_policy: "all") } + let(:order_products) { [product_three, product_two, product_one] } + let(:eligible_products) { [product_two, product_three] } + + it "is eligible if all of the eligible products are ordered" do + expect(condition).to be_eligible(order) + end + + context "when any of the eligible products is not ordered" do + let(:order_products) { [product_one, product_two] } + let(:eligible_products) { [product_one, product_two, product_three] } + + it { expect(condition).not_to be_eligible(order) } + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first).to eq( + "This coupon code can't be applied because you don't have all of the necessary products in your cart." + ) + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :missing_product + end + end + end + + context "with 'none' match policy" do + let(:condition_options) { super().merge(preferred_match_policy: "none") } + let(:order_products) { [product_one] } + let(:eligible_products) { [product_two, product_three] } + + it "is eligible if none of the order's products are in eligible products" do + expect(condition).to be_eligible(order) + end + + context "when any of the order's products are in eligible products" do + let(:order_products) { [product_one, product_two] } + let(:eligible_products) { [product_two, product_three] } + + it { expect(condition).not_to be_eligible(order) } + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "Your cart contains a product that prevents this coupon code from being applied." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :has_excluded_product + end + end + end + + context "with 'only' match policy" do + let(:condition_options) { super().merge(preferred_match_policy: "only") } + let(:order_products) { [product_one] } + let(:eligible_products) { [product_one] } + + it "is eligible if all of the order's products are in eligible products" do + expect(condition).to be_eligible(order) + end + + context "if none of the order's products are in eligible products" do + let(:eligible_products) { [product_two, product_three] } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + + context "when any of the order's products are in eligible products" do + let(:order_products) { [product_one, product_two] } + let(:eligible_products) { [product_two, product_three] } + + it { expect(condition).not_to be_eligible(order) } + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "Your cart contains a product that prevents this coupon code from being applied." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :has_excluded_product + end + end + end + end + + describe "#eligible?(line_item)" do + subject { condition.eligible?(line_item) } + + let(:condition_line_item) { Spree::LineItem.new(product: condition_product) } + let(:other_line_item) { Spree::LineItem.new(product: other_product) } + + let(:condition_options) { super().merge(products: [condition_product]) } + let(:condition_product) { mock_model(Spree::Product) } + let(:other_product) { mock_model(Spree::Product) } + + it "is eligible if there are no products" do + expect(condition).to be_eligible(condition_line_item) + end + + context "for product in condition" do + let(:line_item) { condition_line_item } + + it { is_expected.to be_truthy } + end + + context "for product not in condition" do + let(:line_item) { other_line_item } + + it { is_expected.to be_falsey } + end + end + + describe "#product_ids_string" do + it "returns a string of product ids" do + condition.products = [create(:product), create(:product)] + expect(condition.product_ids_string).to eq("#{condition.products[0].id},#{condition.products[1].id}") + end + end + + describe "#preload_relations" do + subject { condition.preload_relations } + it { is_expected.to eq([:products]) } + end + + describe "#product_ids_string=" do + it "sets products based on a string of product ids" do + product_one = create(:product) + product_two = create(:product) + condition.product_ids_string = "#{product_one.id},#{product_two.id}" + expect(condition.products).to eq([product_one, product_two]) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/shipping_method_spec.rb b/promotions/spec/models/solidus_promotions/conditions/shipping_method_spec.rb new file mode 100644 index 00000000000..32d3df363c0 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/shipping_method_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::ShippingMethod, type: :model do + let(:condition) { described_class.new } + let!(:promotion) { create(:solidus_promotion, benefits: [benefit]) } + let(:benefit) { SolidusPromotions::Benefits::AdjustShipment.new(calculator: SolidusPromotions::Calculators::FlatRate.new) } + let(:ups_ground) { create(:shipping_method) } + let(:dhl_saver) { create(:shipping_method) } + + it { is_expected.to respond_to(:preferred_shipping_method_ids) } + + describe "preferred_shipping_methods_ids=" do + subject { condition.preferred_shipping_method_ids = [ups_ground.id] } + + let(:condition) { benefit.conditions.build(type: described_class.to_s) } + + it "creates a valid condition with a shipping method" do + subject + expect(condition).to be_valid + expect(condition.preferred_shipping_method_ids).to include(ups_ground.id) + end + end + + describe "#eligible?" do + subject { condition.eligible?(promotable) } + + let(:condition) { benefit.conditions.build(type: described_class.to_s, preferred_shipping_method_ids: [ups_ground.id]) } + + context "with a shipment" do + context "when the shipment has the right shipping method selected" do + let(:promotable) { create(:shipment, shipping_method: ups_ground) } + + it { is_expected.to be true } + end + + context "when the shipment does not have the right shipping method selected" do + let(:promotable) { create(:shipment, shipping_method: dhl_saver) } + + it { is_expected.to be false } + end + + context "when the shipment has no shipping method selected" do + let(:promotable) { create(:shipment, shipping_method: nil) } + + it { is_expected.to be false } + end + end + + context "with a shipping rate" do + context "when the shipping rate has the right shipping method selected" do + let(:promotable) { create(:shipping_rate, shipping_method: ups_ground) } + + it { is_expected.to be true } + end + + context "when the shipping rate does not have the right shipping method selected" do + let(:promotable) { create(:shipping_rate, shipping_method: dhl_saver) } + + it { is_expected.to be false } + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/store_spec.rb b/promotions/spec/models/solidus_promotions/conditions/store_spec.rb new file mode 100644 index 00000000000..9b69c5a4442 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/store_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::Store, type: :model do + let(:condition) { described_class.new } + + it { is_expected.to have_many(:stores) } + + it { is_expected.to be_updateable } + + describe "store_ids=" do + subject { condition.store_ids = [store.id] } + + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let(:promotion_benefit) { promotion.benefits.first } + let!(:unimportant_store) { create(:store) } + let!(:store) { create(:store) } + let(:condition) { promotion_benefit.conditions.build(type: described_class.to_s) } + + it "creates a valid condition with a store" do + subject + expect(condition).to be_valid + expect(condition.stores).to include(store) + end + end + + describe "#preload_relations" do + subject { condition.preload_relations } + it { is_expected.to eq([:stores]) } + end + + describe "#eligible?(order)" do + let(:order) { Spree::Order.new } + + it "is eligible if no stores are provided" do + expect(condition).to be_eligible(order) + end + + it "is eligible if stores include the order's store" do + default_store = Spree::Store.new(name: "Default") + other_store = Spree::Store.new(name: "Other") + + condition.stores = [default_store, other_store] + order.store = default_store + + expect(condition).to be_eligible(order) + end + + it "is not eligible if order is placed in a different store" do + default_store = Spree::Store.new(name: "Default") + other_store = Spree::Store.new(name: "Other") + + condition.stores = [other_store] + order.store = default_store + + expect(condition).not_to be_eligible(order) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/taxon_spec.rb b/promotions/spec/models/solidus_promotions/conditions/taxon_spec.rb new file mode 100644 index 00000000000..18b40c7a589 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/taxon_spec.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::Taxon, type: :model do + let(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let(:promotion_benefit) { promotion.benefits.first } + let(:condition) do + described_class.create!(benefit: promotion_benefit) + end + let(:product) { order.products.first } + let(:order) { create :order_with_line_items } + let(:taxon_one) { create :taxon, name: "first" } + let(:taxon_two) { create :taxon, name: "second" } + + it { is_expected.to have_many(:taxons) } + + it { is_expected.to be_updateable } + + describe "taxon_ids_string=" do + subject { condition.assign_attributes("taxon_ids_string" => taxon_two.id.to_s) } + + let(:condition) { promotion_benefit.conditions.build(type: described_class.to_s) } + + it "creates a valid condition with a taxon" do + subject + expect(condition).to be_valid + condition.save! + expect(condition.reload.taxons).to include(taxon_two) + end + end + + describe "#preload_relations" do + subject { condition.preload_relations } + it { is_expected.to eq([:taxons]) } + end + + describe "#eligible?(order)" do + context "with any match policy" do + before do + condition.update!(preferred_match_policy: "any") + end + + it "is eligible if order does have any prefered taxon" do + product.taxons << taxon_one + condition.taxons << taxon_one + expect(condition).to be_eligible(order) + end + + context "when order does not have any prefered taxon" do + before { condition.taxons << taxon_two } + + it { expect(condition).not_to be_eligible(order) } + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first).to eq( + "You need to add a product from an applicable category before applying this coupon code." + ) + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :no_matching_taxons + end + end + + context "when a product has a taxon child of a taxon condition" do + before do + taxon_one.children << taxon_two + product.taxons << taxon_two + condition.taxons << taxon_one + end + + it { expect(condition).to be_eligible(order) } + end + end + + context "with all match policy" do + before do + condition.update!(preferred_match_policy: "all") + end + + it "is eligible order has all prefered taxons" do + product.taxons << taxon_two + order.products.last.taxons << taxon_one + + condition.taxons = [taxon_one, taxon_two] + + expect(condition).to be_eligible(order) + end + + context "when order does not have all prefered taxons" do + before { condition.taxons << taxon_one } + + it { expect(condition).not_to be_eligible(order) } + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first).to eq( + "You need to add a product from all applicable categories before applying this coupon code." + ) + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :missing_taxon + end + end + + context "when a product has a taxon child of a taxon condition" do + let(:taxon_three) { create :taxon } + + before do + taxon_one.children << taxon_two + taxon_one.save! + taxon_one.reload + + product.taxons = [taxon_two, taxon_three] + condition.taxons = [taxon_one, taxon_three] + end + + it { expect(condition).to be_eligible(order) } + end + end + + context "with none match policy" do + before do + condition.preferred_match_policy = "none" + end + + context "none of the order's products are in listed taxon" do + before { condition.taxons << taxon_two } + + it { expect(condition).to be_eligible(order) } + end + + context "one of the order's products is in a listed taxon" do + before do + order.products.first.taxons << taxon_one + condition.taxons << taxon_one + end + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first).to eq( + "Your cart contains a product from an excluded category that prevents this coupon code from being applied." + ) + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :has_excluded_taxon + end + end + end + + context "with an invalid match policy" do + before do + order.products.first.taxons << taxon_one + condition.taxons << taxon_one + condition.preferred_match_policy = "invalid" + condition.save!(validate: false) + end + + it "raises" do + expect { + condition.eligible?(order) + }.to raise_error('unexpected match policy: "invalid"') + end + end + end + + describe "#eligible?(line_item)" do + let(:line_item) { order.line_items.first! } + let(:order) { create :order_with_line_items } + let(:taxon) { create :taxon, name: "first" } + + context "with an invalid match policy" do + before do + condition.preferred_match_policy = "invalid" + condition.save!(validate: false) + line_item.product.taxons << taxon + condition.taxons << taxon + end + + it "raises" do + expect { + condition.eligible?(line_item) + }.to raise_error('unexpected match policy: "invalid"') + end + end + + context "when a product has a taxon of a taxon condition" do + before do + product.taxons << taxon + condition.taxons << taxon + condition.save! + end + + it "is eligible" do + expect(condition).to be_eligible(line_item) + end + end + + context "when a product has a taxon child of a taxon condition" do + before do + taxon.children << taxon_two + product.taxons << taxon_two + condition.taxons << taxon + condition.save! + end + + it "is eligible" do + expect(condition).to be_eligible(line_item) + end + end + + context "when a product does not have taxon or child taxon of a taxon condition" do + before do + product.taxons << taxon_two + condition.taxons << taxon + condition.save! + end + + it "is not eligible" do + expect(condition).not_to be_eligible(line_item) + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/user_logged_in_spec.rb b/promotions/spec/models/solidus_promotions/conditions/user_logged_in_spec.rb new file mode 100644 index 00000000000..7e4d5699da5 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/user_logged_in_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::UserLoggedIn, type: :model do + let(:condition) { described_class.new } + + describe "#eligible?(order)" do + let(:order) { Spree::Order.new } + + it "is eligible if order has an associated user" do + user = Spree::LegacyUser.new + allow(order).to receive_messages(user: user) + + expect(condition).to be_eligible(order) + end + + context "when user is not logged in" do + before { allow(order).to receive_messages(user: nil) } # better to be explicit here + + it { expect(condition).not_to be_eligible(order) } + + it "sets an error message" do + condition.eligible?(order) + expect(condition.eligibility_errors.full_messages.first) + .to eq "You need to login before applying this coupon code." + end + + it "sets an error code" do + condition.eligible?(order) + expect(condition.eligibility_errors.details[:base].first[:error_code]) + .to eq :no_user_specified + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/user_role_spec.rb b/promotions/spec/models/solidus_promotions/conditions/user_role_spec.rb new file mode 100644 index 00000000000..1b5f8db041b --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/user_role_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::UserRole, type: :model do + subject { condition } + + let(:condition) { described_class.new(preferred_role_ids: roles_for_condition.map(&:id)) } + let(:user) { create(:user, spree_roles: roles_for_user) } + let(:roles_for_condition) { [] } + let(:roles_for_user) { [] } + + shared_examples "eligibility" do + context "no roles on condition" do + let(:roles_for_user) { create_list(:role, 1) } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + + context "no roles on user" do + let(:roles_for_condition) { create_list(:role, 1) } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + + context "mismatched roles" do + let(:roles_for_user) { create_list(:role, 1) } + let(:roles_for_condition) { create_list(:role, 1) } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + + context "matching all roles" do + let(:roles_for_user) { create_list(:role, 2) } + let(:roles_for_condition) { roles_for_user } + + it "is eligible" do + expect(condition).to be_eligible(order) + end + end + end + + describe "#eligible?(order)" do + context "order with no user" do + let(:order) { Spree::Order.new } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + + context "order with user" do + let(:order) { Spree::Order.new(user: user) } + + context "with any match policy" do + before { condition.preferred_match_policy = "any" } + + include_examples "eligibility" + + context "one shared role" do + let(:shared_role) { create(:role) } + let(:roles_for_user) { [create(:role), shared_role] } + let(:roles_for_condition) { [create(:role), shared_role] } + + it "is eligible" do + expect(condition).to be_eligible(order) + end + end + end + + context "with all match policy" do + before { condition.preferred_match_policy = "all" } + + include_examples "eligibility" + + context "one shared role" do + let(:shared_role) { create(:role) } + let(:roles_for_user) { [create(:role), shared_role] } + let(:roles_for_condition) { [create(:role), shared_role] } + + it "is not eligible" do + expect(condition).not_to be_eligible(order) + end + end + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/conditions/user_spec.rb b/promotions/spec/models/solidus_promotions/conditions/user_spec.rb new file mode 100644 index 00000000000..9a94250c1b2 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/conditions/user_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Conditions::User, type: :model do + let(:condition) { described_class.new } + + it { is_expected.to have_many(:users) } + + it { is_expected.to be_updateable } + + describe "user_ids=" do + subject { condition.user_ids = [user.id] } + + let(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let(:benefit) { promotion.benefits.first } + let(:user) { create(:user) } + let(:condition) { described_class.new(users: [user], benefit: benefit) } + + it "creates a valid condition with a user" do + expect(condition).to be_valid + end + end + + describe "#preload_relations" do + subject { condition.preload_relations } + it { is_expected.to eq([:users]) } + end + + describe "#eligible?(order)" do + let(:order) { Spree::Order.new } + + it "is not eligible if users are not provided" do + expect(condition).not_to be_eligible(order) + end + + it "is eligible if users include user placing the order" do + user = mock_model(Spree::LegacyUser) + users = [user, mock_model(Spree::LegacyUser)] + allow(condition).to receive_messages(users: users) + allow(order).to receive_messages(user: user) + + expect(condition).to be_eligible(order) + end + + it "is not eligible if user placing the order is not listed" do + allow(order).to receive_messages(user: mock_model(Spree::LegacyUser)) + users = [mock_model(Spree::LegacyUser), mock_model(Spree::LegacyUser)] + allow(condition).to receive_messages(users: users) + + expect(condition).not_to be_eligible(order) + end + + # Regression test for https://github.com/spree/spree/issues/3885 + it "can assign to user_ids" do + user1 = Spree::LegacyUser.create!(email: "test1@example.com") + user2 = Spree::LegacyUser.create!(email: "test2@example.com") + condition.user_ids = "#{user1.id}, #{user2.id}" + end + end +end diff --git a/promotions/spec/models/solidus_promotions/distributed_amounts_handler_spec.rb b/promotions/spec/models/solidus_promotions/distributed_amounts_handler_spec.rb new file mode 100644 index 00000000000..d7fa53a2940 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/distributed_amounts_handler_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::DistributedAmountsHandler, type: :model do + let(:order) do + FactoryBot.create( + :order_with_line_items, + line_items_attributes: line_items_attributes + ) + end + + let(:handler) { + described_class.new(order.line_items, total_amount) + } + + describe "#amount" do + let(:total_amount) { 15 } + + context "when there is only one line item" do + let(:line_items_attributes) { [{ price: 100 }] } + let(:line_item) { order.line_items.first } + + it "applies the entire amount to the line item" do + expect(handler.amount(line_item)).to eq(15) + end + end + + context "when there are multiple line items" do + let(:line_items_attributes) do + [{ price: 50 }, { price: 50 }, { price: 50 }] + end + + context "and the line items are equally priced" do + it "evenly distributes the total amount" do + expect( + [ + handler.amount(order.line_items[0]), + handler.amount(order.line_items[1]), + handler.amount(order.line_items[2]) + ] + ).to eq( + [5, 5, 5] + ) + end + + context "and the total amount cannot be equally distributed" do + let(:total_amount) { 10 } + + it "applies the remainder of the total amount to the last item" do + expect( + [ + handler.amount(order.line_items[0]), + handler.amount(order.line_items[1]), + handler.amount(order.line_items[2]) + ] + ).to match_array( + [3.33, 3.33, 3.34] + ) + end + end + end + + context "and the line items do not have equal subtotal amounts" do + let(:line_items_attributes) do + [{ price: 50, quantity: 3 }, { price: 50, quantity: 1 }, { price: 50, quantity: 2 }] + end + + it "distributes the total amount relative to the item's price" do + expect( + [ + handler.amount(order.line_items[0]), + handler.amount(order.line_items[1]), + handler.amount(order.line_items[2]) + ] + ).to eq( + [7.5, 2.5, 5] + ) + end + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/eligibility_result_spec.rb b/promotions/spec/models/solidus_promotions/eligibility_result_spec.rb new file mode 100644 index 00000000000..f94d13e8db1 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/eligibility_result_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::EligibilityResult do + it { is_expected.to respond_to(:item) } + it { is_expected.to respond_to(:condition) } + it { is_expected.to respond_to(:success) } + it { is_expected.to respond_to(:code) } + it { is_expected.to respond_to(:message) } +end diff --git a/promotions/spec/models/solidus_promotions/eligibility_results_spec.rb b/promotions/spec/models/solidus_promotions/eligibility_results_spec.rb new file mode 100644 index 00000000000..a1affff3780 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/eligibility_results_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::EligibilityResults do + subject(:eligibility_results) { described_class.new(promotion) } + + describe "#add" do + let(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let(:promotion_benefit) { promotion.benefits.first } + let(:order) { create(:order, item_total: 100) } + let(:condition) { SolidusPromotions::Conditions::ItemTotal.new(benefit: promotion_benefit, preferred_amount: 101) } + + it "can add an error result" do + result = condition.eligible?(order) + + eligibility_results.add( + item: order, + condition: condition, + success: result, + code: condition.eligibility_errors.details[:base].first[:error_code], + message: condition.eligibility_errors.full_messages.first + ) + + expect(eligibility_results.to_a).to eq([ + SolidusPromotions::EligibilityResult.new( + item: order, + condition: condition, + success: result, + code: condition.eligibility_errors.details[:base].first[:error_code], + message: condition.eligibility_errors.full_messages.first + ) + ]) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/item_discount_spec.rb b/promotions/spec/models/solidus_promotions/item_discount_spec.rb new file mode 100644 index 00000000000..9ef51c6bb66 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/item_discount_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::ItemDiscount do + it { is_expected.to respond_to(:item) } + it { is_expected.to respond_to(:source) } + it { is_expected.to respond_to(:amount) } + it { is_expected.to respond_to(:label) } +end diff --git a/promotions/spec/models/solidus_promotions/migration_support/order_promotion_syncer_spec.rb b/promotions/spec/models/solidus_promotions/migration_support/order_promotion_syncer_spec.rb new file mode 100644 index 00000000000..909c176c5f3 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/migration_support/order_promotion_syncer_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::MigrationSupport::OrderPromotionSyncer do + subject(:syncer) { described_class.new(order: order).call } + + let(:order) { create(:order) } + + context "when there are no solidus order promotions" do + it "does not create order promotions" do + expect { subject }.not_to change { order.order_promotions.length } + end + end + + context "when there are no order promotions" do + it "does not create solidus order promotions" do + expect { subject }.not_to change { order.solidus_order_promotions.length } + end + end + + context "when there are spree order promotions" do + let(:spree_promotion) { create(:promotion) } + let(:spree_promotion_code) { nil } + + before do + order.order_promotions.create( + promotion: spree_promotion, + promotion_code: spree_promotion_code + ) + end + + it "does not create solidus order promotions" do + expect { subject }.not_to change { order.solidus_order_promotions.length } + end + + context "when there is a corresponding solidus promotion" do + let!(:solidus_promotion) { create(:solidus_promotion, original_promotion: spree_promotion) } + let!(:solidus_promotion_code) { nil } + + it "creates a solidus order promotions" do + expect { subject }.to change { order.solidus_order_promotions.length }.by(1) + expect(order.solidus_order_promotions.first.promotion).to eq(solidus_promotion) + end + + it "does not create a solidus order promotion twice" do + described_class.new(order: order).call + expect { subject }.not_to change { order.solidus_order_promotions.length } + end + + context "with a promotion code" do + let(:spree_promotion_code) { create(:promotion_code, promotion: spree_promotion) } + let!(:solidus_promotion_code) { create(:solidus_promotion_code, promotion: solidus_promotion, value: spree_promotion_code.value) } + + it "does creates a solidus order promotion with the corresponding code" do + expect { subject }.to change { order.solidus_order_promotions.length }.by(1) + expect(order.solidus_order_promotions.first.promotion_code).to eq(solidus_promotion_code) + end + end + end + end + + context "when there are solidus order promotions" do + let(:spree_promotion) { nil } + let(:solidus_promotion) { create(:solidus_promotion, original_promotion: spree_promotion) } + let(:solidus_promotion_code) { nil } + + before do + order.solidus_order_promotions.create( + promotion: solidus_promotion, + promotion_code: solidus_promotion_code + ) + end + + it "does not create order promotions" do + expect { subject }.not_to change { order.order_promotions.length } + end + + context "when there is a corresponding promotion" do + let(:spree_promotion) { create(:promotion) } + let!(:spree_promotion_code) { nil } + + it "creates a spree order promotion" do + expect { subject }.to change { order.order_promotions.length }.by(1) + expect(order.order_promotions.first.promotion).to eq(spree_promotion) + end + + it "does not create a solidus order promotion twice" do + described_class.new(order: order).call + expect { subject }.not_to change { order.order_promotions.length } + end + + context "with a promotion code" do + let(:solidus_promotion_code) { create(:solidus_promotion_code, promotion: solidus_promotion) } + let!(:spree_promotion_code) { create(:promotion_code, promotion: spree_promotion, value: solidus_promotion_code.value) } + + it "does creates a solidus order promotion with the corresponding code" do + expect { subject }.to change { order.order_promotions.length }.by(1) + expect(order.order_promotions.first.promotion_code).to eq(spree_promotion_code) + end + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/order_adjuster/choose_discounts_spec.rb b/promotions/spec/models/solidus_promotions/order_adjuster/choose_discounts_spec.rb new file mode 100644 index 00000000000..ed8da35e994 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/order_adjuster/choose_discounts_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::OrderAdjuster::ChooseDiscounts do + subject { described_class.new(discounts).call } + + let(:source_1) { create(:solidus_promotion, :with_adjustable_benefit).benefits.first } + let(:source_2) { create(:solidus_promotion, :with_adjustable_benefit).benefits.first } + let(:good_discount) { SolidusPromotions::ItemDiscount.new(amount: -2, source: source_1) } + let(:bad_discount) { SolidusPromotions::ItemDiscount.new(amount: -1, source: source_2) } + + let(:discounts) do + [ + good_discount, + bad_discount + ] + end + + it { is_expected.to contain_exactly(good_discount) } +end diff --git a/promotions/spec/models/solidus_promotions/order_adjuster/discount_order_spec.rb b/promotions/spec/models/solidus_promotions/order_adjuster/discount_order_spec.rb new file mode 100644 index 00000000000..606e687d5a2 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/order_adjuster/discount_order_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::OrderAdjuster::DiscountOrder do + context "shipped orders" do + let(:promotions) { [] } + let(:order) { create(:order, shipment_state: "shipped") } + + subject { described_class.new(order, promotions).call } + + it "returns the order unmodified" do + expect(order).not_to receive(:reset_current_discounts) + expect(subject).to eq(order) + end + end + + describe "discounting orders" do + let(:shirt) { create(:product, name: "Shirt") } + let(:order) { create(:order_with_line_items, line_items_attributes: [{ variant: shirt.master, quantity: 1 }]) } + let!(:promotion) { create(:solidus_promotion, :with_free_shipping, name: "20% off Shirts", apply_automatically: true) } + let(:promotions) { [promotion] } + let(:discounter) { described_class.new(order, promotions) } + + subject { discounter.call } + + before do + order.shipments.first.shipping_rates.first.update!(cost: nil) + end + + it "does not blow up if the shipping rate cost is nil" do + expect { subject }.not_to raise_exception + end + end + + describe "collecting eligibility results in a dry run" do + let(:shirt) { create(:product, name: "Shirt") } + let(:order) { create(:order_with_line_items, line_items_attributes: [{ variant: shirt.master, quantity: 1 }]) } + let(:conditions) { [product_condition] } + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, conditions: conditions, name: "20% off Shirts", apply_automatically: true) } + let(:product_condition) { SolidusPromotions::Conditions::Product.new(products: [shirt], preferred_line_item_applicable: false) } + let(:promotions) { [promotion] } + let(:discounter) { described_class.new(order, promotions, dry_run: true) } + + subject { discounter.call } + + it "will collect eligibility results" do + subject + + expect(promotion.eligibility_results.first.success).to be true + expect(promotion.eligibility_results.first.code).to be nil + expect(promotion.eligibility_results.first.condition).to eq(product_condition) + expect(promotion.eligibility_results.first.message).to be nil + expect(promotion.eligibility_results.first.item).to eq(order) + end + + it "can tell us about success" do + subject + expect(promotion.eligibility_results.success?).to be true + end + + context "with two conditions" do + let(:conditions) { [product_condition, item_total_condition] } + let(:item_total_condition) { SolidusPromotions::Conditions::ItemTotal.new(preferred_amount: 2000) } + + it "will collect eligibility results" do + subject + + expect(promotion.eligibility_results.first.success).to be true + expect(promotion.eligibility_results.first.code).to be nil + expect(promotion.eligibility_results.first.condition).to eq(product_condition) + expect(promotion.eligibility_results.first.message).to be nil + expect(promotion.eligibility_results.first.item).to eq(order) + expect(promotion.eligibility_results.last.success).to be false + expect(promotion.eligibility_results.last.condition).to eq(item_total_condition) + expect(promotion.eligibility_results.last.code).to eq :item_total_less_than_or_equal + expect(promotion.eligibility_results.last.message).to eq "This coupon code can't be applied to orders less than or equal to $2,000.00." + expect(promotion.eligibility_results.last.item).to eq(order) + end + + it "can tell us about success" do + subject + expect(promotion.eligibility_results.success?).to be false + end + + it "has errors for this promo" do + subject + expect(promotion.eligibility_results.error_messages).to eq([ + "This coupon code can't be applied to orders less than or equal to $2,000.00." + ]) + end + end + + context "with an order with multiple line items and an item-level condition" do + let(:pants) { create(:product, name: "Pants") } + let(:order) do + create( + :order_with_line_items, + line_items_attributes: [{ variant: shirt.master, quantity: 1 }, { variant: pants.master, quantity: 1 }] + ) + end + + let(:shirt_product_condition) { SolidusPromotions::Conditions::LineItemProduct.new(products: [shirt]) } + let(:conditions) { [shirt_product_condition] } + + it "can tell us about success" do + subject + # This is successful, because one of the line item conditions matches + expect(promotion.eligibility_results.success?).to be true + end + + it "has no errors for this promo" do + subject + expect(promotion.eligibility_results.error_messages).to be_empty + end + + context "with a second line item level condition" do + let(:hats) { create(:taxon, name: "Hats", products: [hat]) } + let(:hat) { create(:product) } + let(:hat_product_condition) { SolidusPromotions::Conditions::LineItemTaxon.new(taxons: [hats]) } + let(:conditions) { [shirt_product_condition, hat_product_condition] } + + it "can tell us about success" do + subject + expect(promotion.eligibility_results.success?).to be false + end + + it "has errors for this promo" do + subject + expect(promotion.eligibility_results.error_messages).to eq([ + "This coupon code could not be applied to the cart at this time." + ]) + end + end + end + + context "when the order must not contain a shirt" do + let(:no_shirt_condition) { SolidusPromotions::Conditions::Product.new(products: [shirt], preferred_match_policy: "none", preferred_line_item_applicable: false) } + let(:conditions) { [no_shirt_condition] } + + it "can tell us about success" do + subject + # This is successful, because the order has a shirt + expect(promotion.eligibility_results.success?).to be false + end + end + + context "where one benefit succeeds and another errors" do + let(:usps) { create(:shipping_method) } + let(:ups_ground) { create(:shipping_method) } + let(:order) { create(:order_with_line_items, line_items_attributes: [{ variant: shirt.master, quantity: 1 }], shipping_method: ups_ground) } + let(:product_condition) { SolidusPromotions::Conditions::Product.new(products: [shirt], preferred_line_item_applicable: false) } + let(:shipping_method_condition) { SolidusPromotions::Conditions::ShippingMethod.new(preferred_shipping_method_ids: [usps.id]) } + let(:ten_off_items) { SolidusPromotions::Calculators::Percent.create!(preferred_percent: 10) } + let(:ten_off_shipping) { SolidusPromotions::Calculators::Percent.create!(preferred_percent: 10) } + let(:shipping_benefit) { SolidusPromotions::Benefits::AdjustShipment.new(calculator: ten_off_shipping) } + let(:line_item_benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(calculator: ten_off_items) } + let(:benefits) { [shipping_benefit, line_item_benefit] } + let(:conditions) { [product_condition, shipping_method_condition] } + let!(:promotion) { create(:solidus_promotion, benefits: benefits, name: "10% off Shirts and USPS Shipping", apply_automatically: true) } + + before do + shipping_benefit.conditions << shipping_method_condition + line_item_benefit.conditions << product_condition + end + + it "can tell us about success" do + subject + expect(promotion.eligibility_results.success?).to be true + end + + it "can tell us about errors" do + subject + expect(promotion.eligibility_results.error_messages).to eq(["This coupon code could not be applied to the cart at this time."]) + end + end + + context "with no conditions" do + let(:conditions) { [] } + + it "has no errors for this promo" do + subject + expect(promotion.eligibility_results.error_messages).to be_empty + end + end + + context "with an ineligible order-level condition" do + let(:mug) { create(:product) } + let(:order_condition) { SolidusPromotions::Conditions::NthOrder.new(preferred_nth_order: 2) } + let(:line_item_condition) { SolidusPromotions::Conditions::LineItemProduct.new(products: [mug]) } + let(:conditions) { [order_condition, line_item_condition] } + + it "can tell us about success" do + subject + expect(promotion.eligibility_results.success?).to be false + end + + it "can tell us about all the errors", :pending do + subject + expect(promotion.eligibility_results.error_messages).to eq( + [ + "This coupon code could not be applied to the cart at this time.", + "You need to add an applicable product before applying this coupon code." + ] + ) + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/order_adjuster/load_promotions_spec.rb b/promotions/spec/models/solidus_promotions/order_adjuster/load_promotions_spec.rb new file mode 100644 index 00000000000..52a83c564dc --- /dev/null +++ b/promotions/spec/models/solidus_promotions/order_adjuster/load_promotions_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::OrderAdjuster::LoadPromotions do + describe "selecting promotions" do + subject { described_class.new(order: order).call } + + let(:order) { create(:order) } + + let!(:active_promotion) { create(:solidus_promotion, :with_adjustable_benefit, apply_automatically: true) } + let!(:inactive_promotion) do + create(:solidus_promotion, :with_adjustable_benefit, expires_at: 2.days.ago, apply_automatically: true) + end + let!(:connectable_promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let!(:connectable_inactive_promotion) do + create(:solidus_promotion, :with_adjustable_benefit, expires_at: 2.days.ago) + end + + context "no promo is connected to the order" do + it "returns only active promotions" do + expect(subject).to eq([active_promotion]) + end + end + + context "an active promo is connected to the order" do + before do + order.solidus_promotions << connectable_promotion + end + + it "checks active and connected promotions" do + expect(subject).to include(active_promotion, connectable_promotion) + end + end + + context "an inactive promo is connected to the order" do + before do + order.solidus_promotions << connectable_inactive_promotion + end + + it "does not check connected inactive promotions" do + expect(subject).not_to include(connectable_inactive_promotion) + expect(subject).to eq([active_promotion]) + end + end + + context "discarded promotions" do + let!(:discarded_promotion) { create(:solidus_promotion, :with_adjustable_benefit, deleted_at: 1.hour.ago, apply_automatically: true) } + + it "does not check discarded promotions" do + expect(subject).not_to include(discarded_promotion) + end + + context "a discarded promo is connected to the order" do + before do + order.solidus_promotions << discarded_promotion + end + + it "does not check connected discarded promotions" do + expect(subject).not_to include(discarded_promotion) + expect(subject).to eq([active_promotion]) + end + end + end + end + + context "promotions in the past" do + let(:order) { create(:order, completed_at: 7.days.ago) } + let(:currently_active_promotion) { create(:solidus_promotion, :with_adjustable_benefit, starts_at: 1.hour.ago) } + let(:past_promotion) { create(:solidus_promotion, :with_adjustable_benefit, starts_at: 1.year.ago, expires_at: 11.months.ago) } + let(:order_promotion) { create(:solidus_promotion, :with_adjustable_benefit, starts_at: 8.days.ago, expires_at: 6.days.ago) } + + before do + order.solidus_promotions << past_promotion + order.solidus_promotions << order_promotion + order.solidus_promotions << currently_active_promotion + end + + subject { described_class.new(order: order).call } + + it "only evaluates the past promotion that was active when the order was completed" do + expect(subject).to eq([order_promotion]) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/order_adjuster_spec.rb b/promotions/spec/models/solidus_promotions/order_adjuster_spec.rb new file mode 100644 index 00000000000..6a13304dca7 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/order_adjuster_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::OrderAdjuster, type: :model do + subject(:discounter) { described_class.new(order) } + + let(:line_item) { create(:line_item) } + let(:order) { line_item.order } + let(:promotion) { create(:solidus_promotion, apply_automatically: true) } + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 10) } + + context "adjusting line items" do + let(:benefit) do + SolidusPromotions::Benefits::AdjustLineItem.create(promotion: promotion, calculator: calculator) + end + let(:adjustable) { line_item } + + subject do + benefit + discounter.call + end + + context "promotion with conditionless benefit" do + context "creates the adjustment" do + it "creates the adjustment" do + expect { + subject + }.to change { adjustable.adjustments.length }.by(1) + end + + it "does not keep the current discounts" do + subject + expect(adjustable.current_discounts).to be_empty + end + + context "if order is complete but not shipped" do + let(:line_item) { order.line_items.first } + let(:order) { create(:order_ready_to_ship) } + + it "creates the adjustment" do + expect { + subject + order.save + }.to change { adjustable.reload.adjustments.length }.by(1) + end + + context "but the preference to recalculate complete orders is set to false" do + around do |example| + SolidusPromotions.config.recalculate_complete_orders = false + example.run + SolidusPromotions.config.recalculate_complete_orders = true + end + + it "will not create the adjustment" do + expect { + subject + order.save + }.not_to change { adjustable.reload.adjustments.length } + end + end + end + end + + context "with a calculator that returns zero" do + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 0) } + it " will not create the adjustment" do + expect { + subject + }.not_to change { adjustable.adjustments.length } + end + end + + context "for a non-sale promotion" do + let(:promotion) { create(:solidus_promotion, apply_automatically: false) } + + it "doesn't connect the promotion to the order" do + expect { + subject + }.to change { order.promotions.length }.by(0) + end + + it "doesn't create an adjustment" do + expect { + subject + }.to change { adjustable.adjustments.length }.by(0) + end + + context "for an line item that has an adjustment from the old promotion system" do + let(:old_promotion_benefit) { create(:promotion, :with_adjustable_action, apply_automatically: false).actions.first } + let!(:adjustment) { create(:adjustment, source: old_promotion_benefit, adjustable: line_item) } + + it "removes the old adjustment from the line item" do + adjustable.reload + expect { + subject + }.to change { adjustable.reload.adjustments.length }.by(-1) + end + end + end + end + + context "promotion includes item involved" do + before do + benefit.conditions.create(type: "SolidusPromotions::Conditions::Product", products: [line_item.product]) + end + + context "creates the adjustment" do + it "creates the adjustment" do + expect { + subject + }.to change { adjustable.adjustments.length }.by(1) + end + end + end + + context "promotion has item total condition" do + before do + benefit.conditions.create!( + type: "SolidusPromotions::Conditions::ItemTotal", + preferred_operator: "gt", + preferred_amount: 50 + ) + # Makes the order eligible for this promotion + order.item_total = 100 + order.save + end + + context "creates the adjustment" do + it "creates the adjustment" do + expect { + subject + }.to change { adjustable.adjustments.length }.by(1) + end + end + end + end + + context "adjusting shipping rates" do + let(:promotion) { create(:solidus_promotion, benefits: [shipment_benefit], apply_automatically: true) } + let(:shipment_benefit) { SolidusPromotions::Benefits::AdjustShipment.new(calculator: fifty_percent) } + let(:fifty_percent) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 50) } + let(:order) { create(:order_with_line_items) } + + subject do + promotion + discounter.call + end + + it "creates shipping rate discounts" do + expect { subject }.to change { SolidusPromotions::ShippingRateDiscount.count } + end + + context "if the promo is eligible but the calculcator returns 0" do + let(:shipment_benefit) { SolidusPromotions::Benefits::AdjustShipment.new(calculator: zero_percent) } + let(:zero_percent) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 0) } + + it "will not create an adjustment on the shipping rate" do + expect do + subject + end.not_to change { order.shipments.first.shipping_rates.first.discounts.count } + end + end + end + + context "adjusting shipments" do + let(:promotion) { create(:solidus_promotion, benefits: [shipment_benefit], apply_automatically: true) } + let(:shipment_benefit) { SolidusPromotions::Benefits::AdjustShipment.new(calculator: fifty_percent) } + let(:fifty_percent) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 50) } + let(:order) { create(:order_with_line_items) } + + it "creates an adjustment on the shipment" do + expect do + promotion + subject.call + end.to change { order.shipments.first.adjustments.count } + end + + context "if the promo is eligible but the calculcator returns 0" do + let(:shipment_benefit) { SolidusPromotions::Benefits::AdjustShipment.new(calculator: zero_percent) } + let(:zero_percent) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 0) } + + it "will not create an adjustment on the shipment" do + expect do + promotion + subject.call + end.not_to change { order.shipments.first.adjustments.count } + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/order_promotion_spec.rb b/promotions/spec/models/solidus_promotions/order_promotion_spec.rb new file mode 100644 index 00000000000..f9a9317a0e7 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/order_promotion_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::OrderPromotion do + subject do + order_promotion + end + + let(:promotion) { build(:solidus_promotion) } + let(:order_promotion) { build(:solidus_order_promotion, promotion: promotion) } + + describe "promotion code presence error" do + subject do + order_promotion.valid? + order_promotion.errors[:promotion_code] + end + + context "when the promotion does not have a code" do + it { is_expected.to be_blank } + end + + context "when the promotion has a code" do + let!(:promotion_code) do + promotion.codes << build(:solidus_promotion_code, promotion: promotion) + end + + it { is_expected.to include("can't be blank") } + end + end + + describe "promotion code presence error on promotion that apply automatically" do + subject do + order_promotion.promotion.apply_automatically = true + order_promotion.valid? + order_promotion.errors[:promotion_code] + end + + context "when the promotion does not have a code" do + it { is_expected.to be_blank } + end + + context "when the promotion has a code" do + let!(:promotion_code) do + promotion.codes << build(:solidus_promotion_code, promotion: promotion) + end + + it { is_expected.to be_blank } + end + end +end diff --git a/promotions/spec/models/solidus_promotions/permission_sets/solidus_promotion_management_spec.rb b/promotions/spec/models/solidus_promotions/permission_sets/solidus_promotion_management_spec.rb new file mode 100644 index 00000000000..80c22d5836d --- /dev/null +++ b/promotions/spec/models/solidus_promotions/permission_sets/solidus_promotion_management_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" +require "cancan/matchers" + +RSpec.describe SolidusPromotions::PermissionSets::SolidusPromotionManagement do + let(:ability_klass) do + Class.new do + include CanCan::Ability + end + end + let(:ability) { ability_klass.new } + + subject { ability } + + context "when activated" do + before do + described_class.new(ability).activate! + end + + it { is_expected.to be_able_to(:manage, SolidusPromotions::Promotion) } + it { is_expected.to be_able_to(:manage, SolidusPromotions::Condition) } + it { is_expected.to be_able_to(:manage, SolidusPromotions::Benefit) } + it { is_expected.to be_able_to(:manage, SolidusPromotions::PromotionCategory) } + it { is_expected.to be_able_to(:manage, SolidusPromotions::PromotionCode) } + end + + context "when not activated" do + it { is_expected.not_to be_able_to(:manage, SolidusPromotions::Promotion) } + it { is_expected.not_to be_able_to(:manage, SolidusPromotions::Condition) } + it { is_expected.not_to be_able_to(:manage, SolidusPromotions::Benefit) } + it { is_expected.not_to be_able_to(:manage, SolidusPromotions::PromotionCategory) } + it { is_expected.not_to be_able_to(:manage, SolidusPromotions::PromotionCode) } + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_advertiser_spec.rb b/promotions/spec/models/solidus_promotions/promotion_advertiser_spec.rb new file mode 100644 index 00000000000..b0bf73175ad --- /dev/null +++ b/promotions/spec/models/solidus_promotions/promotion_advertiser_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::PromotionAdvertiser, type: :model do + describe ".for_product" do + subject { described_class.for_product(product) } + + let(:product) { create(:product) } + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, advertise: true, starts_at: 1.day.ago) } + let!(:rule) do + SolidusPromotions::Conditions::LineItemProduct.create( + benefit: promotion.benefits.first, + products: [product] + ) + end + + it "lists the promotion as a possible promotion" do + expect(subject).to include(promotion) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_category_spec.rb b/promotions/spec/models/solidus_promotions/promotion_category_spec.rb new file mode 100644 index 00000000000..0bb0b9389f8 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/promotion_category_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::PromotionCategory, type: :model do + it { is_expected.to have_many :promotions } + + describe "validation" do + subject { described_class.new name: name } + + let(:name) { "Nom" } + + context "when all required attributes are specified" do + it { is_expected.to be_valid } + end + + context "when name is missing" do + let(:name) { nil } + + it { is_expected.not_to be_valid } + end + end + + describe "associations" do + let!(:promotion) { create(:solidus_promotion, category: category) } + let(:category) { create(:solidus_promotion_category) } + + it "nullifies associated promotions when deleted" do + category.destroy + expect(promotion.reload.promotion_category_id).to be_nil + end + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_code/batch_builder_spec.rb b/promotions/spec/models/solidus_promotions/promotion_code/batch_builder_spec.rb new file mode 100644 index 00000000000..82827c43544 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/promotion_code/batch_builder_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::PromotionCode::BatchBuilder do + subject { described_class.new(code_batch, options) } + + let(:promotion) { create(:solidus_promotion) } + let(:base_code) { "abc" } + let(:options) { {} } + let(:number_of_codes) { 10 } + let(:code_batch) do + SolidusPromotions::PromotionCodeBatch.create!( + promotion_id: promotion.id, + base_code: base_code, + number_of_codes: number_of_codes, + email: "test@email.com" + ) + end + + describe "#build_promotion_codes" do + context "with a failed build" do + before do + allow(subject).to receive(:generate_random_codes).and_raise "Error" + + expect { subject.build_promotion_codes }.to raise_error RuntimeError + end + + it "updates the error and state on promotion batch" do + expect(code_batch.reload.error).to eq("#") + expect(code_batch.reload.state).to eq("failed") + end + end + + context "with a successful build" do + before do + allow(SolidusPromotions::PromotionCodeBatchMailer) + .to receive(:promotion_code_batch_finished) + .and_call_original + + subject.build_promotion_codes + end + + it "update the promotion codes count for the batch" do + expect(code_batch.promotion_codes.count).to eq(10) + end + + it "builds the correct number of codes" do + expect(subject.promotion.codes.size).to eq(10) + end + + it "builds codes with distinct values" do + expect(subject.promotion.codes.map(&:value).uniq.size).to eq(10) + end + + it "updates the promotion code batch state to completed" do + expect(code_batch.state).to eq("completed") + end + end + + context "with likely code contention" do + let(:number_of_codes) { 50 } + let(:options) do + { + batch_size: 10, + sample_characters: (0..9).to_a.map(&:to_s), + random_code_length: 2 + } + end + + it "creates the correct number of codes" do + subject.build_promotion_codes + expect(promotion.codes.size).to eq(number_of_codes) + end + end + end + + describe "#join_character" do + context "with the default join charachter _" do + it "builds codes with the same base prefix" do + subject.build_promotion_codes + + values = subject.promotion.codes.map(&:value) + expect(values.all? { |val| val.starts_with?("#{base_code}_") }).to be true + end + end + + context "with a custom join separator" do + let(:code_batch) do + SolidusPromotions::PromotionCodeBatch.create!( + promotion_id: promotion.id, + base_code: base_code, + number_of_codes: number_of_codes, + email: "test@email.com", + join_characters: "x" + ) + end + + it "builds codes with the same base prefix" do + subject.build_promotion_codes + + values = subject.promotion.codes.map(&:value) + expect(values.all? { |val| val.starts_with?("#{base_code}x") }).to be true + end + end + end + + context "when promotion_code creation returns an error" do + before do + @raise_exception = true + allow(SolidusPromotions::PromotionCode).to receive(:create!) do + if @raise_exception + @raise_exception = false + raise(ActiveRecord::RecordInvalid) + else + create(:solidus_promotion_code, promotion: promotion) + end + end + end + + it "creates the correct number of codes anyway" do + subject.build_promotion_codes + expect(promotion.codes.size).to eq(number_of_codes) + end + end + + context "when same promotion_codes are already present" do + let(:number_of_codes) { 50 } + before do + create_list(:solidus_promotion_code, 11, promotion: promotion, promotion_code_batch: code_batch) + end + + it "creates only the missing promotion_codes" do + expect { subject.build_promotion_codes }.to change { promotion.codes.size }.by(39) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_code_batch_spec.rb b/promotions/spec/models/solidus_promotions/promotion_code_batch_spec.rb new file mode 100644 index 00000000000..882450734aa --- /dev/null +++ b/promotions/spec/models/solidus_promotions/promotion_code_batch_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::PromotionCodeBatch, type: :model do + subject do + described_class.create!( + promotion_id: create(:solidus_promotion).id, + base_code: "abc", + number_of_codes: 1, + error: nil, + email: "test@email.com" + ) + end + + describe "#process" do + context "with a pending code batch" do + it "calls the worker" do + expect { subject.process } + .to have_enqueued_job(SolidusPromotions::PromotionCodeBatchJob) + end + + it "updates the state to processing" do + subject.process + + expect(subject.state).to eq("processing") + end + end + + context "with a processing batch" do + before { subject.update_attribute(:state, "processing") } + + it "raises an error" do + expect { subject.process }.to raise_error described_class::CantProcessStartedBatch + end + end + + context "with a completed batch" do + before { subject.update_attribute(:state, "completed") } + + it "raises an error" do + expect { subject.process }.to raise_error described_class::CantProcessStartedBatch + end + end + + context "with a failed batch" do + before { subject.update_attribute(:state, "failed") } + + it "raises an error" do + expect { subject.process }.to raise_error described_class::CantProcessStartedBatch + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_code_spec.rb b/promotions/spec/models/solidus_promotions/promotion_code_spec.rb new file mode 100644 index 00000000000..418bb9a9833 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/promotion_code_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::PromotionCode do + let(:promotion) { create(:solidus_promotion) } + subject { create(:solidus_promotion_code, promotion: promotion) } + + it { is_expected.to belong_to(:promotion) } + it { is_expected.to have_many(:order_promotions).class_name("SolidusPromotions::OrderPromotion").dependent(:destroy) } + + context "callbacks" do + subject { promotion_code.save } + + describe "#normalize_code" do + let(:promotion) { create(:solidus_promotion, code: code) } + + before { subject } + + context "when no other code with the same value exists" do + let(:promotion_code) { promotion.codes.first } + + context "with mixed case" do + let(:code) { "NewCoDe" } + + it "downcases the value before saving" do + expect(promotion_code.value).to eq("newcode") + end + end + + context "with extra spacing" do + let(:code) { " new code " } + + it "removes surrounding whitespace" do + expect(promotion_code.value).to eq "new code" + end + end + end + + context "when another code with the same value exists" do + let(:promotion_code) { promotion.codes.build(value: code) } + + context "with mixed case" do + let(:code) { "NewCoDe" } + + it "does not save the record and marks it as invalid" do + expect(promotion_code.valid?).to eq false + + expect(promotion_code.errors.messages[:value]).to contain_exactly( + "has already been taken" + ) + end + end + + context "with extra spacing" do + let(:code) { " new code " } + + it "does not save the record and marks it as invalid" do + expect(promotion_code.valid?).to eq false + + expect(promotion_code.errors.messages[:value]).to contain_exactly( + "has already been taken" + ) + end + end + end + end + end + + describe "#usage_limit_exceeded?" do + subject { code.usage_limit_exceeded? } + + shared_examples "it should" do + context "when there is a usage limit" do + context "and the limit is not exceeded" do + let(:usage_limit) { 10 } + + it { is_expected.to be_falsy } + end + + context "and the limit is exceeded" do + let(:usage_limit) { 1 } + + context "on a different order" do + before do + FactoryBot.create( + :completed_order_with_solidus_promotion, + promotion: promotion + ) + end + + it { is_expected.to be_truthy } + end + + context "on the same order" do + it { is_expected.to be_falsy } + end + end + end + + context "when there is no usage limit" do + let(:usage_limit) { nil } + + it { is_expected.to be_falsy } + end + end + + let(:code) { promotion.codes.first } + + context "with an order-level adjustment" do + let(:promotion) do + FactoryBot.create( + :solidus_promotion, + :with_order_adjustment, + code: "discount", + per_code_usage_limit: usage_limit + ) + end + let(:promotable) do + FactoryBot.create( + :completed_order_with_solidus_promotion, + promotion: promotion + ) + end + + it_behaves_like "it should" + end + + context "with an item-level adjustment" do + let(:promotion) do + FactoryBot.create( + :solidus_promotion, + :with_line_item_adjustment, + code: "discount", + per_code_usage_limit: usage_limit + ) + end + + before do + order.recalculate + end + + context "when there are multiple line items" do + let(:order) { FactoryBot.create(:order_with_line_items, line_items_count: 2) } + + describe "the first item" do + let(:promotable) { order.line_items.first } + + it_behaves_like "it should" + end + + describe "the second item" do + let(:promotable) { order.line_items.last } + + it_behaves_like "it should" + end + end + + context "when there is a single line item" do + let(:order) { FactoryBot.create(:order_with_line_items) } + let(:promotable) { order.line_items.first } + + it_behaves_like "it should" + end + end + end + + describe "#usage_count" do + subject { code.usage_count } + + let(:promotion) do + FactoryBot.create( + :solidus_promotion, + :with_order_adjustment, + code: "discount" + ) + end + let(:code) { promotion.codes.first } + + context "when the code is applied to a non-complete order" do + let(:order) { FactoryBot.create(:order_with_line_items) } + + before do + order.solidus_order_promotions.create( + promotion: promotion, + promotion_code: code + ) + order.recalculate + end + + it { is_expected.to eq 0 } + end + + context "when the code is applied to a complete order" do + let!(:order) do + FactoryBot.create( + :completed_order_with_solidus_promotion, + promotion: promotion + ) + end + + context "and the promo is eligible" do + it { is_expected.to eq 1 } + end + + context "and the promo is ineligible" do + before do + promotion.benefits.first.conditions << SolidusPromotions::Conditions::NthOrder.new(preferred_nth_order: 2) + order.recalculate + end + + it { is_expected.to eq 0 } + end + + context "and the order is canceled" do + before { order.cancel! } + + it { is_expected.to eq 0 } + it { expect(order.state).to eq "canceled" } + end + end + end + + describe "completing multiple orders with the same code", slow: true do + let(:promotion) do + FactoryBot.create( + :solidus_promotion, + :with_order_adjustment, + code: "discount", + per_code_usage_limit: 1, + weighted_order_adjustment_amount: 10 + ) + end + let(:code) { promotion.codes.first } + + let(:order) do + FactoryBot.create(:order_with_line_items, line_items_price: 40, shipment_cost: 0).tap do |order| + FactoryBot.create(:payment, amount: 30, order: order) + end + end + + let(:promo_adjustment) { order.all_adjustments.solidus_promotion.first } + + before do + order.solidus_order_promotions.create!( + order: order, + promotion: promotion, + promotion_code: described_class.find_by(value: "discount") + ) + order.recalculate + order.next! until order.can_complete? + + FactoryBot.create(:order_with_line_items, line_items_price: 40, shipment_cost: 0).tap do |order| + FactoryBot.create(:payment, amount: 30, order: order) + order.solidus_order_promotions.create!( + order: order, + promotion: promotion, + promotion_code: described_class.find_by(value: "discount") + ) + order.recalculate + order.next! until order.can_complete? + order.complete! + end + end + + it "makes the adjustment disappear" do + expect { + order.complete + }.to change { order.all_adjustments.solidus_promotion }.to([]) + end + + it "adjusts the promo_total" do + expect { + order.complete + }.to change(order, :promo_total).by(10) + end + + it "increases the total to remove the promo" do + expect { + order.complete + }.to change(order, :total).from(30).to(40) + end + + it "resets the state of the order" do + expect { + order.complete + }.to change { order.reload.state }.from("confirm").to("address") + end + end + + it "cannot create promotion code on apply automatically promotion" do + promotion = create(:solidus_promotion, apply_automatically: true) + expect { + create(:solidus_promotion_code, promotion: promotion) + }.to raise_error ActiveRecord::RecordInvalid, + "Validation failed: Could not create promotion code on promotion that apply automatically" + end + + describe "#destroy" do + subject { promotion_code.destroy } + + let(:promotion_code) { create(:solidus_promotion_code) } + let(:order) { create(:order_with_line_items) } + + before do + order.solidus_order_promotions.create(promotion: promotion_code.promotion, promotion_code: promotion_code) + end + + it "destroys the order_promotion" do + expect { subject }.to change { SolidusPromotions::OrderPromotion.count }.by(-1) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_finder_spec.rb b/promotions/spec/models/solidus_promotions/promotion_finder_spec.rb new file mode 100644 index 00000000000..b5abc8c0949 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/promotion_finder_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::PromotionFinder do + describe ".by_code_or_id" do + let!(:promotion) { create(:solidus_promotion, code: "promo") } + + it "finds a promotion by its code" do + expect(described_class.by_code_or_id("promo")).to eq promotion + end + + it "finds a promotion by its ID" do + expect(described_class.by_code_or_id(promotion.id)).to eq promotion + end + + context "when the promotion does not exist" do + it "raises an error" do + expect { described_class.by_code_or_id("nonexistent") }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_handler/coupon_spec.rb b/promotions/spec/models/solidus_promotions/promotion_handler/coupon_spec.rb new file mode 100644 index 00000000000..0e82d6c8bdc --- /dev/null +++ b/promotions/spec/models/solidus_promotions/promotion_handler/coupon_spec.rb @@ -0,0 +1,455 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::PromotionHandler::Coupon, type: :model do + let(:order) { double("Order", coupon_code: "10off").as_null_object } + + subject { described_class.new(order) } + + def expect_order_connection(order:, promotion:, promotion_code: nil) + expect(order.solidus_promotions.to_a).to include(promotion) + expect(order.solidus_order_promotions.flat_map(&:promotion_code)).to include(promotion_code) + end + + def expect_adjustment_creation(adjustable:, promotion:) + expect(adjustable.adjustments.map(&:source).map(&:promotion)).to include(promotion) + end + + it "returns self in apply" do + expect(subject.apply).to be_a described_class + end + + context "status messages" do + let(:coupon) { described_class.new(order) } + + describe "#set_success_code" do + let(:status) { :coupon_code_applied } + subject { coupon.send(:set_success_code, status) } + + it "should have status_code" do + subject + expect(coupon.status_code).to eq(status) + end + + it "should have success message" do + subject + expect(coupon.success).to eq "The coupon code was successfully applied to your order." + end + end + + describe "#set_error_code" do + subject { coupon.send(:set_error_code, status) } + + context "not found" do + let(:status) { :coupon_code_not_found } + + it "has status_code" do + subject + expect(coupon.status_code).to eq(status) + end + + it "has error message" do + subject + expect(coupon.error).to eq "The coupon code you entered doesn't exist. Please try again." + end + end + + context "not present" do + let(:status) { :coupon_code_not_present } + + it "has status_code" do + subject + expect(coupon.status_code).to eq(status) + end + + it "has error message" do + subject + expect(coupon.error).to eq "The coupon code you are trying to remove is not present on this order." + end + end + end + end + + context "coupon code promotion doesnt exist" do + before { create(:promotion) } + + it "doesnt fetch any promotion" do + expect(subject.promotion).to be_blank + end + + context "with no benefits defined" do + before { create(:promotion, code: "10off") } + + it "populates error message" do + subject.apply + expect(subject.error).to eq "The coupon code you entered doesn't exist. Please try again." + end + end + end + + context "existing coupon code promotion" do + let!(:promotion) { promotion_code.promotion } + let(:promotion_code) { create(:solidus_promotion_code, value: "10off") } + let!(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.create(promotion: promotion, calculator: calculator) } + let(:calculator) { SolidusPromotions::Calculators::FlatRate.new(preferred_amount: 10) } + + it "fetches with given code" do + expect(subject.promotion).to eq promotion + end + + context "with a per-item adjustment benefit" do + let(:order) { create(:order_with_line_items, line_items_count: 3) } + + context "right coupon given" do + context "with correct coupon code casing" do + before { order.coupon_code = "10off" } + + it "successfully activates promo" do + expect(order.total).to eq(130) + subject.apply + expect(subject.success).to be_present + expect_order_connection(order: order, promotion: promotion, promotion_code: promotion_code) + order.line_items.each do |line_item| + expect_adjustment_creation(adjustable: line_item, promotion: promotion) + end + # Ensure that applying the adjustment actually affects the order's total! + expect(order.reload.total).to eq(100) + end + + it "coupon already applied to the order" do + subject.apply + expect(subject.success).to be_present + subject.apply + expect(subject.error).to eq "The coupon code has already been applied to this order" + end + end + + # Regression test for https://github.com/spree/spree/issues/4211 + context "with incorrect coupon code casing" do + before { order.coupon_code = "10OFF" } + it "successfully activates promo" do + expect(order.total).to eq(130) + subject.apply + expect(subject.success).to be_present + expect_order_connection(order: order, promotion: promotion, promotion_code: promotion_code) + order.line_items.each do |line_item| + expect_adjustment_creation(adjustable: line_item, promotion: promotion) + end + # Ensure that applying the adjustment actually affects the order's total! + expect(order.reload.total).to eq(100) + end + end + end + + context "coexists with a non coupon code promo" do + let!(:order) { create(:order) } + + before do + order.coupon_code = "10off" + calculator = SolidusPromotions::Calculators::FlatRate.new(preferred_amount: 10) + general_promo = create(:solidus_promotion, lane: :post, apply_automatically: true, name: "General Promo") + SolidusPromotions::Benefits::AdjustLineItem.create(promotion: general_promo, calculator: calculator) + + order.contents.add create(:variant) + end + + # regression spec for https://github.com/spree/spree/issues/4515 + it "successfully activates promo" do + subject.apply + expect(subject).to be_successful + expect_order_connection(order: order, promotion: promotion, promotion_code: promotion_code) + order.line_items.each do |line_item| + expect_adjustment_creation(adjustable: line_item, promotion: promotion) + end + end + end + + context "applied alongside another valid promotion " do + let!(:order) { create(:order) } + + before do + order.coupon_code = "10off" + calculator = SolidusPromotions::Calculators::Percent.new(preferred_percent: 10) + general_promo = create(:solidus_promotion, lane: :pre, apply_automatically: true, name: "General Promo") + SolidusPromotions::Benefits::AdjustLineItem.create!(promotion: general_promo, calculator: calculator) + + order.contents.add create(:variant, price: 500) + order.contents.add create(:variant, price: 10) + end + + it "successfully activates both promotions and returns success" do + subject.apply + expect(subject).to be_successful + order.line_items.each do |line_item| + expect(line_item.adjustments.count).to eq 2 + expect_adjustment_creation(adjustable: line_item, promotion: promotion) + end + end + end + end + + context "with a free-shipping adjustment benefit" do + let!(:benefit) do + SolidusPromotions::Benefits::AdjustShipment.create!( + promotion: promotion, + calculator: calculator + ) + end + let(:calculator) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 100) } + context "right coupon code given" do + let(:order) { create(:order_with_line_items, line_items_count: 3) } + + before { order.coupon_code = "10off" } + + it "successfully activates promo" do + expect(order.total).to eq(130) + subject.apply + expect(subject.success).to be_present + + expect_order_connection(order: order, promotion: promotion, promotion_code: promotion_code) + order.shipments.each do |shipment| + expect_adjustment_creation(adjustable: shipment, promotion: promotion) + end + end + + it "coupon already applied to the order" do + subject.apply + expect(subject.success).to be_present + subject.apply + expect(subject.error).to eq "The coupon code has already been applied to this order" + end + end + end + + context "with a whole-order adjustment benefit" do + let!(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.create(promotion: promotion, calculator: calculator) } + context "right coupon given" do + let(:order) { create(:order) } + let(:calculator) { SolidusPromotions::Calculators::DistributedAmount.new(preferred_amount: 10) } + + before do + allow(order).to receive_messages({ + coupon_code: "10off", + # These need to be here so that promotion adjustment "wins" + item_total: 50, + ship_total: 10 + }) + end + + it "successfully activates promo" do + subject.apply + expect(subject.success).to be_present + expect(order.all_adjustments.count).to eq(order.line_items.count) + expect_order_connection(order: order, promotion: promotion, promotion_code: promotion_code) + order.line_items.each do |line_item| + expect_adjustment_creation(adjustable: line_item, promotion: promotion) + end + end + + context "when the coupon is already applied to the order" do + before { subject.apply } + + it "is not successful" do + subject.apply + expect(subject.successful?).to be false + end + + it "returns a coupon has already been applied error" do + subject.apply + expect(subject.error).to eq "The coupon code has already been applied to this order" + end + end + + context "when the coupon fails to activate" do + let(:impossible_condition) { SolidusPromotions::Conditions::NthOrder.new(preferred_nth_order: 2) } + + before do + promotion.benefits.first.conditions << impossible_condition + end + + it "is not successful" do + subject.apply + expect(subject.successful?).to be false + end + + it "returns a coupon failed to activate error" do + subject.apply + expect(subject.error).to eq "This coupon code could not be applied to the cart at this time." + end + end + + context "when the promotion exceeds its usage limit" do + let!(:second_order) { FactoryBot.create(:completed_order_with_solidus_promotion, promotion: promotion) } + + before do + promotion.update!(usage_limit: 1) + described_class.new(second_order).apply + end + + it "is not successful" do + subject.apply + expect(subject.successful?).to be false + end + + it "returns a coupon is at max usage error" do + subject.apply + expect(subject.error).to eq "Coupon code usage limit exceeded" + end + end + end + end + + context "for an order with taxable line items" do + let(:store) { create(:store) } + let(:order) { create(:order, store: store) } + let(:tax_category) { create(:tax_category, name: "Taxable Foo") } + let(:zone) { create(:zone, :with_country) } + let!(:tax_rate) { create(:tax_rate, amount: 0.1, tax_categories: [tax_category], zone: zone) } + + before(:each) do + expect(order).to receive(:tax_address).at_least(:once).and_return(Spree::Tax::TaxLocation.new(country: zone.countries.first)) + end + + context "and the product price is less than promo discount" do + before(:each) do + order.coupon_code = "10off" + + 3.times do |_i| + taxable = create(:product, tax_category: tax_category, price: 9.0) + order.contents.add(taxable.master, 1) + end + end + + it "successfully applies the promo" do + # 3 * (9 + 0.9) + expect(order.total).to eq(29.7) + coupon = described_class.new(order) + coupon.apply + expect(coupon.success).to be_present + # 3 * ((9 - [9,10].min) + 0) + expect(order.reload.total).to eq(0) + expect(order.additional_tax_total).to eq(0) + end + end + + context "and the product price is greater than promo discount" do + before(:each) do + order.coupon_code = "10off" + + 3.times do |_i| + taxable = create(:product, tax_category: tax_category, price: 11.0) + order.contents.add(taxable.master, 2) + end + end + + it "successfully applies the promo" do + # 3 * (22 + 2.2) + expect(order.total.to_f).to eq(72.6) + coupon = described_class.new(order) + coupon.apply + expect(coupon.success).to be_present + # 3 * ( (22 - 10) + 1.2) + expect(order.reload.total).to eq(39.6) + expect(order.additional_tax_total).to eq(3.6) + end + end + + context "and multiple quantity per line item" do + before(:each) do + twnty_off = create(:solidus_promotion, name: "promo", code: "20off") + twnty_off_calc = SolidusPromotions::Calculators::FlatRate.new(preferred_amount: 20) + SolidusPromotions::Benefits::AdjustLineItem.create(promotion: twnty_off, + calculator: twnty_off_calc) + + order.coupon_code = "20off" + + 3.times do |_i| + taxable = create(:product, tax_category: tax_category, price: 10.0) + order.contents.add(taxable.master, 2) + end + end + + it "successfully applies the promo" do + # 3 * ((2 * 10) + 2.0) + expect(order.total.to_f).to eq(66) + coupon = described_class.new(order) + coupon.apply + expect(coupon.success).to be_present + # 0 + expect(order.reload.total).to eq(0) + expect(order.additional_tax_total).to eq(0) + end + end + end + end + + context "removing a coupon code from an order" do + let!(:promotion) { promotion_code.promotion } + let(:promotion_code) { create(:solidus_promotion_code, value: "10off") } + let!(:benefit) { SolidusPromotions::Benefits::AdjustLineItem.create(promotion: promotion, calculator: calculator) } + let(:calculator) { SolidusPromotions::Calculators::FlatRate.new(preferred_amount: 10) } + let(:order) { create(:order_with_line_items, line_items_count: 3) } + + context "with an already applied coupon" do + before do + order.coupon_code = "10off" + subject.apply + order.reload + expect(order.total).to eq(100) + end + + it "successfully removes the coupon code from the order" do + subject.remove + expect(subject.error).to eq nil + expect(subject.success).to eq "The coupon code was successfully removed from this order." + expect(order.reload.total).to eq(130) + end + end + + context "with a coupon code not applied to an order" do + before do + order.coupon_code = "10off" + expect(order.total).to eq(130) + end + + it "returns an error" do + subject.remove + expect(subject.success).to eq nil + expect(subject.error).to eq "The coupon code you are trying to remove is not present on this order." + expect(order.reload.total).to eq(130) + end + end + end + + context "with multiple errors" do + let(:shirt) { create(:product) } + let(:hat) { create(:product) } + let(:order) { create(:order_with_line_items, coupon_code: "XMAS", line_items_attributes: [{ variant: shirt.master, quantity: 1 }]) } + let(:product_condition) { SolidusPromotions::Conditions::Product.new(products: [hat], preferred_line_item_applicable: false) } + let(:nth_order_condition) { SolidusPromotions::Conditions::NthOrder.new(preferred_nth_order: 2) } + let(:ten_off_items) { SolidusPromotions::Calculators::Percent.create!(preferred_percent: 10) } + let(:line_item_benefit) { SolidusPromotions::Benefits::AdjustLineItem.new(calculator: ten_off_items, conditions: conditions) } + let(:benefits) { [line_item_benefit] } + let(:conditions) { [product_condition, nth_order_condition] } + let!(:promotion) { create(:solidus_promotion, benefits: benefits, name: "10% off Shirts and USPS Shipping") } + let!(:coupon) { create(:solidus_promotion_code, promotion: promotion, value: "XMAS") } + let(:handler) { described_class.new(order) } + + subject { handler.apply } + + it "is unsuccessful with multiple errors" do + subject + expect(handler.success).to be nil + # Promotion conditions are not ordered, so it can be either of these errors. + expect(handler.error).to be_in([ + "You need to add an applicable product before applying this coupon code.", + "This coupon code could not be applied to the cart at this time." + ]) + expect(handler.errors).to contain_exactly( + "You need to add an applicable product before applying this coupon code.", + "This coupon code could not be applied to the cart at this time." + ) + end + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_handler/page_spec.rb b/promotions/spec/models/solidus_promotions/promotion_handler/page_spec.rb new file mode 100644 index 00000000000..2932f3e5ac2 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/promotion_handler/page_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::PromotionHandler::Page, type: :model do + subject { described_class.new(order, path).activate } + + let(:order) { create(:order_with_line_items, line_items_count: 1) } + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, name: "10% off", path: "10off") } + let(:path) { "10off" } + + it "activates at the right path" do + expect(order.line_item_adjustments.count).to eq(0) + subject + expect(order.line_item_adjustments.count).to eq(1) + end + + context "when promotion is expired" do + before do + promotion.update( + starts_at: 1.week.ago, + expires_at: 1.day.ago + ) + end + + it "is not activated" do + expect(order.line_item_adjustments.count).to eq(0) + subject + expect(order.line_item_adjustments.count).to eq(0) + end + end + + context "with a wrong path" do + let(:path) { "wrongpath" } + it "does not activate at the wrong path" do + expect(order.line_item_adjustments.count).to eq(0) + subject + expect(order.line_item_adjustments.count).to eq(0) + end + end + + context "when promotion is not eligible" do + let(:impossible_condition) { SolidusPromotions::Conditions::NthOrder.new(preferred_nth_order: 2) } + before do + promotion.benefits.first.conditions << impossible_condition + end + + it "is not applied" do + expect { subject }.not_to change { order.line_item_adjustments.count } + end + + it "does not connect the promotion to the order" do + expect { subject }.not_to change { order.solidus_order_promotions.count } + end + end +end diff --git a/promotions/spec/models/solidus_promotions/promotion_spec.rb b/promotions/spec/models/solidus_promotions/promotion_spec.rb new file mode 100644 index 00000000000..e2fea9fad2a --- /dev/null +++ b/promotions/spec/models/solidus_promotions/promotion_spec.rb @@ -0,0 +1,689 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::Promotion, type: :model do + let(:promotion) { described_class.new } + + it { is_expected.to belong_to(:category).optional } + it { is_expected.to respond_to(:customer_label) } + it { is_expected.to have_many :conditions } + it { is_expected.to have_many(:order_promotions).dependent(:destroy) } + it { is_expected.to have_many(:code_batches).dependent(:destroy) } + + describe "lane" do + it { is_expected.to respond_to(:lane) } + + it "is default be default" do + expect(subject.lane).to eq("default") + end + end + + describe "#destroy" do + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, apply_automatically: true) } + + subject { promotion.destroy! } + + it "destroys the promotion and deletes the benefit" do + expect { subject }.to change { SolidusPromotions::Promotion.count }.by(-1) + expect(SolidusPromotions::Benefit.count).to be_zero + end + + context "when the promotion has been applied to a complete order" do + let(:order) { create(:order_ready_to_complete) } + + before do + order.recalculate + order.complete! + end + + it "raises an error" do + expect { subject }.to raise_exception(ActiveRecord::RecordNotDestroyed) + end + end + + context "when the promotion has been added to an incomplete order" do + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let(:order) { create(:order) } + + before do + order.solidus_promotions << promotion + end + + it "destroys the connection" do + expect { subject }.to change(SolidusPromotions::OrderPromotion, :count).by(-1) + end + end + end + + describe "#discard" do + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, apply_automatically: true) } + + subject { promotion.discard! } + + it "discards the promotion and keeps the benefit" do + expect { subject }.to change { SolidusPromotions::Promotion.count }.by(-1) + end + + it "keeps the benefit" do + expect { subject }.not_to change(SolidusPromotions::Benefit, :count) + end + + context "when the promotion has been applied to a complete order" do + let(:order) { create(:order_ready_to_complete) } + + before do + order.recalculate + order.complete! + end + + it "does not complain" do + expect { subject }.not_to raise_exception + end + end + + context "when the promotion has been added to an incomplete order" do + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let(:order) { create(:order) } + + before do + order.solidus_promotions << promotion + end + + it "destroys the connection" do + expect { subject }.to change(SolidusPromotions::OrderPromotion, :count).by(-1) + end + end + + context "when the promotion has been added to a complete order" do + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let(:order) { create(:order_ready_to_ship) } + + before do + order.solidus_promotions << promotion + end + + it "keeps the connection" do + expect { subject }.not_to change(SolidusPromotions::OrderPromotion, :count) + end + end + end + + describe ".ordered_lanes" do + subject { described_class.ordered_lanes } + + it { is_expected.to eq({ "pre" => 0, "default" => 1, "post" => 2 }) } + end + + describe "validations" do + subject(:promotion) { build(:solidus_promotion) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:customer_label) } + it { is_expected.to validate_numericality_of(:usage_limit).is_greater_than(0) } + end + + describe ".advertised" do + let(:promotion) { create(:solidus_promotion) } + let(:advertised_promotion) { create(:solidus_promotion, advertise: true) } + + it "only shows advertised promotions" do + advertised = described_class.advertised + expect(advertised).to include(advertised_promotion) + expect(advertised).not_to include(promotion) + end + end + + describe ".coupons" do + subject { described_class.coupons } + + let(:promotion_code) { create(:solidus_promotion_code) } + let!(:promotion_with_code) { promotion_code.promotion } + let!(:another_promotion_code) { create(:solidus_promotion_code, promotion: promotion_with_code) } + let!(:promotion_without_code) { create(:solidus_promotion) } + + it "returns only distinct promotions with a code associated" do + expect(subject).to eq [promotion_with_code] + end + end + + describe ".active" do + subject { described_class.active } + + let(:promotion) { create(:solidus_promotion, starts_at: Date.yesterday, name: "name1") } + + before { promotion } + + it "doesn't return promotion without benefits" do + expect(subject).to be_empty + end + + context "when promotion has an benefit" do + let(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, starts_at: Date.yesterday, name: "name1") } + + it "returns promotion with benefit" do + expect(subject).to match [promotion] + end + end + + context "when called with a time that is not current" do + subject { described_class.active(4.days.ago) } + + let(:promotion) do + create( + :solidus_promotion, + :with_adjustable_benefit, + starts_at: 5.days.ago, + expires_at: 3.days.ago, + name: "name1" + ) + end + + it "returns promotion that was active then" do + expect(subject).to match [promotion] + end + end + end + + describe ".has_benefits" do + subject { described_class.has_benefits } + + let(:promotion) { create(:solidus_promotion, starts_at: Date.yesterday, name: "name1") } + + before { promotion } + + it "doesn't return promotion without benefits" do + expect(subject).to be_empty + end + + context "when promotion has two benefits" do + let(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, starts_at: Date.yesterday, name: "name1") } + + before do + promotion.benefits << SolidusPromotions::Benefits::AdjustShipment.new(calculator: SolidusPromotions::Calculators::Percent.new) + end + + it "returns distinct promotion" do + expect(subject).to match [promotion] + end + end + end + + describe "#apply_automatically" do + subject { create(:solidus_promotion) } + + it "defaults to false" do + expect(subject.apply_automatically).to eq(false) + end + + context "when set to true" do + before { subject.apply_automatically = true } + + it "remains valid" do + expect(subject).to be_valid + end + + it "invalidates the promotion when it has a path" do + subject.path = "foo" + expect(subject).not_to be_valid + expect(subject.errors).to include(:apply_automatically) + end + end + + context "when the promotion has a code" do + before do + subject.codes.new(value: "foo") + end + + it "cannot be changed to true" do + expect { subject.apply_automatically = true }.to change { subject.valid? }.from(true).to(false) + expect(subject.errors.full_messages).to include("Apply automatically cannot be set to true when promotion code is present") + end + end + end + + describe "#usage_limit_exceeded?" do + subject { promotion.usage_limit_exceeded? } + + shared_examples "it should" do + context "when there is a usage limit" do + context "and the limit is not exceeded" do + let(:usage_limit) { 10 } + + it { is_expected.to be_falsy } + end + + context "and the limit is exceeded" do + let(:usage_limit) { 1 } + + context "on a different order" do + before do + FactoryBot.create( + :completed_order_with_solidus_promotion, + promotion: promotion + ) + end + + it { is_expected.to be_truthy } + end + + context "on the same order" do + it { is_expected.to be_falsy } + end + end + end + + context "when there is no usage limit" do + let(:usage_limit) { nil } + + it { is_expected.to be_falsy } + end + end + + context "with an item-level adjustment" do + let(:promotion) do + FactoryBot.create( + :solidus_promotion, + :with_line_item_adjustment, + code: "discount", + usage_limit: usage_limit + ) + end + + before do + order.solidus_order_promotions.create( + promotion_code: promotion.codes.first, + promotion: promotion + ) + order.recalculate + end + + context "when there are multiple line items" do + let(:order) { FactoryBot.create(:order_with_line_items, line_items_count: 2) } + + describe "the first item" do + let(:promotable) { order.line_items.first } + + it_behaves_like "it should" + end + + describe "the second item" do + let(:promotable) { order.line_items.last } + + it_behaves_like "it should" + end + end + + context "when there is a single line item" do + let(:order) { FactoryBot.create(:order_with_line_items) } + let(:promotable) { order.line_items.first } + + it_behaves_like "it should" + end + end + end + + describe "#usage_count" do + subject { promotion.usage_count } + + let(:promotion) do + FactoryBot.create( + :solidus_promotion, + :with_line_item_adjustment, + code: "discount" + ) + end + + context "when the code is applied to a non-complete order" do + let(:order) { FactoryBot.create(:order_with_line_items) } + + before do + order.solidus_order_promotions.create( + promotion_code: promotion.codes.first, + promotion: promotion + ) + order.recalculate + end + + it { is_expected.to eq 0 } + end + + context "when the code is applied to a complete order" do + let!(:order) do + FactoryBot.create( + :completed_order_with_solidus_promotion, + promotion: promotion + ) + end + + context "and the promo is eligible" do + it { is_expected.to eq 1 } + end + + context "and the promo is ineligible" do + before do + promotion.benefits.first.conditions << SolidusPromotions::Conditions::NthOrder.new(preferred_nth_order: 2) + order.recalculate + end + it { is_expected.to eq 0 } + end + + context "and the order is canceled" do + before { order.cancel! } + + it { is_expected.to eq 0 } + it { expect(order.state).to eq "canceled" } + end + end + end + + describe "#inactive" do + let(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + + it "is not expired" do + expect(promotion).not_to be_inactive + end + + it "is inactive if it hasn't started yet" do + promotion.starts_at = Time.current + 1.day + expect(promotion).to be_inactive + end + + it "is inactive if it has already ended" do + promotion.expires_at = Time.current - 1.day + expect(promotion).to be_inactive + end + + it "is not inactive if it has started already" do + promotion.starts_at = Time.current - 1.day + expect(promotion).not_to be_inactive + end + + it "is not inactive if it has not ended yet" do + promotion.expires_at = Time.current + 1.day + expect(promotion).not_to be_inactive + end + + it "is not inactive if current time is within starts_at and expires_at range" do + promotion.starts_at = Time.current - 1.day + promotion.expires_at = Time.current + 1.day + expect(promotion).not_to be_inactive + end + end + + describe "#not_started?" do + subject { promotion.not_started? } + + let(:promotion) { described_class.new(starts_at: starts_at) } + + context "no starts_at date" do + let(:starts_at) { nil } + + it { is_expected.to be_falsey } + end + + context "when starts_at date is in the past" do + let(:starts_at) { Time.current - 1.day } + + it { is_expected.to be_falsey } + end + + context "when starts_at date is not already reached" do + let(:starts_at) { Time.current + 1.day } + + it { is_expected.to be_truthy } + end + end + + describe "#started?" do + subject { promotion.started? } + + let(:promotion) { described_class.new(starts_at: starts_at) } + + context "when no starts_at date" do + let(:starts_at) { nil } + + it { is_expected.to be_truthy } + end + + context "when starts_at date is in the past" do + let(:starts_at) { Time.current - 1.day } + + it { is_expected.to be_truthy } + end + + context "when starts_at date is not already reached" do + let(:starts_at) { Time.current + 1.day } + + it { is_expected.to be_falsey } + end + end + + describe "#expired?" do + subject { promotion.expired? } + + let(:promotion) { described_class.new(expires_at: expires_at) } + + context "when no expires_at date" do + let(:expires_at) { nil } + + it { is_expected.to be_falsey } + end + + context "when expires_at date is not already reached" do + let(:expires_at) { Time.current + 1.day } + + it { is_expected.to be_falsey } + end + + context "when expires_at date is in the past" do + let(:expires_at) { Time.current - 1.day } + + it { is_expected.to be_truthy } + end + end + + describe "#not_expired?" do + subject { promotion.not_expired? } + + let(:promotion) { described_class.new(expires_at: expires_at) } + + context "when no expired_at date" do + let(:expires_at) { nil } + + it { is_expected.to be_truthy } + end + + context "when expires_at date is not already reached" do + let(:expires_at) { Time.current + 1.day } + + it { is_expected.to be_truthy } + end + + context "when expires_at date is in the past" do + let(:expires_at) { Time.current - 1.day } + + it { is_expected.to be_falsey } + end + end + + describe "#active" do + it "is not active if it has started already" do + promotion.starts_at = Time.current - 1.day + expect(promotion.active?).to eq(false) + end + + it "is not active if it has not ended yet" do + promotion.expires_at = Time.current + 1.day + expect(promotion.active?).to eq(false) + end + + it "is not active if current time is within starts_at and expires_at range" do + promotion.starts_at = Time.current - 1.day + promotion.expires_at = Time.current + 1.day + expect(promotion.active?).to eq(false) + end + + it "is not active if there are no start and end times set" do + promotion.starts_at = nil + promotion.expires_at = nil + expect(promotion.active?).to eq(false) + end + + context "when promotion has an benefit" do + let(:promotion) { create(:solidus_promotion, :with_adjustable_benefit, name: "name1") } + + it "is active if it has started already" do + promotion.starts_at = Time.current - 1.day + expect(promotion.active?).to eq(true) + end + + it "is active if it has not ended yet" do + promotion.expires_at = Time.current + 1.day + expect(promotion.active?).to eq(true) + end + + it "is active if current time is within starts_at and expires_at range" do + promotion.starts_at = Time.current - 1.day + promotion.expires_at = Time.current + 1.day + expect(promotion.active?).to eq(true) + end + + it "is active if there are no start and end times set" do + promotion.starts_at = nil + promotion.expires_at = nil + expect(promotion.active?).to eq(true) + end + + context "when called with a time" do + subject { promotion.active?(1.day.ago) } + + context "if promo was active a day ago" do + before do + promotion.starts_at = 2.days.ago + promotion.expires_at = 1.hour.ago + end + + it { is_expected.to be true } + end + + context "if promo was not active a day ago" do + before do + promotion.starts_at = 1.hour.ago + promotion.expires_at = 1.day.from_now + end + + it { is_expected.to be false } + end + end + end + end + + describe "#products" do + let(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let(:promotion_benefit) { promotion.benefits.first } + + context "when it has product conditions with products associated" do + let(:promotion_condition) { SolidusPromotions::Conditions::Product.new } + + before do + promotion_condition.benefit = promotion_benefit + promotion_condition.products << create(:product) + promotion_condition.save + end + + it "has products" do + expect(promotion.reload.products.size).to eq(1) + end + end + + context "when there's no product condition associated" do + it "does not have products but still return an empty array" do + expect(promotion.products).to be_blank + end + end + end + + # regression for https://github.com/spree/spree/issues/4059 + # admin form posts the code and path as empty string + describe "normalize blank values for path" do + it "will save blank value as nil value instead" do + promotion = SolidusPromotions::Promotion.create(name: "A promotion", customer_label: "nice", path: "") + expect(promotion.path).to be_nil + end + end + + describe "#used_by?" do + subject { promotion.used_by? user, [excluded_order] } + + let(:promotion) { create :solidus_promotion, :with_adjustable_benefit } + let(:user) { create :user } + let(:order) { create :order_with_line_items, user: user } + let(:excluded_order) { create :order_with_line_items, user: user } + + before do + order.user_id = user.id + order.save! + end + + context "when the user has used this promo" do + before do + order.solidus_order_promotions.create( + promotion: promotion + ) + order.recalculate + order.completed_at = Time.current + order.save! + end + + context "when the order is complete" do + it { is_expected.to be true } + + context "when the promotion was not eligible" do + before do + promotion.benefits.first.conditions << SolidusPromotions::Conditions::NthOrder.new(preferred_nth_order: 2) + order.recalculate + end + + it { is_expected.to be false } + end + + context "when the only matching order is the excluded order" do + let(:excluded_order) { order } + + it { is_expected.to be false } + end + end + + context "when the order is not complete" do + let(:order) { create :order, user: user } + + # The before clause above sets the completed at + # value for this order + before { order.update completed_at: nil } + + it { is_expected.to be false } + end + end + + context "when the user has not used this promo" do + it { is_expected.to be false } + end + end + + describe ".original_promotion" do + let(:spree_promotion) { create :promotion, :with_adjustable_action } + let(:solidus_promotion) { create :solidus_promotion, :with_adjustable_benefit } + + subject { solidus_promotion.original_promotion } + + it "can be migrated from spree" do + solidus_promotion.original_promotion = spree_promotion + expect(subject).to eq(spree_promotion) + end + + it "is ok to be new" do + expect(subject).to be_nil + end + end +end diff --git a/promotions/spec/models/solidus_promotions/shipping_rate_discount_spec.rb b/promotions/spec/models/solidus_promotions/shipping_rate_discount_spec.rb new file mode 100644 index 00000000000..d1e1d2432e6 --- /dev/null +++ b/promotions/spec/models/solidus_promotions/shipping_rate_discount_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::ShippingRateDiscount do + subject(:shipping_rate_discount) { build(:solidus_shipping_rate_discount) } + + it { is_expected.to belong_to(:shipping_rate) } + + it { is_expected.to respond_to(:shipping_rate) } + it { is_expected.to respond_to(:benefit) } + it { is_expected.to respond_to(:amount) } + it { is_expected.to respond_to(:display_amount) } + it { is_expected.to respond_to(:label) } +end diff --git a/promotions/spec/models/spree/adjustment_spec.rb b/promotions/spec/models/spree/adjustment_spec.rb new file mode 100644 index 00000000000..e583d589e03 --- /dev/null +++ b/promotions/spec/models/spree/adjustment_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Spree::Adjustment do + describe ".promotion" do + let(:spree_promotion_action) { create(:promotion, :with_action).actions.first } + let!(:spree_promotion_action_adjustment) { create(:adjustment, source: spree_promotion_action) } + let(:solidus_promotion_benefit) { create(:solidus_promotion, :with_adjustable_benefit).benefits.first } + let!(:solidus_promotion_benefit_adjustment) { create(:adjustment, source: solidus_promotion_benefit) } + let(:tax_rate) { create(:tax_rate) } + let!(:tax_adjustment) { create(:adjustment, source: tax_rate) } + + subject { described_class.promotion } + + it { is_expected.to contain_exactly(solidus_promotion_benefit_adjustment, spree_promotion_action_adjustment) } + end + + describe ".solidus_promotion" do + let(:spree_promotion_action) { create(:promotion, :with_action).actions.first } + let!(:spree_promotion_action_adjustment) { create(:adjustment, source: spree_promotion_action) } + let(:solidus_promotion_benefit) { create(:solidus_promotion, :with_adjustable_benefit).benefits.first } + let!(:solidus_promotion_benefit_adjustment) { create(:adjustment, source: solidus_promotion_benefit) } + let(:tax_rate) { create(:tax_rate) } + let!(:tax_adjustment) { create(:adjustment, source: tax_rate) } + + subject { described_class.solidus_promotion } + + it { is_expected.to contain_exactly(solidus_promotion_benefit_adjustment) } + end + + describe "#promotion?" do + let(:source) { create(:solidus_promotion, :with_adjustable_benefit).benefits.first } + let!(:adjustment) { build(:adjustment, source: source) } + + subject { adjustment.promotion? } + + it { is_expected.to be true } + + context "with a Spree Promotion Action source" do + let(:source) { create(:promotion, :with_action).actions.first } + + it { is_expected.to be true } + end + + context "with a Tax Rate source" do + let(:source) { create(:tax_rate) } + + it { is_expected.to be false } + end + end +end diff --git a/promotions/spec/models/spree/line_item_spec.rb b/promotions/spec/models/spree/line_item_spec.rb new file mode 100644 index 00000000000..34a9d58029f --- /dev/null +++ b/promotions/spec/models/spree/line_item_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Spree::LineItem do + it { is_expected.to belong_to(:managed_by_order_benefit).optional } + + describe "#discountable_amount" do + let(:discounts) { [] } + let(:line_item) { Spree::LineItem.new(price: 10, quantity: 2, current_discounts: discounts) } + + subject(:discountable_amount) { line_item.discountable_amount } + + it { is_expected.to eq(20) } + + context "with a proposed discount" do + let(:discounts) do + [ + SolidusPromotions::ItemDiscount.new(item: double, amount: -2, label: "Foo", source: double) + ] + end + + it { is_expected.to eq(18) } + end + end + + describe "#reset_current_discounts" do + let(:line_item) { Spree::LineItem.new } + + subject { line_item.reset_current_discounts } + before do + line_item.current_discounts << SolidusPromotions::ItemDiscount.new(item: double, amount: -2, label: "Foo", source: double) + end + + it "resets the current discounts to an empty array" do + expect { subject }.to change { line_item.current_discounts.length }.from(1).to(0) + end + end + + describe "changing quantities" do + context "when line item is managed by an automation" do + let(:order) { create(:order) } + let(:variant) { create(:variant) } + let(:promotion) { create(:solidus_promotion, apply_automatically: true) } + let(:promotion_benefit) { SolidusPromotions::Benefits::CreateDiscountedItem.create!(calculator: hundred_percent, preferred_variant_id: variant.id, promotion: promotion) } + let(:hundred_percent) { SolidusPromotions::Calculators::Percent.new(preferred_percent: 100) } + + before do + order.line_items.create!(variant: variant, managed_by_order_benefit: promotion_benefit, quantity: 1) + end + + it "makes the line item invalid" do + line_item = order.line_items.first + line_item.quantity = 2 + expect { line_item.save! }.to raise_exception(ActiveRecord::RecordInvalid) + expect(line_item.errors.full_messages.first).to eq("Quantity cannot be changed on a line item managed by a promotion benefit") + end + end + end +end diff --git a/promotions/spec/models/spree/order_recalculator_spec.rb b/promotions/spec/models/spree/order_recalculator_spec.rb new file mode 100644 index 00000000000..91047bb2894 --- /dev/null +++ b/promotions/spec/models/spree/order_recalculator_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Spree::Config.order_recalculator_class do + let(:order) { create(:order) } + subject { described_class.new(order).recalculate } + + it "calls the order promotion syncer" do + expect(SolidusPromotions::MigrationSupport::OrderPromotionSyncer).not_to receive(:new) + subject + end + + context "if config option is set to false" do + around do |example| + SolidusPromotions.config.sync_order_promotions = true + example.run + SolidusPromotions.config.sync_order_promotions = false + end + + it "does not call the order promotion syncer" do + expect_any_instance_of(SolidusPromotions::MigrationSupport::OrderPromotionSyncer).to receive(:call) + subject + end + end +end diff --git a/promotions/spec/models/spree/order_spec.rb b/promotions/spec/models/spree/order_spec.rb new file mode 100644 index 00000000000..7f879da74b7 --- /dev/null +++ b/promotions/spec/models/spree/order_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Spree::Order do + it { is_expected.to have_many :solidus_promotions } + it { is_expected.to have_many :solidus_order_promotions } + + describe "#reset_current_discounts" do + let(:line_item) { Spree::LineItem.new } + let(:shipment) { Spree::Shipment.new } + let(:order) { Spree::Order.new(shipments: [shipment], line_items: [line_item]) } + + subject { order.reset_current_discounts } + + it "resets the current discounts on all line items and shipments" do + expect(line_item).to receive(:reset_current_discounts) + expect(shipment).to receive(:reset_current_discounts) + subject + end + end + + describe "order deletion" do + let(:order) { create(:order) } + let(:promotion) { create(:solidus_promotion) } + + subject { order.destroy } + before do + order.solidus_promotions << promotion + end + + it "deletes join table entries when deleting an order" do + expect { subject }.to change { SolidusPromotions::OrderPromotion.count }.from(1).to(0) + end + end + + describe ".allowed_ransackable_associations" do + subject { described_class.allowed_ransackable_associations } + + it { is_expected.to include("solidus_promotions", "solidus_order_promotions") } + end +end diff --git a/promotions/spec/models/spree/shipment_spec.rb b/promotions/spec/models/spree/shipment_spec.rb new file mode 100644 index 00000000000..3e5ef0b49ac --- /dev/null +++ b/promotions/spec/models/spree/shipment_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Spree::Shipment do + describe "#discountable_amount" do + let(:discounts) { [] } + let(:shipment) { Spree::Shipment.new(amount: 20, current_discounts: discounts) } + + subject(:discountable_amount) { shipment.discountable_amount } + + it { is_expected.to eq(20) } + + context "with a proposed discount" do + let(:discounts) do + [ + SolidusPromotions::ItemDiscount.new(item: double, amount: -2, label: "Foo", source: double) + ] + end + + it { is_expected.to eq(18) } + end + + describe "#reset_current_discounts" do + let(:shipping_rate) { Spree::ShippingRate.new } + let(:shipment) { Spree::Shipment.new(shipping_rates: [shipping_rate]) } + + subject { shipment.reset_current_discounts } + before do + shipment.current_discounts << SolidusPromotions::ItemDiscount.new(item: double, amount: -2, label: "Foo", source: double) + end + + it "resets the current discounts to an empty array and resets current discounts on all shipping rates" do + expect(shipping_rate).to receive(:reset_current_discounts) + expect { subject }.to change { shipment.current_discounts.length }.from(1).to(0) + end + end + end +end diff --git a/promotions/spec/models/spree/shipping_rate_spec.rb b/promotions/spec/models/spree/shipping_rate_spec.rb new file mode 100644 index 00000000000..a7829d86881 --- /dev/null +++ b/promotions/spec/models/spree/shipping_rate_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Spree::ShippingRate do + let(:subject) { build(:shipping_rate) } + + describe "#display_price" do + before { subject.amount = 5 } + + it "returns formatted amount" do + expect(subject.display_price).to eq("$5.00") + end + end + + describe "#total_before_tax" do + subject { shipping_rate.total_before_tax } + + let(:shipping_rate) { build(:shipping_rate, cost: 4) } + + it { is_expected.to eq(4) } + + context "with discounts" do + let(:shipping_rate) { build(:shipping_rate, cost: 4, discounts: discounts) } + let(:discounts) { build_list(:solidus_shipping_rate_discount, 2, amount: -1.5, label: "DISCOUNT") } + + it { is_expected.to eq(1) } + end + end + + describe "#display_total_before_tax" do + subject { shipping_rate.display_total_before_tax } + + let(:shipping_rate) { build(:shipping_rate, cost: 10) } + + it { is_expected.to eq(Spree::Money.new("10.00")) } + end + + describe "#display_promo_total" do + subject { shipping_rate.display_promo_total } + + let(:shipping_rate) { build(:shipping_rate) } + + it { is_expected.to eq(Spree::Money.new("0")) } + end + + describe "#discountable_amount" do + let(:discounts) { [] } + let(:shipping_rate) { Spree::ShippingRate.new(amount: 20, current_discounts: discounts) } + + subject(:discountable_amount) { shipping_rate.discountable_amount } + + it { is_expected.to eq(20) } + + context "with a proposed discount" do + let(:discounts) do + [ + SolidusPromotions::ItemDiscount.new(item: double, amount: -2, label: "Foo", source: double) + ] + end + + it { is_expected.to eq(18) } + end + end + + describe "#reset_current_discounts" do + let(:shipping_rate) { Spree::ShippingRate.new } + + subject { shipping_rate.reset_current_discounts } + before do + shipping_rate.current_discounts << SolidusPromotions::ItemDiscount.new(item: double, amount: -2, label: "Foo", source: double) + end + + it "resets the current discounts to an empty array" do + expect { subject }.to change { shipping_rate.current_discounts.length }.from(1).to(0) + end + end +end diff --git a/promotions/spec/rails_helper.rb b/promotions/spec/rails_helper.rb new file mode 100644 index 00000000000..f5d21ccef3a --- /dev/null +++ b/promotions/spec/rails_helper.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Configure Rails Environment +ENV["RAILS_ENV"] = "test" + +require "spec_helper" +require "solidus_legacy_promotions" + +# SOLIDUS DUMMY APP +require "spree/testing_support/dummy_app" +DummyApp.setup( + gem_root: File.expand_path("..", __dir__), + lib_name: "solidus_promotions" +) + +# Calling `draw` will completely rewrite the routes defined in the dummy app, +# so we need to include the main solidus route. +DummyApp::Application.routes.draw do + mount SolidusAdmin::Engine, at: "/admin", constraints: ->(req) { + req.cookies["solidus_admin"] == "true" || + req.params["solidus_admin"] == "true" || + SolidusPromotions.config.use_new_admin? + } + mount SolidusPromotions::Engine, at: "/" + mount Spree::Core::Engine, at: "/" +end + +# Turbo will try to autoload ActionCable if we allow `app/channels`. +# Backport of https://github.com/hotwired/turbo-rails/pull/601 +# Can go once `turbo-rails` 2.0.7 is released. +Rails.autoloaders.once.do_not_eager_load("#{Turbo::Engine.root}/app/channels") + +require "solidus_admin/testing_support/admin_assets" + +# AXE - ACCESSIBILITY +require "axe-rspec" +require "axe-capybara" + +# Feature helpers for the new admin +require "solidus_admin/testing_support/feature_helpers" +require "shoulda-matchers" +# Explicitly load activemodel mocks +require "rspec-activemodel-mocks" + +# Requires supporting ruby files with custom matchers and macros, etc, +# in spec/support/ and its subdirectories. +Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f } + +require "rspec/rails" +require "database_cleaner" + +Dir["./spec/support/**/*.rb"].sort.each { |f| require f } + +require "spree/testing_support/preferences" +require "spree/testing_support/rake" +require "spree/testing_support/job_helpers" +require "spree/api/testing_support/helpers" +require "spree/testing_support/url_helpers" +require "spree/testing_support/authorization_helpers" +require "spree/testing_support/controller_requests" +require "cancan/matchers" +require "spree/testing_support/capybara_ext" + +require "selenium/webdriver" +# Requires factories defined in Solidus core and this extension. +# See: lib/solidus_promotions/testing_support/factories.rb +require "spree/testing_support/factory_bot" +require "solidus_legacy_promotions/testing_support/factory_bot" +require "solidus_promotions/testing_support/factory_bot" +Spree::TestingSupport::FactoryBot.add_definitions! +SolidusLegacyPromotions::TestingSupport::FactoryBot.add_definitions! +SolidusPromotions::TestingSupport::FactoryBot.add_paths_and_load! + +Spree::Config.order_contents_class = "Spree::SimpleOrderContents" +Spree::Config.promotions = SolidusPromotions.configuration +ActiveJob::Base.queue_adapter = :test +# Allow Capybara to find elements by aria-label attributes +Capybara.enable_aria_label = true + +RSpec.configure do |config| + config.infer_spec_type_from_file_location! + config.use_transactional_fixtures = true + + config.include SolidusPromotions::Engine.routes.url_helpers, type: :request + config.include FactoryBot::Syntax::Methods + config.include Shoulda::Matchers::ActiveRecord, type: :model + + config.around :each, :solidus_admin, :js do |example| + SolidusPromotions.config.use_new_admin = true + example.run + SolidusPromotions.config.use_new_admin = false + end + config.include SolidusAdmin::TestingSupport::FeatureHelpers, type: :feature, solidus_admin: true +end + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end diff --git a/promotions/spec/requests/solidus_promotions/api/checkout_spec.rb b/promotions/spec/requests/solidus_promotions/api/checkout_spec.rb new file mode 100644 index 00000000000..eb8f0688c1a --- /dev/null +++ b/promotions/spec/requests/solidus_promotions/api/checkout_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Api Feature Specs", type: :request do + before do + stub_spree_preferences(Spree::Api::Config, requires_authentication: false) + end + let!(:promotion) { FactoryBot.create(:solidus_promotion, :with_order_adjustment, code: "foo", weighted_order_adjustment_amount: 10) } + let(:promotion_code) { promotion.codes.first } + let!(:store) { FactoryBot.create(:store) } + let(:bill_address) { FactoryBot.create(:address) } + let(:ship_address) { FactoryBot.create(:address) } + let(:variant_1) { FactoryBot.create(:variant, price: 100.00) } + let(:variant_2) { FactoryBot.create(:variant, price: 200.00) } + let(:payment_method) { FactoryBot.create(:check_payment_method) } + let!(:shipping_method) do + FactoryBot.create(:shipping_method).tap do |shipping_method| + shipping_method.zones.first.zone_members.create!(zoneable: ship_address.country) + shipping_method.calculator.set_preference(:amount, 10.0) + end + end + + def parsed + JSON.parse(response.body) + end + + def login + expect { + post "/api/users", params: { + user: { + email: "featurecheckoutuser@example.com", + password: "featurecheckoutuser" + } + } + }.to change { Spree.user_class.count }.by 1 + expect(response).to have_http_status(:created) + @user = Spree.user_class.find(parsed["id"]) + + # copied from api testing helpers support since we can't really sign in + allow(Spree.user_class).to receive(:find_by).with(hash_including(:spree_api_key)) { @user } + end + + def create_order(order_params: {}) + expect { post "/api/orders", params: order_params }.to change { Spree::Order.count }.by 1 + expect(response).to have_http_status(:created) + @order = Spree::Order.find(parsed["id"]) + expect(@order.email).to eq "featurecheckoutuser@example.com" + end + + def update_order(order_params: {}) + put "/api/orders/#{@order.number}", params: order_params + expect(response).to have_http_status(:ok) + end + + def create_line_item(variant, quantity = 1) + expect { + post "/api/orders/#{@order.number}/line_items", + params: { line_item: { variant_id: variant.id, quantity: quantity } } + }.to change { @order.line_items.count }.by 1 + expect(response).to have_http_status(:created) + end + + def add_promotion(_promotion) + expect { + post "/api/orders/#{@order.number}/coupon_codes", + params: { coupon_code: promotion_code.value } + }.to change { @order.solidus_promotions.count }.by 1 + expect(response).to have_http_status(:ok) + end + + def add_address(address, billing: true) + address_type = billing ? :bill_address : :ship_address + # It seems we are missing an order-scoped address api endpoint since we need + # to use update here. + expect { + update_order(order_params: { order: { address_type => address.as_json.except("id") } }) + }.to change { @order.reload.public_send(address_type) }.to address + end + + def add_payment + expect { + post "/api/orders/#{@order.number}/payments", + params: { payment: { payment_method_id: payment_method.id } } + }.to change { @order.reload.payments.count }.by 1 + expect(response).to have_http_status(:created) + expect(@order.payments.last.payment_method).to eq payment_method + end + + def advance + put "/api/checkouts/#{@order.number}/advance" + expect(response).to have_http_status(:ok) + end + + def complete + put "/api/checkouts/#{@order.number}/complete" + expect(response).to have_http_status(:ok) + end + + def assert_order_expectations + @order.reload + expect(@order.state).to eq "complete" + expect(@order.completed_at).to be_a ActiveSupport::TimeWithZone + expect(@order.item_total).to eq 600.00 + expect(@order.total).to eq 600.00 + expect(@order.adjustment_total).to eq(-10.00) + expect(@order.shipment_total).to eq 10.00 + expect(@order.user).to eq @user + expect(@order.bill_address).to eq bill_address + expect(@order.ship_address).to eq ship_address + expect(@order.payments.length).to eq 1 + expect(@order.line_items.any? { |li| li.variant == variant_1 && li.quantity == 2 }).to eq true + expect(@order.line_items.any? { |li| li.variant == variant_2 && li.quantity == 2 }).to eq true + expect(@order.solidus_promotions).to eq [promotion] + end + + it "is able to checkout with individualized requests" do + login + create_order + + create_line_item(variant_1, 2) + add_promotion(promotion) + create_line_item(variant_2, 2) + + add_address(bill_address) + add_address(ship_address, billing: false) + + add_payment + + advance + complete + + assert_order_expectations + end + + it "is able to checkout with the create request" do + login + + create_order(order_params: { + order: { + bill_address: bill_address.as_json.except("id"), + ship_address: ship_address.as_json.except("id"), + line_items: { + 0 => { variant_id: variant_1.id, quantity: 2 }, + 1 => { variant_id: variant_2.id, quantity: 2 } + } + # Would like to do this, but it puts the payment in a complete state, + # which the order does not like when transitioning from confirm to complete + # since it looks to process pending payments. + # payments: [ { payment_method: payment_method.name, state: "pending" } ], + } + }) + + add_promotion(promotion) + add_payment + + advance + complete + + assert_order_expectations + end + + it "is able to checkout with the update request" do + login + + create_order + update_order(order_params: { + order: { + bill_address: bill_address.as_json.except("id"), + ship_address: ship_address.as_json.except("id"), + line_items: { + 0 => { variant_id: variant_1.id, quantity: 2 }, + 1 => { variant_id: variant_2.id, quantity: 2 } + } + # Would like to do this, but it puts the payment in a complete state, + # which the order does not like when transitioning from confirm to complete + # since it looks to process pending payments. + # payments: [ { payment_method: payment_method.name, state: "pending" } ], + } + }) + + add_promotion(promotion) + add_payment + + advance + complete + + assert_order_expectations + end +end diff --git a/promotions/spec/requests/solidus_promotions/backend/benefits_request_spec.rb b/promotions/spec/requests/solidus_promotions/backend/benefits_request_spec.rb new file mode 100644 index 00000000000..a0d3e89dbbf --- /dev/null +++ b/promotions/spec/requests/solidus_promotions/backend/benefits_request_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Admin::Benefits", type: :request do + stub_authorization! + + let!(:promotion) { create(:solidus_promotion) } + + it "can create a promotion benefit of a valid type" do + post solidus_promotions.admin_promotion_benefits_path(promotion_id: promotion.id), params: { + benefit: { + type: "SolidusPromotions::Benefits::AdjustLineItem", + calculator_attributes: { type: "SolidusPromotions::Calculators::FlatRate" } + } + } + expect(response).to be_redirect + expect(response).to redirect_to solidus_promotions.edit_admin_promotion_path(promotion) + expect(promotion.benefits.count).to eq(1) + end + + it "can not create a promotion benefit of an invalid type" do + post solidus_promotions.admin_promotion_benefits_path(promotion_id: promotion.id), params: { + benefit: { type: "Spree::InvalidType" } + } + expect(response).to be_redirect + expect(response).to redirect_to solidus_promotions.edit_admin_promotion_path(promotion) + expect(promotion.benefits.count).to eq(0) + end +end diff --git a/promotions/spec/requests/solidus_promotions/backend/conditions_request_spec.rb b/promotions/spec/requests/solidus_promotions/backend/conditions_request_spec.rb new file mode 100644 index 00000000000..b7274c5b8cf --- /dev/null +++ b/promotions/spec/requests/solidus_promotions/backend/conditions_request_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Admin::Conditions", type: :request do + let!(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + let(:benefit) { promotion.benefits.first } + + context "when the user is authorized" do + stub_authorization! do |_u| + SolidusPromotions::PermissionSets::PromotionManagement.new(self).activate! + end + + it "can create a promotion condition of a valid type" do + post solidus_promotions.admin_promotion_benefit_conditions_path(promotion, benefit), params: { + condition: { type: "SolidusPromotions::Conditions::Product" } + } + expect(response).to be_redirect + expect(response).to redirect_to solidus_promotions.edit_admin_promotion_path(promotion) + expect(benefit.conditions.count).to eq(1) + end + + it "can not create a promotion condition of an invalid type" do + post solidus_promotions.admin_promotion_benefit_conditions_path(promotion, benefit), params: { + condition: { type: "Spree::InvalidType" } + } + expect(response).to be_redirect + expect(response).to redirect_to solidus_promotions.edit_admin_promotion_path(promotion) + expect(benefit.conditions.count).to eq(0) + end + end + + context "when the user is not authorized" do + it "redirects the user to login" do + post solidus_promotions.admin_promotion_benefit_conditions_path(promotion, benefit), params: { + condition: { type: "SolidusPromotions::Conditions::Product" } + } + expect(response).to be_redirect + end + end +end diff --git a/promotions/spec/requests/solidus_promotions/backend/promotions_request_spec.rb b/promotions/spec/requests/solidus_promotions/backend/promotions_request_spec.rb new file mode 100644 index 00000000000..8b78c5fc6cc --- /dev/null +++ b/promotions/spec/requests/solidus_promotions/backend/promotions_request_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Admin::Promotions", type: :request do + describe "GET /index" do + stub_authorization! + + it "is successful" do + get admin_promotions_path + expect(response).to be_successful + end + end +end diff --git a/promotions/spec/shared_examples/calculator_shared_examples.rb b/promotions/spec/shared_examples/calculator_shared_examples.rb new file mode 100644 index 00000000000..0eb256ce669 --- /dev/null +++ b/promotions/spec/shared_examples/calculator_shared_examples.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.shared_examples_for "a calculator with a description" do + describe ".description" do + subject { described_class.description } + + it "has a description" do + expect(subject.size).to be > 0 + end + end +end diff --git a/promotions/spec/spec_helper.rb b/promotions/spec/spec_helper.rb new file mode 100644 index 00000000000..ff8a2dbd06b --- /dev/null +++ b/promotions/spec/spec_helper.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +if ENV["COVERAGE"] + require "simplecov" + if ENV["COVERAGE_DIR"] + SimpleCov.coverage_dir(ENV["COVERAGE_DIR"]) + end + SimpleCov.command_name("solidus:core") + SimpleCov.merge_timeout(3600) + SimpleCov.start("rails") +end + +require "rspec/core" + +require "spree/testing_support/flaky" +require "spree/testing_support/partial_double_verification" +require "spree/testing_support/silence_deprecations" +require "spree/testing_support/preferences" +require "spree/deprecator" +require "spree/core/version" +require "spree/config" + +require "solidus_promotions" + +Spree::Config.promotions = SolidusPromotions.configuration + +RSpec.configure do |config| + config.disable_monkey_patching! + config.color = true + config.expect_with :rspec do |c| + c.syntax = :expect + end + config.mock_with :rspec do |c| + c.syntax = :expect + end + + config.include Spree::TestingSupport::Preferences + + config.filter_run focus: true + config.run_all_when_everything_filtered = true + + config.example_status_persistence_file_path = "./spec/examples.txt" + + config.order = :random + + Kernel.srand config.seed +end diff --git a/promotions/spec/subscribers/solidus_promotions/order_promotion_subscriber_spec.rb b/promotions/spec/subscribers/solidus_promotions/order_promotion_subscriber_spec.rb new file mode 100644 index 00000000000..eccfc045a5b --- /dev/null +++ b/promotions/spec/subscribers/solidus_promotions/order_promotion_subscriber_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SolidusPromotions::OrderPromotionSubscriber do + let(:bus) { Omnes::Bus.new } + + before do + bus.register(:order_emptied) + + described_class.new.subscribe_to(bus) + end + + describe "on :order_emptied" do + it "clears connected promotions" do + promotion = create(:solidus_promotion) + order = create(:order) + order.solidus_promotions << promotion + expect(order.solidus_promotions).not_to be_empty + + bus.publish(:order_emptied, order: order) + expect(order.solidus_promotions.reload).to be_empty + end + end +end diff --git a/promotions/spec/system/solidus_promotions/admin/orders/index_spec.rb b/promotions/spec/system/solidus_promotions/admin/orders/index_spec.rb new file mode 100644 index 00000000000..dc6304eb355 --- /dev/null +++ b/promotions/spec/system/solidus_promotions/admin/orders/index_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Orders", type: :feature, solidus_admin: true do + let(:promotion) { create(:solidus_promotion, name: "10OFF") } + let!(:order_with_promotion) { create(:completed_order_with_solidus_promotion, number: "R123456789", promotion: promotion) } + let!(:order_without_promotion) { create(:completed_order_with_totals, number: "R987654321") } + + before { sign_in create(:admin_user, email: "admin@example.com") } + + it "lists products", :js, :flaky do + visit "/admin/orders" + + click_button "Filter" + + within("div[role=search]") do + expect(page).to have_content("Promotions") + find(:xpath, "//summary[normalize-space(text())='Promotions']").click + end + check "10OFF" + expect(page).to have_content("R123456789") + expect(page).not_to have_content("R987654321") + expect(page).to be_axe_clean + end +end diff --git a/promotions/spec/system/solidus_promotions/admin/orders/show/adjustments_spec.rb b/promotions/spec/system/solidus_promotions/admin/orders/show/adjustments_spec.rb new file mode 100644 index 00000000000..a49c520f0a4 --- /dev/null +++ b/promotions/spec/system/solidus_promotions/admin/orders/show/adjustments_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Order", :js, type: :feature, solidus_admin: true do + let(:admin) { create(:admin_user) } + let(:order) { create(:order, number: "R123456789") } + + before do + allow(SolidusAdmin::Config).to receive(:enable_alpha_features?) { true } + sign_in admin + end + + context "with a promotion adjustment" do + let(:order) { create(:order_ready_to_ship, number: "R123456789") } + let(:promotion) { create(:solidus_promotion, :with_adjustable_benefit) } + + before do + Spree::Adjustment.create!( + order: order, + source: promotion.benefits.first, + adjustable: order.line_items.first, + amount: 2, + label: "Promotion Adjustment" + ) + end + + it "can display the adjustment" do + visit "/admin/orders/R123456789" + + click_on "Adjustments" + expect(page).to have_content("Promotion Adjustment") + expect(page).to be_axe_clean + end + end +end diff --git a/promotions/spec/system/solidus_promotions/admin/promotion_categories_spec.rb b/promotions/spec/system/solidus_promotions/admin/promotion_categories_spec.rb new file mode 100644 index 00000000000..1513dc9f907 --- /dev/null +++ b/promotions/spec/system/solidus_promotions/admin/promotion_categories_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Promotion Categories", :js, type: :feature, solidus_admin: true do + before { sign_in create(:admin_user, email: "admin@example.com") } + + it "lists promotion categories and allows deleting them" do + create(:solidus_promotion_category, name: "test1", code: "code1") + create(:solidus_promotion_category, name: "test2", code: "code2") + + visit "/admin/solidus/promotion_categories" + expect(page).to have_content("test1") + expect(page).to have_content("test2") + + expect(page).to be_axe_clean + + select_row("test1") + click_on "Delete" + expect(page).to have_content("Promotion Categories were successfully removed.") + expect(page).not_to have_content("test1") + expect(SolidusPromotions::PromotionCategory.count).to eq(1) + end +end diff --git a/promotions/spec/system/solidus_promotions/admin/promotions_spec.rb b/promotions/spec/system/solidus_promotions/admin/promotions_spec.rb new file mode 100644 index 00000000000..b49bbd94d37 --- /dev/null +++ b/promotions/spec/system/solidus_promotions/admin/promotions_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Promotions", :js, type: :feature, solidus_admin: true do + before { sign_in create(:admin_user, email: "admin@example.com") } + + it "lists promotions and allows deleting them" do + create(:solidus_promotion, :with_adjustable_benefit, name: "My active Promotion") + create(:solidus_promotion, name: "My draft Promotion") + create(:solidus_promotion, :with_adjustable_benefit, name: "My expired Promotion", expires_at: 1.day.ago) + create(:solidus_promotion, :with_adjustable_benefit, name: "My future Promotion", starts_at: 1.day.from_now) + + visit "/admin/solidus/promotions" + expect(page).to have_content("My active Promotion") + click_on "Draft" + expect(page).to have_content("My draft Promotion", wait: 30) + click_on "Future" + expect(page).to have_content("My future Promotion", wait: 30) + click_on "Expired" + expect(page).to have_content("My expired Promotion", wait: 30) + click_on "All" + expect(page).to have_content("My active Promotion", wait: 30) + expect(page).to have_content("My draft Promotion") + expect(page).to have_content("My future Promotion") + expect(page).to have_content("My expired Promotion") + + expect(page).to be_axe_clean + + select_row("My active Promotion") + click_on "Delete" + expect(page).to have_content("Promotions were successfully removed.") + expect(page).not_to have_content("My active Promotion") + expect(SolidusPromotions::Promotion.count).to eq(3) + end +end diff --git a/promotions/spec/system/solidus_promotions/backend/main_menu_spec.rb b/promotions/spec/system/solidus_promotions/backend/main_menu_spec.rb new file mode 100644 index 00000000000..73021eba6aa --- /dev/null +++ b/promotions/spec/system/solidus_promotions/backend/main_menu_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Main Menu", type: :feature do + context "as admin user" do + stub_authorization! + + context "visiting the homepage" do + before(:each) do + visit spree.admin_path + end + + it "should have a link to promotions" do + expect(page).to have_link("Promotions", href: solidus_promotions.admin_promotions_path, count: 2) + end + it "should have a link to legacy promotions" do + expect(page).to have_link("Legacy Promotions", href: spree.admin_promotions_path, count: 2) + end + end + + context "visiting the promotions tab" do + before(:each) do + visit solidus_promotions.admin_promotions_path + end + + it "should have a link to promotions" do + within(".selected .admin-subnav") { expect(page).to have_link("Promotions", href: solidus_promotions.admin_promotions_path) } + end + + it "should have a link to promotion categories" do + within(".selected .admin-subnav") { expect(page).to have_link("Promotion Categories", href: solidus_promotions.admin_promotion_categories_path) } + end + end + + context "visiting the legacy promotions tab" do + before(:each) do + visit spree.admin_promotions_path + end + + it "should have a link to promotions" do + within(".selected .admin-subnav") { expect(page).to have_link("Legacy Promotions", href: spree.admin_promotions_path) } + end + + it "should have a link to promotion categories" do + within(".selected .admin-subnav") { expect(page).to have_link("Legacy Promotion Categories", href: spree.admin_promotion_categories_path) } + end + end + end +end diff --git a/promotions/spec/system/solidus_promotions/backend/promotion_categories_spec.rb b/promotions/spec/system/solidus_promotions/backend/promotion_categories_spec.rb new file mode 100644 index 00000000000..5483e59bf7e --- /dev/null +++ b/promotions/spec/system/solidus_promotions/backend/promotion_categories_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Promotion Categories", type: :system do + stub_authorization! + + context "index" do + before do + create(:solidus_promotion_category, name: "name1", code: "code1") + create(:solidus_promotion_category, name: "name2", code: "code2") + visit solidus_promotions.admin_promotion_categories_path + end + + context "listing promotion categories" do + it "lists the existing promotion categories" do + within_row(1) do + expect(column_text(1)).to eq("name1") + expect(column_text(2)).to eq("code1") + end + + within_row(2) do + expect(column_text(1)).to eq("name2") + expect(column_text(2)).to eq("code2") + end + end + end + end + + context "create" do + before do + visit solidus_promotions.admin_promotion_categories_path + click_on "New Promotion Category" + end + + it "allows an admin to create a new promotion category" do + fill_in "promotion_category_name", with: "promotion test" + fill_in "promotion_category_code", with: "prtest" + click_button "Create" + expect(page).to have_content("successfully created!") + end + + it "does not allow admin to create promotion category when invalid data" do + fill_in "promotion_category_name", with: "" + fill_in "promotion_category_code", with: "prtest" + click_button "Create" + expect(page).to have_content("Name can't be blank") + end + end + + context "edit" do + before do + create(:solidus_promotion_category, name: "name1") + visit solidus_promotions.admin_promotion_categories_path + within_row(1) { click_icon :edit } + end + + it "allows an admin to edit an existing promotion category" do + fill_in "promotion_category_name", with: "name 99" + click_button "Update" + expect(page).to have_content("successfully updated!") + expect(page).to have_content("name 99") + end + + it "shows validation errors" do + fill_in "promotion_category_name", with: "" + click_button "Update" + expect(page).to have_content("Name can't be blank") + end + end + + context "delete" do + before do + create(:solidus_promotion_category, name: "name1") + visit solidus_promotions.admin_promotion_categories_path + end + + it "allows an admin to delete an existing promotion category", js: true do + accept_alert do + click_icon :trash + end + expect(page).to have_content("successfully removed!") + end + end +end diff --git a/promotions/spec/system/solidus_promotions/backend/promotion_code_batches_spec.rb b/promotions/spec/system/solidus_promotions/backend/promotion_code_batches_spec.rb new file mode 100644 index 00000000000..aaa1fc5b53f --- /dev/null +++ b/promotions/spec/system/solidus_promotions/backend/promotion_code_batches_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.feature "Promotion Code Batches", partial_double_verification: false do + stub_authorization! + + describe "create" do + let(:promotion) { create :solidus_promotion } + + before do + allow_any_instance_of(ApplicationController).to receive(:spree_current_user) { build(:user, id: 123) } + visit solidus_promotions.new_admin_promotion_promotion_code_batch_path(promotion) + end + + def create_code_batch + fill_in "Base code", with: "base" + fill_in "Number of codes", with: 3 + click_button "Create" + end + + it "renders partial without 'Per code usage limit' " do + expect(page).to_not have_field("promotion_per_code_usage_limit") + end + + it "creates a new promotion code batch and disables the submit button", :js do + create_code_batch + + expect(page).to have_content "Code batch has been successfully created!" + + visit solidus_promotions.new_admin_promotion_promotion_code_batch_path(promotion) + + page.execute_script <<~JS + document.querySelectorAll('form').forEach(function(element) { + addEventListener('submit', function(element) { + element.preventDefault(); + }) + }); + JS + + create_code_batch + + expect(page).to have_button("Create", disabled: true) + end + end +end diff --git a/promotions/spec/system/solidus_promotions/backend/promotion_codes_spec.rb b/promotions/spec/system/solidus_promotions/backend/promotion_codes_spec.rb new file mode 100644 index 00000000000..98b61a77eea --- /dev/null +++ b/promotions/spec/system/solidus_promotions/backend/promotion_codes_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.feature "Promotion Codes", partial_double_verification: false do + stub_authorization! + + describe "Admin" do + let(:promotion) { create :solidus_promotion, code: "10off" } + + before do + allow_any_instance_of(ApplicationController).to receive(:spree_current_user) { build(:user, id: 123) } + visit solidus_promotions.admin_promotion_promotion_codes_path(promotion) + end + + it "renders any promotion codes' " do + expect(page).to have_content("10off") + end + + it "creates new promotion codes" do + click_link "Create promotion code" + fill_in "promotion_code_value", with: "20off" + click_button "Create" + expect(page).to have_content("Promotion code has been successfully created!") + end + + context "when downloading a CSV" do + let!(:promotion_code) { create :solidus_promotion_code, promotion: promotion } + + it "downloads a CSV file with the promotion codes" do + click_link "Download codes list" + expect(page.response_headers["Content-Type"]).to eq("text/csv") + expect(page.response_headers["Content-Disposition"]).to include("attachment") + expect(page.response_headers["Content-Disposition"]).to include("promotion-code-list-#{promotion.id}.csv") + expect(page.body).to include(promotion_code.value) + end + end + end +end diff --git a/promotions/spec/system/solidus_promotions/backend/promotions_spec.rb b/promotions/spec/system/solidus_promotions/backend/promotions_spec.rb new file mode 100644 index 00000000000..2357a27a74e --- /dev/null +++ b/promotions/spec/system/solidus_promotions/backend/promotions_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Promotions admin", type: :system do + stub_authorization! + + describe "#index" do + let!(:promotion1) do + create(:solidus_promotion, :with_adjustable_benefit, name: "name1", code: "code1", path: "path1", lane: "pre", updated_at: 2.days.ago) + end + let!(:promotion2) do + create(:solidus_promotion, :with_adjustable_benefit, name: "name2", code: "code2", path: "path2", lane: "default", updated_at: 10.days.ago) + end + let!(:promotion3) do + create( + :solidus_promotion, + :with_adjustable_benefit, + lane: "post", + name: "name3", + code: "code3", + path: "path3", + updated_at: 5.days.ago, + expires_at: Date.yesterday + ) + end + let!(:inactive_promotion) { create(:solidus_promotion, name: "My inactive Promotion", starts_at: 1.day.ago, updated_at: 20.days.ago) } + + let!(:category) { create :solidus_promotion_category } + + it "succeeds" do + visit solidus_promotions.admin_promotions_path + [promotion3, promotion2, promotion1].map(&:name).each do |promotion_name| + expect(page).to have_content promotion_name + end + end + + it "shows promotion categories" do + visit solidus_promotions.admin_promotions_path + expect(page).to have_select( + SolidusPromotions::PromotionCategory.model_name.human, + options: ["All", category.name] + ) + end + + describe "search" do + it "pages results" do + visit solidus_promotions.admin_promotions_path(per_page: "1") + expect(page).to have_content(promotion1.name) + expect(page).not_to have_content(promotion3.name) + end + + it "filters by name" do + visit solidus_promotions.admin_promotions_path(q: { name_cont: promotion1.name }) + expect(page).to have_content(promotion1.name) + expect(page).not_to have_content(promotion2.name) + end + + it "filters by code" do + visit solidus_promotions.admin_promotions_path(q: { codes_value_cont: promotion1.codes.first.value }) + expect(page).to have_content(promotion1.name) + expect(page).not_to have_content(promotion2.name) + end + + it "filters by path" do + visit solidus_promotions.admin_promotions_path(q: { path_cont: promotion1.path }) + expect(page).to have_content(promotion1.name) + expect(page).not_to have_content(promotion2.name) + end + + it "filters by active date" do + visit solidus_promotions.admin_promotions_path(q: { active: Time.current }) + expect(page).to have_content(promotion1.name) + expect(page).to have_content(promotion2.name) + expect(page).not_to have_content(promotion3.name) + end + + it "filters by active the day before yesterday" do + visit solidus_promotions.admin_promotions_path(q: { active: 2.days.ago }) + expect(page).to have_content(promotion1.name) + expect(page).to have_content(promotion2.name) + expect(page).to have_content(promotion3.name) + end + + it "filters by lane" do + visit solidus_promotions.admin_promotions_path(q: { lane_eq: :pre }) + expect(page).to have_content(promotion1.name) + expect(page).not_to have_content(promotion2.name) + expect(page).not_to have_content(promotion3.name) + end + + it "sorts by updated_at by default" do + visit solidus_promotions.admin_promotions_path + expect(page.text).to match(/.*name1.*name3.*name2.*/m) + end + end + end + + describe "Creating a promotion" do + it "allows creating a promotion with the new UI", :js do + visit solidus_promotions.admin_promotions_path + click_link "New Promotion" + expect(page).to have_field("Name") + expect(page).to have_field("Starts at") + expect(page).to have_field("Expires at") + expect(page).to have_field("Description") + fill_in("Name", with: "March 2023 Giveaway") + fill_in("Customer-facing label", with: "20 percent off") + fill_in("Starts at", with: Time.current) + fill_in("Expires at", with: 1.week.from_now) + check("Apply automatically") + click_button("Create") + expect(page).to have_content("March 2023 Giveaway") + promotion = SolidusPromotions::Promotion.first + + within("#new_benefit_promotion_#{promotion.id}") do + click_link("Add Benefit") + select("Discount matching line items", from: "Type") + select("Flat Rate", from: "Calculator type") + fill_in("benefit_calculator_attributes_preferred_amount", with: 20) + click_button("Add") + end + expect(page).to have_selector(".card-header", text: "Discount matching line items") + benefit = promotion.benefits.first + + within("#benefits_adjust_line_item_#{benefit.id}_promotion_#{promotion.id}") do + fill_in("benefit_calculator_attributes_preferred_amount", with: 30) + click_button("Update") + end + expect(benefit.reload.calculator.preferred_amount).to eq(30) + + click_link("Add Condition") + select("First Order", from: "Condition Type") + click_button("Add") + expect(page).to have_content("Must be the customer's first order") + expect(SolidusPromotions::Condition.first).to be_a(SolidusPromotions::Conditions::FirstOrder) + within("#benefits_adjust_line_item_#{benefit.id}_conditions") do + find(".delete").click + end + expect(page).not_to have_content("Must be the customer's first order") + expect(promotion.conditions).to be_empty + + click_link("Add Condition") + select("Item Total", from: "Condition Type") + fill_in("condition_preferred_amount", with: 200) + click_button("Add") + + expect(page).to have_content("Order total must be greater than or equal to the specified amount") + + within("#benefits_adjust_line_item_#{benefit.id}_conditions") do + expect(find("#condition_preferred_amount").value).to eq("200.00") + fill_in("condition_preferred_amount", with: 300) + click_button("Update") + expect(find("#condition_preferred_amount").value).to eq("300.00") + end + + within("#benefits_adjust_line_item_#{benefit.id}_header") do + find(".delete").click + end + expect(page).to have_content("Benefit has been successfully removed!") + end + end +end