Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4c1693f
let time promotion last longer during the test
porbas Apr 16, 2025
69e7ad4
publish more pricing info in ItemAdded/ItemRemoved event payloads
porbas Apr 16, 2025
14bb5b8
fix free products test
porbas Apr 16, 2025
a35c145
add more Pricing::Offer tests to extend coverage and kill mutants
porbas Apr 16, 2025
e08fcea
kill mutants
porbas Apr 16, 2025
079ed33
fix ThreeePlusOneFreeTests
porbas Apr 16, 2025
6f43584
fix tests in rails_application after adding new attributes to ItemAdd…
porbas Apr 16, 2025
db251d0
Three plus one gratis alternative implementation
lukaszreszke Apr 17, 2025
92c1405
Take list into apply method
lukaszreszke Apr 17, 2025
60625e6
Three plus one as promotion
lukaszreszke Apr 17, 2025
fbca2b9
add command to helper
lukaszreszke Apr 17, 2025
464d64a
Failing test -- Discount added at the end should be included correctly
lukaszreszke Apr 17, 2025
50450fa
Extend PercentageDiscountSet event by total and base values
lukaszreszke Apr 17, 2025
87ee904
Set base_total_value and total_value on DiscountSet event
lukaszreszke Apr 17, 2025
6771234
Promotion should be off by default
lukaszreszke Apr 17, 2025
b4d2e92
Skip test, it doesn't fit into new design
lukaszreszke Apr 17, 2025
795c2d3
Adjust PercentageDiscountSet event's data in test
lukaszreszke Apr 17, 2025
e10e3ad
Extend the add item command for now to continue poc
lukaszreszke Apr 17, 2025
b24f510
Adjust tests to match the event structure
lukaszreszke Apr 17, 2025
5ed78dc
Don't change price of free product
lukaszreszke Apr 17, 2025
b428ce2
Assert PriceItemValueCalculated
lukaszreszke Apr 17, 2025
c281497
Keep info about applied promotion
lukaszreszke Apr 17, 2025
b49a1f3
Support additional cases
lukaszreszke Apr 17, 2025
f332616
Test when 3+1 and remove item
lukaszreszke Apr 17, 2025
9368786
Kill mutants
lukaszreszke Apr 18, 2025
4ab6f34
Kill mutant
lukaszreszke Apr 18, 2025
e18785b
Fix test - kill last mutant
lukaszreszke Apr 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ecommerce/pricing/lib/pricing/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ class AddPriceItem < Infra::Command
attribute :order_id, Infra::Types::UUID
attribute :product_id, Infra::Types::UUID
attribute :price, Infra::Types::Price
attribute :promotion, Infra::Types::Strict::Bool.default(false)

alias aggregate_id order_id
end
Expand Down
10 changes: 10 additions & 0 deletions ecommerce/pricing/lib/pricing/discounts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,15 @@ def add(other_discount)
def exists?
end
end

class ThreePlusOneGratis
def apply(product_quantities, product_id, base_price)
product = product_quantities.find { |product_quantity| product_quantity.fetch(:product_id) == product_id }
return [false, base_price] if product.nil?
quantity = product.fetch(:quantity) + 1
return [false, base_price] if !quantity.%(4).eql?(0)
[true, 0]
end
end
end
end
9 changes: 9 additions & 0 deletions ecommerce/pricing/lib/pricing/events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,27 @@ class PercentageDiscountSet < Infra::Event
attribute :order_id, Infra::Types::UUID
attribute :type, Infra::Types::String
attribute :amount, Infra::Types::PercentageDiscount
attribute :base_total_value, Infra::Types::Price
attribute :total_value, Infra::Types::Price
end

class PriceItemAdded < Infra::Event
attribute :order_id, Infra::Types::UUID
attribute :product_id, Infra::Types::UUID
attribute :base_price, Infra::Types::Price
attribute :price, Infra::Types::Price
attribute :base_total_value, Infra::Types::Price
attribute :total_value, Infra::Types::Price
attribute? :applied_promotion, Infra::Types::String
end

class PriceItemRemoved < Infra::Event
attribute :order_id, Infra::Types::UUID
attribute :product_id, Infra::Types::UUID
attribute :base_price, Infra::Types::Price
attribute :price, Infra::Types::Price
attribute :base_total_value, Infra::Types::Price
attribute :total_value, Infra::Types::Price
end

class PercentageDiscountRemoved < Infra::Event
Expand Down
83 changes: 59 additions & 24 deletions ecommerce/pricing/lib/pricing/offer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,39 @@ def initialize(id)
@state = :draft
end

