diff --git a/ecommerce/pricing/lib/pricing/events.rb b/ecommerce/pricing/lib/pricing/events.rb index ea709897..d5d52a75 100644 --- a/ecommerce/pricing/lib/pricing/events.rb +++ b/ecommerce/pricing/lib/pricing/events.rb @@ -47,6 +47,7 @@ class PriceItemAdded < Infra::Event class PriceItemRemoved < Infra::Event attribute :order_id, Infra::Types::UUID attribute :product_id, Infra::Types::UUID + attribute :price, Infra::Types::Price end class PercentageDiscountRemoved < Infra::Event diff --git a/ecommerce/pricing/lib/pricing/offer.rb b/ecommerce/pricing/lib/pricing/offer.rb index a933e994..d601dd82 100644 --- a/ecommerce/pricing/lib/pricing/offer.rb +++ b/ecommerce/pricing/lib/pricing/offer.rb @@ -23,10 +23,12 @@ def add_item(product_id, price) end def remove_item(product_id) + price = @list.lowest_price(product_id) apply PriceItemRemoved.new( data: { order_id: @id, - product_id: product_id + product_id: product_id, + price: price } ) end @@ -158,7 +160,7 @@ def expire end on PriceItemRemoved do |event| - @list.remove_item(event.data.fetch(:product_id)) + @list.remove_item(event.data.fetch(:product_id), event.data.fetch(:price)) end on PriceItemValueCalculated do |event| @@ -220,11 +222,9 @@ def add_item(product_id, price) @items << Item.new(product_id:, price:, quantity: 1) end - def remove_item(product_id) - new_items = @items.sort { _1.price} - index_of_item_to_remove = new_items.index { |item| item.product_id == product_id } - new_items.delete_at(index_of_item_to_remove) - @items = new_items + def remove_item(product_id, price) + index_of_item_to_remove = @items.index { |item| item.product_id == product_id && item.price == price } + @items.delete_at(index_of_item_to_remove) end def contains_free_products? @@ -251,10 +251,19 @@ def set_free(product_id) def restore_nonfree(product_id) idx = @items.index { |item| item.product_id == product_id && item.price == 0 } + return unless idx old_item = @items.delete_at(idx) @items << old_item.with(price: old_item.catalog_price) end + def lowest_price(product_id) + @items + .select { |item| item.product_id == product_id } + .sort_by(&:price) + .first + &.price + end + def quantities sub_amounts_total.map do |product_id, h| { product_id:, quantity: h.fetch(:quantity) } diff --git a/ecommerce/processes/lib/processes/three_plus_one_free.rb b/ecommerce/processes/lib/processes/three_plus_one_free.rb index df6d7b7c..aad027a8 100644 --- a/ecommerce/processes/lib/processes/three_plus_one_free.rb +++ b/ecommerce/processes/lib/processes/three_plus_one_free.rb @@ -39,8 +39,7 @@ def build_state(event) end def make_or_remove_free_product(state) - pricing_catalog = Pricing::PricingCatalog.new(@event_store) - free_product_id = FreeProductResolver.new(state, pricing_catalog).call + free_product_id = state.cheapest_product return if current_free_product_not_changed?(free_product_id, state) @@ -69,17 +68,18 @@ class ProcessState def initialize(order_id) @order_id = order_id - @order_lines = Hash.new(0) + @order_lines = [] end def call(event) product_id = event.data.fetch(:product_id) case event when Pricing::PriceItemAdded - order_lines[product_id] += 1 + order_lines << { product_id: event.data.fetch(:product_id), price: event.data.fetch(:price) } when Pricing::PriceItemRemoved - order_lines[product_id] -= 1 - order_lines.delete(product_id) if order_lines.fetch(product_id) <= 0 + index_of_line_to_remove = order_lines.index { |line| line.fetch(:product_id) == product_id && line.fetch(:price) == event.data.fetch(:price) } + @order_lines.delete_at(index_of_line_to_remove) + @current_free_product_id = nil if event.data.fetch(:price) == 0 when Pricing::ProductMadeFreeForOrder @current_free_product_id = product_id when Pricing::FreeProductRemovedFromOrder @@ -87,33 +87,9 @@ def call(event) end end - def total_quantity - order_lines.values.sum - end - end - - class FreeProductResolver MIN_ORDER_LINES_QUANTITY = 4 - - def initialize(state, pricing_catalog) - @state = state - @pricing_catalog = pricing_catalog - end - - def call - cheapest_product if eligible_for_free_product? - end - - private - - attr_reader :state, :pricing_catalog - def cheapest_product - state.order_lines.keys.sort_by { |product_id| pricing_catalog.price_by_product_id(product_id) }.first - end - - def eligible_for_free_product? - state.total_quantity >= MIN_ORDER_LINES_QUANTITY + order_lines.sort_by { |line| line.fetch(:price) }.first.fetch(:product_id) if order_lines.size >= MIN_ORDER_LINES_QUANTITY end end end diff --git a/ecommerce/processes/test/test_helper.rb b/ecommerce/processes/test/test_helper.rb index 67516deb..b55bc057 100644 --- a/ecommerce/processes/test/test_helper.rb +++ b/ecommerce/processes/test/test_helper.rb @@ -59,7 +59,7 @@ def customer_id end def given(events, store: event_store, process: nil) - events.each do |ev| + events.flatten.each do |ev| store.append(ev) process.call(ev) if process end diff --git a/ecommerce/processes/test/three_plus_one_free_test.rb b/ecommerce/processes/test/three_plus_one_free_test.rb index e98cc038..87162594 100644 --- a/ecommerce/processes/test/three_plus_one_free_test.rb +++ b/ecommerce/processes/test/three_plus_one_free_test.rb @@ -4,17 +4,11 @@ module Processes class ThreePlusOneFreeTest < Test cover "Processes::ThreePlusOneFree*" - def setup - skip "ThreePlusOneFree process block us from removing Pricing::PricingCatalog" - end - def test_one_order_line_is_not_eligible_for_free_product product_id = SecureRandom.uuid order_id = SecureRandom.uuid process = ThreePlusOneFree.new(event_store, command_bus) - given(item_added_event(order_id, product_id, 22)).each do |event| - process.call(event) - end + given(item_added_event(order_id, product_id, 22), process:) assert_no_command end @@ -23,9 +17,7 @@ def test_four_order_lines_are_eligible_for_free_product order_id = SecureRandom.uuid process = ThreePlusOneFree.new(event_store, command_bus) given([set_price(product_id, 20)]) - given(item_added_event(order_id, product_id, 20, times: 4)).each do |event| - process.call(event) - end + given(item_added_event(order_id, product_id, 20, times: 4), process:) assert_command(Pricing::MakeProductFreeForOrder.new(order_id: order_id, product_id: product_id)) end @@ -34,12 +26,11 @@ def test_remove_free_product_when_order_lines_qtn_is_less_than_four order_id = SecureRandom.uuid process = ThreePlusOneFree.new(event_store, command_bus) given([set_price(product_id, 20)]) - given(item_added_event(order_id, product_id, 20, times:4) + - product_made_for_free(order_id, product_id) + - item_removed_event(order_id, product_id, times: 1) + - free_product_removed(order_id, product_id)).each do |event| - process.call(event) - end + given([item_added_event(order_id, product_id, 20, times: 4), + product_made_for_free(order_id, product_id), + item_removed_event(order_id, product_id, 20, times: 1), + free_product_removed(order_id, product_id) + ], process:) assert_all_commands(Pricing::MakeProductFreeForOrder.new(order_id: order_id, product_id: product_id), Pricing::RemoveFreeProductFromOrder.new(order_id: order_id, product_id: product_id)) @@ -53,14 +44,12 @@ def test_change_free_product_if_new_order_line_is_the_cheapest given([set_price(product_id, 20)]) given([set_price(cheapest_product_id, 1)]) - given(item_added_event(order_id, product_id, 20, times:4) + - product_made_for_free(order_id, product_id) + - item_added_event(order_id, cheapest_product_id, 1) + - free_product_removed(order_id, product_id) + - product_made_for_free(order_id, cheapest_product_id) - ).each do |event| - process.call(event) - end + given([item_added_event(order_id, product_id, 20, times: 4), + product_made_for_free(order_id, product_id), + item_added_event(order_id, cheapest_product_id, 1), + free_product_removed(order_id, product_id), + product_made_for_free(order_id, cheapest_product_id) + ], process:) assert_all_commands(Pricing::MakeProductFreeForOrder.new(order_id: order_id, product_id: product_id), Pricing::RemoveFreeProductFromOrder.new(order_id: order_id, product_id: product_id), @@ -75,11 +64,10 @@ def test_do_not_change_free_product_if_new_order_line_is_more_expensive given([set_price(product_id, 20)]) given([set_price(more_expensive_product_id, 50)]) - given(item_added_event(order_id, product_id, 20, times: 4) + - product_made_for_free(order_id, product_id) + - item_added_event(order_id, more_expensive_product_id, 50)).each do |event| - process.call(event) - end + given([item_added_event(order_id, product_id, 20, times: 4), + product_made_for_free(order_id, product_id), + item_added_event(order_id, more_expensive_product_id, 50) + ], process:) assert_all_commands(Pricing::MakeProductFreeForOrder.new(order_id: order_id, product_id: product_id)) end @@ -92,16 +80,15 @@ def test_change_free_product_if_the_cheapest_order_line_is_removed given([set_price(product_id, 20)]) given([set_price(cheapest_product_id, 1)]) - given(item_added_event(order_id, product_id, 20, times: 4) + - product_made_for_free(order_id, product_id) + - item_added_event(order_id, cheapest_product_id, 1) + - free_product_removed(order_id, product_id) + - product_made_for_free(order_id, cheapest_product_id) + - item_removed_event(order_id, cheapest_product_id) + - free_product_removed(order_id, cheapest_product_id) + - product_made_for_free(order_id, product_id)).each do |event| - process.call(event) - end + given([item_added_event(order_id, product_id, 20, times: 4), + product_made_for_free(order_id, product_id), + item_added_event(order_id, cheapest_product_id, 1), + free_product_removed(order_id, product_id), + product_made_for_free(order_id, cheapest_product_id), + item_removed_event(order_id, cheapest_product_id, 1), + free_product_removed(order_id, cheapest_product_id), + product_made_for_free(order_id, product_id), + ], process:) assert_all_commands(Pricing::MakeProductFreeForOrder.new(order_id: order_id, product_id: product_id), Pricing::RemoveFreeProductFromOrder.new(order_id: order_id, product_id: product_id), @@ -114,28 +101,21 @@ def test_change_free_product_if_the_cheapest_order_line_is_removed private def set_price(product_id, amount) - Pricing::PriceSet.new(data: { product_id: product_id, price: amount }) + Pricing::PriceSet.new(data: { product_id:, price: amount }) end def item_added_event(order_id, product_id, price, times: 1) times.times.collect do Pricing::PriceItemAdded.new( - data: { - order_id: order_id, - product_id: product_id, - price: price - } + data: { order_id:, product_id:, price: } ) end end - def item_removed_event(order_id, product_id, times: 1) + def item_removed_event(order_id, product_id, price, times: 1) times.times.collect do Pricing::PriceItemRemoved.new( - data: { - order_id: order_id, - product_id: product_id - } + data: { order_id:, product_id:, price: } ) end end @@ -143,10 +123,7 @@ def item_removed_event(order_id, product_id, times: 1) def product_made_for_free(order_id, product_id) [ Pricing::ProductMadeFreeForOrder.new( - data: { - order_id: order_id, - product_id: product_id - } + data: { order_id:, product_id: } ) ] end @@ -154,10 +131,7 @@ def product_made_for_free(order_id, product_id) def free_product_removed(order_id, product_id) [ Pricing::FreeProductRemovedFromOrder.new( - data: { - order_id: order_id, - product_id: product_id - } + data: { order_id:, product_id: } ) ] end diff --git a/infra/lib/infra/types.rb b/infra/lib/infra/types.rb index f74c4439..b6ab925c 100644 --- a/infra/lib/infra/types.rb +++ b/infra/lib/infra/types.rb @@ -13,7 +13,7 @@ module Types OrderNumber = Types::Strict::String.constrained(format: /\A\d{4}\/\d{2}\/\d+\z/i) Quantity = Types::Strict::Integer.constrained(gt: 0) - Price = Types::Coercible::Decimal.constrained(gt: 0) + Price = Types::Coercible::Decimal.constrained(gteq: 0) Value = Types::Coercible::Decimal PercentageDiscount = Types::Coercible::Decimal.constrained(gt: 0, lteq: 100) CouponDiscount = Types::Coercible::Decimal.constrained(gt: 0, lteq: 100) diff --git a/rails_application/test/client_orders/item_removed_from_basket_test.rb b/rails_application/test/client_orders/item_removed_from_basket_test.rb index f02bd871..60e83faa 100644 --- a/rails_application/test/client_orders/item_removed_from_basket_test.rb +++ b/rails_application/test/client_orders/item_removed_from_basket_test.rb @@ -47,7 +47,8 @@ def test_remove_item_when_quantity_gt_1 Pricing::PriceItemRemoved.new( data: { order_id: order_id, - product_id: product_id + product_id: product_id, + price: 20, } ) ) @@ -93,7 +94,8 @@ def test_remove_item_when_quantity_eq_1 Pricing::PriceItemRemoved.new( data: { order_id: order_id, - product_id: product_id + product_id: product_id, + price: 20, } ) ) @@ -169,7 +171,8 @@ def test_remove_item_when_there_is_another_item Pricing::PriceItemRemoved.new( data: { order_id: order_id, - product_id: another_product_id + product_id: another_product_id, + price: 20, } ) ) diff --git a/rails_application/test/orders/broadcast_test.rb b/rails_application/test/orders/broadcast_test.rb index eb7529ce..32060cbb 100644 --- a/rails_application/test/orders/broadcast_test.rb +++ b/rails_application/test/orders/broadcast_test.rb @@ -95,6 +95,7 @@ def test_broadcast_remove_item_from_basket data: { order_id: order_id, product_id: product_id, + price: 20, } ) ) diff --git a/rails_application/test/orders/item_removed_from_basket_test.rb b/rails_application/test/orders/item_removed_from_basket_test.rb index 5a1a99f5..dd82e52b 100644 --- a/rails_application/test/orders/item_removed_from_basket_test.rb +++ b/rails_application/test/orders/item_removed_from_basket_test.rb @@ -46,7 +46,8 @@ def test_remove_item_when_quantity_gt_1 item_removed_from_basket = Pricing::PriceItemRemoved.new( data: { order_id: order_id, - product_id: product_id + product_id: product_id, + price: 20, } ) event_store.publish(item_removed_from_basket) @@ -93,7 +94,8 @@ def test_remove_item_when_quantity_eq_1 Pricing::PriceItemRemoved.new( data: { order_id: order_id, - product_id: product_id + product_id: product_id, + price: 20, } ) ) @@ -169,7 +171,8 @@ def test_remove_item_when_there_is_another_item Pricing::PriceItemRemoved.new( data: { order_id: order_id, - product_id: another_product_id + product_id: another_product_id, + price: 20, } ) )