diff --git a/ecommerce/pricing/lib/pricing.rb b/ecommerce/pricing/lib/pricing.rb index 789b5e0f..64b5735e 100644 --- a/ecommerce/pricing/lib/pricing.rb +++ b/ecommerce/pricing/lib/pricing.rb @@ -86,6 +86,10 @@ def call(event_store, command_bus) RemoveFreeProductFromOrder, RemoveFreeProductFromOrderHandler.new(event_store) ) + command_bus.register( + UseCoupon, + UseCouponHandler.new(event_store) + ) event_store.subscribe(CalculateOrderTotalValue, to: [ PriceItemAdded, PriceItemRemoved, diff --git a/ecommerce/pricing/lib/pricing/commands.rb b/ecommerce/pricing/lib/pricing/commands.rb index 596077c0..94bb2596 100644 --- a/ecommerce/pricing/lib/pricing/commands.rb +++ b/ecommerce/pricing/lib/pricing/commands.rb @@ -80,4 +80,12 @@ class RemoveFreeProductFromOrder < Infra::Command alias aggregate_id order_id end + + class UseCoupon < Infra::Command + attribute :order_id, Infra::Types::UUID + attribute :coupon_id, Infra::Types::UUID + attribute :discount, Infra::Types::CouponDiscount + + alias aggregate_id order_id + end end diff --git a/ecommerce/pricing/lib/pricing/events.rb b/ecommerce/pricing/lib/pricing/events.rb index 37e57a76..79120e3f 100644 --- a/ecommerce/pricing/lib/pricing/events.rb +++ b/ecommerce/pricing/lib/pricing/events.rb @@ -65,4 +65,10 @@ class FreeProductRemovedFromOrder < Infra::Event attribute :order_id, Infra::Types::UUID attribute :product_id, Infra::Types::UUID end + + class CouponUsed < Infra::Event + attribute :order_id, Infra::Types::UUID + attribute :coupon_id, Infra::Types::UUID + attribute :discount, Infra::Types::CouponDiscount + end end diff --git a/ecommerce/pricing/lib/pricing/offer.rb b/ecommerce/pricing/lib/pricing/offer.rb index 38b22144..34675493 100644 --- a/ecommerce/pricing/lib/pricing/offer.rb +++ b/ecommerce/pricing/lib/pricing/offer.rb @@ -111,6 +111,16 @@ def calculate_sub_amounts(pricing_catalog, time_promotions_discount) end end + def use_coupon(coupon_id, discount) + apply CouponUsed.new( + data: { + order_id: @id, + coupon_id: coupon_id, + discount: discount + } + ) + end + private on PriceItemAdded do |event| @@ -151,6 +161,9 @@ def calculate_total_sub_discounts(pricing_catalog, time_promotions_discount) @list.sub_discounts(pricing_catalog, time_promotions_discount, @discount) end + on CouponUsed do |event| + end + class List def initialize diff --git a/ecommerce/pricing/lib/pricing/services.rb b/ecommerce/pricing/lib/pricing/services.rb index 1ac73249..7c1128e1 100644 --- a/ecommerce/pricing/lib/pricing/services.rb +++ b/ecommerce/pricing/lib/pricing/services.rb @@ -180,4 +180,16 @@ def call(command) end end end + + class UseCouponHandler + def initialize(event_store) + @repository = Infra::AggregateRootRepository.new(event_store) + end + + def call(command) + @repository.with_aggregate(Offer, command.aggregate_id) do |order| + order.use_coupon(command.coupon_id, command.discount) + end + end + end end diff --git a/ecommerce/pricing/test/coupons_test.rb b/ecommerce/pricing/test/coupons_test.rb index b8c7a158..55d04fe7 100644 --- a/ecommerce/pricing/test/coupons_test.rb +++ b/ecommerce/pricing/test/coupons_test.rb @@ -34,7 +34,7 @@ def test_100_is_ok end def test_0_01_is_ok - register_coupon(@uid, fake_name, @code, 0.01) + register_coupon(@uid, fake_name, @code, "0.01") end end end diff --git a/ecommerce/pricing/test/use_coupon_test.rb b/ecommerce/pricing/test/use_coupon_test.rb new file mode 100644 index 00000000..92c9b85c --- /dev/null +++ b/ecommerce/pricing/test/use_coupon_test.rb @@ -0,0 +1,38 @@ +require_relative "test_helper" + +module Pricing + class UseCouponTest < Test + cover "Pricing*" + + def test_coupon_is_used + product_1_id = SecureRandom.uuid + set_price(product_1_id, 20) + coupon_id = SecureRandom.uuid + register_coupon(coupon_id, "Coupon", "coupon10", 10) + order_id = SecureRandom.uuid + add_item(order_id, product_1_id) + + assert_events_contain( + stream_name(order_id), + CouponUsed.new( + data: { + order_id: order_id, + coupon_id: coupon_id, + discount: 10 + } + ) + ) do + run_command( + Pricing::UseCoupon.new(order_id: order_id, coupon_id: coupon_id, discount: 10) + ) + end + end + + private + + def stream_name(id) + "Pricing::Offer$#{id}" + end + + end +end diff --git a/ecommerce/processes/lib/processes.rb b/ecommerce/processes/lib/processes.rb index 3b37af41..4c304fff 100644 --- a/ecommerce/processes/lib/processes.rb +++ b/ecommerce/processes/lib/processes.rb @@ -28,6 +28,7 @@ class << self def call(event_store, command_bus) self.class.event_store = event_store self.class.command_bus = command_bus + enable_coupon_discount_process(event_store, command_bus) notify_payments_about_order_total_value(event_store, command_bus) enable_shipment_sync(event_store, command_bus) determine_vat_rates_on_order_placed(event_store, command_bus) @@ -150,5 +151,11 @@ def register_order_on_order_placed(event_store, command_bus) to: [Ordering::OrderPlaced] ) end + + def enable_coupon_discount_process(event_store, command_bus) + Infra::Process.new(event_store, command_bus) + .call(Pricing::CouponUsed, [:order_id, :discount], + Pricing::SetPercentageDiscount, [:order_id, :amount]) + end end end diff --git a/infra/lib/infra/types.rb b/infra/lib/infra/types.rb index f4f1d45f..f74c4439 100644 --- a/infra/lib/infra/types.rb +++ b/infra/lib/infra/types.rb @@ -16,7 +16,7 @@ module Types Price = Types::Coercible::Decimal.constrained(gt: 0) Value = Types::Coercible::Decimal PercentageDiscount = Types::Coercible::Decimal.constrained(gt: 0, lteq: 100) - CouponDiscount = Types::Coercible::Float.constrained(gt: 0, lteq: 100) + CouponDiscount = Types::Coercible::Decimal.constrained(gt: 0, lteq: 100) UUIDQuantityHash = Types::Hash.map(UUID, Quantity) class VatRate < Dry::Struct diff --git a/rails_application/app/controllers/client/orders_controller.rb b/rails_application/app/controllers/client/orders_controller.rb index 7f45388c..66bd31b4 100644 --- a/rails_application/app/controllers/client/orders_controller.rb +++ b/rails_application/app/controllers/client/orders_controller.rb @@ -59,5 +59,25 @@ def remove_item ) end + def use_coupon + coupon = Coupons::Coupon.find_by!("lower(code) = ?", params[:coupon_code].downcase) + ActiveRecord::Base.transaction do + command_bus.(use_coupon_cmd(params[:id], coupon.uid, coupon.discount)) + end + flash[:notice] = "Coupon applied!" + redirect_to edit_client_order_path(params[:id]) + rescue ActiveRecord::RecordNotFound + flash[:alert] = "Coupon not found!" + redirect_to edit_client_order_path(params[:id]) + rescue Pricing::NotPossibleToAssignDiscountTwice + flash[:alert] = "Coupon already used!" + redirect_to edit_client_order_path(params[:id]) + end + + private + + def use_coupon_cmd(order_id, coupon_id, discount) + Pricing::UseCoupon.new(order_id: order_id, coupon_id: coupon_id, discount: discount) + end end end diff --git a/rails_application/app/read_models/client_orders/edit_order.rb b/rails_application/app/read_models/client_orders/edit_order.rb index 590c6d51..b6a4e13f 100644 --- a/rails_application/app/read_models/client_orders/edit_order.rb +++ b/rails_application/app/read_models/client_orders/edit_order.rb @@ -12,6 +12,7 @@ def build(order_id, order_lines, products, attributes = {}) super(attributes) div do products_table(order_id, products, order_lines) + coupon_form(order_id) submit_form(order_id) end end @@ -67,6 +68,19 @@ def remove_item_button(order_id, product_id) button_to "Remove", remove_item_client_order_path(id: order_id, product_id: product_id), class: "hover:underline text-blue-500" end + def coupon_form(order_id) + form(action: use_coupon_client_order_path(id: order_id), method: :post, class: "inline-flex gap-4 mt-8") do + input( + id: "coupon_code", + type: :text, + name: :coupon_code, + class: "focus:ring-blue-500 focus:border-blue-500 block shadow-sm sm:text-sm border-gray-300 rounded-md", + "data-turbo-permanent": true + ) + input(type: :submit, value: "Use Coupon", class: "px-4 py-2 border rounded-md shadow-sm text-sm font-medium border-gray-300 text-gray-700 bg-white hover:bg-gray-50") + end + end + def submit_form(order_id) form(id: "form", action: client_orders_path, method: :post) do input(type: :hidden, name: :order_id, value: order_id) diff --git a/rails_application/app/read_models/orders/update_discount.rb b/rails_application/app/read_models/orders/update_discount.rb index 39fdb432..cf55f2e2 100644 --- a/rails_application/app/read_models/orders/update_discount.rb +++ b/rails_application/app/read_models/orders/update_discount.rb @@ -1,7 +1,7 @@ module Orders class UpdateDiscount def call(event) - order = Order.find_by_uid(event.data.fetch(:order_id)) + order = Order.find_or_create_by(uid: event.data.fetch(:order_id)) if is_newest_value?(event, order) order.percentage_discount = event.data.fetch(:amount) order.discount_updated_at = event.metadata.fetch(:timestamp) @@ -28,4 +28,3 @@ def broadcaster end end end - diff --git a/rails_application/config/routes.rb b/rails_application/config/routes.rb index 6ac9fcf9..88ed31ad 100644 --- a/rails_application/config/routes.rb +++ b/rails_application/config/routes.rb @@ -42,6 +42,7 @@ member do post :add_item post :remove_item + post :use_coupon end end diff --git a/rails_application/test/integration/client_orders_test.rb b/rails_application/test/integration/client_orders_test.rb index ec9e1d6b..1163a7d5 100644 --- a/rails_application/test/integration/client_orders_test.rb +++ b/rails_application/test/integration/client_orders_test.rb @@ -203,6 +203,56 @@ def test_current_time_promotion_is_applied assert_select "tr td", "$2.00" end + def test_using_coupon_applies_discount + customer_id = register_customer("Customer Shop") + product_id = register_product("Fearless Refactoring", 4, 10) + register_coupon("Coupon", "coupon10", 10) + + login(customer_id) + visit_client_orders + + order_id = SecureRandom.uuid + as_client_add_item_to_basket_for_order(product_id, order_id) + as_client_use_coupon(order_id, "COUPON10") + + assert_select "#notice", "Coupon applied!" + + as_client_submit_order_for_customer(order_id) + + assert_select "tr td", "$3.60" + end + + def test_using_coupon_with_wrong_code + customer_id = register_customer("Customer Shop") + product_id = register_product("Fearless Refactoring", 4, 10) + register_coupon("Coupon", "coupon10", 10) + + login(customer_id) + visit_client_orders + + order_id = SecureRandom.uuid + as_client_add_item_to_basket_for_order(product_id, order_id) + as_client_use_coupon(order_id, "WRONGCODE") + + assert_select "#alert", "Coupon not found!" + end + + def test_using_coupon_twice + customer_id = register_customer("Customer Shop") + product_id = register_product("Fearless Refactoring", 4, 10) + register_coupon("Coupon", "coupon10", 10) + + login(customer_id) + visit_client_orders + + order_id = SecureRandom.uuid + as_client_add_item_to_basket_for_order(product_id, order_id) + as_client_use_coupon(order_id, "COUPON10") + as_client_use_coupon(order_id, "COUPON10") + + assert_select "#alert", "Coupon already used!" + end + private def submit_order_for_customer(customer_id, order_id) @@ -223,6 +273,11 @@ def as_client_add_item_to_basket_for_order(async_remote_id, order_id) post "/client_orders/#{order_id}/add_item?product_id=#{async_remote_id}" end + def as_client_use_coupon(order_id, code) + post "/client_orders/#{order_id}/use_coupon", params: { coupon_code: code } + follow_redirect! + end + def cancel_order(order_id) post "/orders/#{order_id}/cancel" end diff --git a/rails_application/test/test_helper.rb b/rails_application/test/test_helper.rb index c77da621..1b30559f 100644 --- a/rails_application/test/test_helper.rb +++ b/rails_application/test/test_helper.rb @@ -71,6 +71,10 @@ def register_product(name, price, vat_rate_code) product_id end + def register_coupon(name, code, discount) + post "/coupons", params: { coupon_id: SecureRandom.uuid, name: name, code: code, discount: discount } + end + def add_available_vat_rate(rate, code = rate.to_s) post "/available_vat_rates", params: { code: code, rate: rate } end