def add_item(product_id, price)
apply PriceItemAdded.new(
data: {
order_id: @id,
product_id: product_id,
price: price,
}
)
def add_item(product_id, base_price, promotion)
if promotion
promotion_applies, price = promotion.apply(@list.quantities, product_id, base_price)
end

unless promotion_applies
price = @discounts.inject(Discounts::NoPercentageDiscount.new, :add).apply(base_price)
end

data = {
order_id: @id,
product_id: product_id,
base_price: base_price,
price: price,
base_total_value: @list.base_sum + base_price,
total_value: @list.actual_sum + price,
}

data[:applied_promotion] = promotion.class.name if promotion_applies

apply PriceItemAdded.new(data:)
end

def remove_item(product_id)
price = @list.lowest_price(product_id)
item = @list.lowest_price_item(product_id)
apply PriceItemRemoved.new(
data: {
order_id: @id,
product_id: product_id,
price: price
base_price: item.base_price,
price: item.price,
base_total_value: @list.base_sum - item.base_price,
total_value: @list.actual_sum - item.price
}
)
end
Expand All @@ -39,7 +55,9 @@ def apply_discount(discount)
data: {
order_id: @id,
type: discount.type,
amount: discount.value
amount: discount.value,
base_total_value: @list.base_sum,
total_value: @list.actual_sum - @list.actual_sum * (discount.value / 100)
}
)
end
Expand Down Expand Up @@ -87,7 +105,7 @@ def remove_free_product(order_id, product_id)

def calculate_total_value
total_value = @list.base_sum
discounted_value = @discounts.inject(Discounts::NoPercentageDiscount.new, :add).apply(total_value)
discounted_value = @list.actual_sum

apply(
OrderTotalValueCalculated.new(
Expand All @@ -110,8 +128,8 @@ def calculate_sub_amounts
order_id: @id,
product_id: product_id,
quantity: h.fetch(:quantity),
amount: h.fetch(:amount),
discounted_amount: @discounts.inject(Discounts::NoPercentageDiscount.new, :add).apply(h.fetch(:amount))
amount: h.fetch(:base_amount),
discounted_amount: h.fetch(:amount),
}
)
)
Expand Down Expand Up @@ -156,7 +174,7 @@ def expire
private

on PriceItemAdded do |event|
@list.add_item(event.data.fetch(:product_id), event.data.fetch(:price))
@list.add_item(event.data.fetch(:product_id), event.data.fetch(:base_price), event.data.fetch(:price))
end

on PriceItemRemoved do |event|
Expand All @@ -171,15 +189,18 @@ def expire

on PercentageDiscountSet do |event|
@discounts << Discounts::PercentageDiscount.new(event.data.fetch(:type), event.data.fetch(:amount))
@list.apply_discounts(@discounts)
end

on PercentageDiscountChanged do |event|
@discounts.delete_if { |discount| discount.type == event.data.fetch(:type) }
@discounts << Discounts::PercentageDiscount.new(event.data.fetch(:type), event.data.fetch(:amount))
@list.apply_discounts(@discounts)
end

on PercentageDiscountRemoved do |event|
@discounts.delete_if { |discount| discount.type == event.data.fetch(:type) }
@list.apply_discounts(@discounts)
end

on ProductMadeFreeForOrder do |event|
Expand Down Expand Up @@ -210,34 +231,49 @@ def discount_exists?(type)
end

class List
Item = Data.define(:product_id, :quantity, :price, :catalog_price) do
def initialize(product_id:, quantity:, price:, catalog_price: price) = super
end
Item = Data.define(:product_id, :quantity, :base_price, :price)

def initialize
@items = []
end

def add_item(product_id, price)
@items << Item.new(product_id:, price:, quantity: 1)
def add_item(product_id, base_price, price)
@items << Item.new(product_id:, base_price:, price:, quantity: 1)
end

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 apply_discounts(discounts)
@items = @items.map do |item|
next item if is_free?(item)
price = discounts.inject(Discounts::NoPercentageDiscount.new, :add).apply(item.base_price)
item.with(price:)
end
end

def is_free?(item)
item.price == 0
end

def contains_free_products?
@items.any? { |item| item.price == 0 }
end

def base_sum
@items.sum(&:base_price)
end

def actual_sum
@items.sum(&:price)
end

def sub_amounts_total
@items.each_with_object({}) do |item, memo|
memo[item.product_id] ||= { amount: 0, quantity: 0 }
memo[item.product_id] ||= { base_amount: 0, amount: 0, quantity: 0 }
memo[item.product_id][:base_amount] += item.base_price * item.quantity
memo[item.product_id][:amount] += item.price * item.quantity
memo[item.product_id][:quantity] += item.quantity
end
Expand All @@ -253,15 +289,14 @@ 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)
@items << old_item.with(price: old_item.base_price)
end

def lowest_price(product_id)
def lowest_price_item(product_id)
@items
.select { |item| item.product_id == product_id }
.sort_by(&:price)
.first
&.price
end

def quantities
Expand Down
3 changes: 2 additions & 1 deletion ecommerce/pricing/lib/pricing/services.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ def initialize(event_store)

def call(command)
@repository.with_aggregate(Offer, command.aggregate_id) do |order|
order.add_item(command.product_id, command.price)
promotion = Discounts::ThreePlusOneGratis.new if command.promotion
order.add_item(command.product_id, command.price, promotion)
end
end
end
Expand Down
14 changes: 11 additions & 3 deletions ecommerce/pricing/test/apply_time_promotion_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class ApplyTimePromotionTest < Test
cover "Pricing*"

def test_applies_biggest_time_promotion_discount

order_id = SecureRandom.uuid
product_id = SecureRandom.uuid
create_inactive_time_promotion(60)
Expand Down Expand Up @@ -57,13 +58,18 @@ def create_active_time_promotion(discount)
end

def item_added_to_basket_event(order_id, product_id)
Pricing::PriceItemAdded.new(
price_item_added = Pricing::PriceItemAdded.new(
data: {
product_id: product_id,
order_id: order_id,
price: 1111,
base_price: 1000,
price: 1000,
base_total_value: 1000,
total_value: 1000
}
)
event_store.append(price_item_added, stream_name: stream_name(order_id))
price_item_added
end

def set_time_promotion_discount(order_id, discount)
Expand All @@ -75,7 +81,9 @@ def percentage_discount_set_event(order_id, amount)
data: {
order_id: order_id,
type: Pricing::Discounts::TIME_PROMOTION_DISCOUNT,
amount: amount
amount: amount,
base_total_value: 1000,
total_value: 500
}
)
end
Expand Down
50 changes: 50 additions & 0 deletions ecommerce/pricing/test/discounts_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,55 @@ def test_doesnt_change_total
assert_equal(100, NoPercentageDiscount.new.apply(100))
end
end

class ThreePlusOneGratisTest < Test
cover "Pricing::Discounts*"
def setup
@three_plus_one_gratis = ThreePlusOneGratis.new
@product_id = SecureRandom.uuid
end

def test_returns_full_price_when_product_not_found
result = @three_plus_one_gratis.apply([], @product_id, 20)
assert_equal [false, 20], result
end

def test_returns_full_price_when_quantity_plus_one_is_less_than_4
quantities = [{ product_id: @product_id, quantity: 2 }]
result = @three_plus_one_gratis.apply(quantities, @product_id, 20)
assert_equal [false, 20], result
end

def test_returns_free_when_quantity_plus_one_is_4
quantities = [{ product_id: @product_id, quantity: 3 }]
result = @three_plus_one_gratis.apply(quantities, @product_id, 20)
assert_equal [true, 0], result
end

def test_returns_full_price_when_quantity_plus_one_is_5
quantities = [{ product_id: @product_id, quantity: 4 }]
result = @three_plus_one_gratis.apply(quantities, @product_id, 20)
assert_equal [false, 20], result
end

def test_returns_free_when_quantity_plus_one_is_8
quantities = [{ product_id: @product_id, quantity: 7 }]
result = @three_plus_one_gratis.apply(quantities, @product_id, 20)
assert_equal [true, 0], result
end

def test_returns_full_price_when_quantity_plus_one_is_9
quantities = [{ product_id: @product_id, quantity: 8 }]
result = @three_plus_one_gratis.apply(quantities, @product_id, 20)
assert_equal [false, 20], result
end

def test_does_not_confuse_other_product_ids
other_product_id = SecureRandom.uuid
quantities = [{ product_id: other_product_id, quantity: 3 }]
result = @three_plus_one_gratis.apply(quantities, @product_id, 20)
assert_equal [false, 20], result
end
end
end
end
6 changes: 3 additions & 3 deletions ecommerce/pricing/test/free_products_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def test_making_product_free_possible_when_order_is_eligible
data: {
order_id: order_id,
discounted_amount: 60,
total_amount: 60
total_amount: 80
}
)
) do
Expand Down Expand Up @@ -58,7 +58,7 @@ def test_making_only_the_cheapest_product_free
data: {
order_id: order_id,
discounted_amount: 60,
total_amount: 60
total_amount: 70
}
),
) do
Expand Down Expand Up @@ -125,7 +125,7 @@ def test_making_product_free_possible_after_previous_free_product_was_removed
data: {
order_id: order_id,
discounted_amount: 60,
total_amount: 60
total_amount: 80
}
)
) do
Expand Down
Loading