Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better error when submitting order with out of stock product #390

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.ruby-version
.tool-versions
5 changes: 2 additions & 3 deletions ecommerce/crm/lib/crm/order.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
module Crm
class Order
include AggregateRoot
CustomerAlreadyAssigned = Class.new(StandardError)

def initialize(id)
@id = id
end

def set_customer(customer_id)
raise CustomerAlreadyAssigned if @customer_id
return if @customer_id == customer_id
Copy link
Collaborator Author

@marlena-b marlena-b Sep 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to allow users to reassign customers. If order submission fails the first time the user is still being assigned. So when user tries again this raises an exception and brakes the process.

I changed it so when customer_id is not changed it does nothing, but if it is changed then we emit the event.

apply CustomerAssignedToOrder.new(data: { order_id: @id, customer_id: customer_id })
end

Expand All @@ -18,4 +17,4 @@ def set_customer(customer_id)
@customer_id = event.data[:customer_id]
end
end
end
end
27 changes: 24 additions & 3 deletions ecommerce/crm/test/assign_customer_to_order_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,31 @@ def test_customer_should_get_assigned
def test_customer_should_not_get_assigned_twice
customer_id = SecureRandom.uuid
order_id = SecureRandom.uuid

register_customer(customer_id, fake_name)

expected_event = CustomerAssignedToOrder.new(data: {customer_id: customer_id, order_id: order_id})

assert_events("Crm::Order$#{order_id}", expected_event) do
assign_customer_to_order(order_id, customer_id)
assign_customer_to_order(order_id, customer_id)
end
end

def test_customer_can_be_reassigned
customer_id = SecureRandom.uuid
another_customer_id = SecureRandom.uuid
order_id = SecureRandom.uuid

register_customer(customer_id, fake_name)
assign_customer_to_order(order_id, customer_id)
assert_raises(Order::CustomerAlreadyAssigned) do
register_customer(another_customer_id, fake_name)

expected_event_1 = CustomerAssignedToOrder.new(data: {customer_id: customer_id, order_id: order_id})
expected_event_2 = CustomerAssignedToOrder.new(data: {customer_id: another_customer_id, order_id: order_id})

assert_events("Crm::Order$#{order_id}", expected_event_1, expected_event_2) do
assign_customer_to_order(order_id, customer_id)
assign_customer_to_order(order_id, another_customer_id)
end
end

Expand All @@ -40,4 +61,4 @@ def assign_customer_to_order(order_id, customer_id)
run_command(AssignCustomerToOrder.new(order_id: order_id, customer_id: customer_id))
end
end
end
end
1 change: 1 addition & 0 deletions ecommerce/processes/lib/processes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require_relative "../../invoicing/lib/invoicing"
require_relative "../../fulfillment/lib/fulfillment"
require_relative 'processes/confirm_order_on_payment_captured'
require_relative 'processes/events'
require_relative 'processes/release_payment_process'
require_relative 'processes/shipment_process'
require_relative 'processes/determine_vat_rates_on_order_placed'
Expand Down
10 changes: 10 additions & 0 deletions ecommerce/processes/lib/processes/events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module Processes
class ReservationProcessFailed < Infra::Event
attribute :order_id, Infra::Types::UUID
attribute :unavailable_products, Infra::Types::Array.of(Infra::Types::UUID)
end

class ReservationProcessSuceeded < Infra::Event
attribute :order_id, Infra::Types::UUID
end
end
36 changes: 26 additions & 10 deletions ecommerce/processes/lib/processes/reservation_process.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,7 @@ def call(event)
state = build_state(event)
case event.event_type
when 'Ordering::OrderSubmitted'
begin
reserve_stock(state)
rescue Inventory::InventoryEntry::InventoryNotAvailable
release_stock(state)
reject_order(state)
else
accept_order(state)
end
order_side_effects(state) { reserve_stock(state) }
when 'Fulfillment::OrderCancelled'
release_stock(state)
when 'Fulfillment::OrderConfirmed'
Expand All @@ -28,10 +21,29 @@ def call(event)
private

def reserve_stock(state)
unavailable_products = []
state.order_lines.each do |product_id, quantity|
command_bus.(Inventory::Reserve.new(product_id: product_id, quantity: quantity))
state.product_reserved(product_id)
rescue Inventory::InventoryEntry::InventoryNotAvailable
unavailable_products << product_id
end
Comment on lines 25 to +30
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReservationProcess no longer stops at the first unavailable product. Instead, it tries to reserve all of them and records which were unavailable. This way we can later display the full list of unavailable products.


if unavailable_products.empty?
event = ReservationProcessSuceeded.new(data: { order_id: state.order_id })
else
release_stock(state)
event = ReservationProcessFailed.new(data: { order_id: state.order_id, unavailable_products: unavailable_products })
end
event_store.publish(event, stream_name: stream_name(state.order_id))
Copy link
Collaborator Author

@marlena-b marlena-b Sep 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReservationProcess now emits events. We then subscribe to these events in the controller and display proper errors to the user.
We could raise an error instead but then the transaction would be rolled back and most of the code here would make no sense.

end

def order_side_effects(state)
event_store
.within { yield }
.subscribe(to: ReservationProcessFailed) { reject_order(state) }
.subscribe(to: ReservationProcessSuceeded) { accept_order(state) }
.call
Comment on lines +41 to +46
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these side effects could be extracted to fully-fledged event handlers. Then the ReservationProcess could have only a single responsibility. This PR is already big so I didn't want to extract it here. Let me know what you think and I will add an issue for this refactor.

end

def release_stock(state)
Expand All @@ -54,8 +66,12 @@ def reject_order(state)
command_bus.(Ordering::RejectOrder.new(order_id: state.order_id))
end

def stream_name(order_id)
"ReservationProcess$#{order_id}"
end

def build_state(event)
stream_name = "ReservationProcess$#{event.data.fetch(:order_id)}"
stream_name = stream_name(event.data.fetch(:order_id))
begin
past_events = event_store.read.stream(stream_name).to_a
last_stored = past_events.size - 1
Expand Down Expand Up @@ -93,4 +109,4 @@ def product_reserved(product_id)
end
end
end
end
end
43 changes: 26 additions & 17 deletions ecommerce/processes/test/reservation_process_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ class ReservationProcessTest < Test

def test_happy_path
process = ReservationProcess.new
given([order_submitted]).each { |event| process.call(event) }
assert_success_event do
given([order_submitted]).each { |event| process.call(event) }
end
assert_all_commands(
Inventory::Reserve.new(product_id: product_id, quantity: 1),
Inventory::Reserve.new(product_id: another_product_id, quantity: 2),
Expand All @@ -26,28 +28,19 @@ def call(command)
end
end

def test_reject_order_command_is_dispatched_when_sth_is_unavailable
def test_rejects_order_and_compensates_stock_when_sth_is_unavailable
failing_command = Inventory::Reserve.new(product_id: product_id, quantity: 1)
enhanced_command_bus = EnhancedFakeCommandBus.new(command_bus, failing_command => Inventory::InventoryEntry::InventoryNotAvailable)
process = ReservationProcess.new
process.command_bus = enhanced_command_bus
given([order_submitted]).each { |event| process.call(event) }
assert_all_commands(
failing_command,
Ordering::RejectOrder.new(order_id: order_id)
)
end

def test_compensation_when_sth_is_unavailable
failing_command = Inventory::Reserve.new(product_id: another_product_id, quantity: 2)
enhanced_command_bus = EnhancedFakeCommandBus.new(command_bus, failing_command => Inventory::InventoryEntry::InventoryNotAvailable)
process = ReservationProcess.new
process.command_bus = enhanced_command_bus
given([order_submitted]).each { |event| process.call(event) }
assert_failure_event do
given([order_submitted]).each { |event| process.call(event) }
end
assert_all_commands(
Inventory::Reserve.new(product_id: product_id, quantity: 1),
failing_command,
Inventory::Release.new(product_id: product_id, quantity: 1),
Inventory::Reserve.new(product_id: another_product_id, quantity: 2),
Inventory::Release.new(product_id: another_product_id, quantity: 2),
Ordering::RejectOrder.new(order_id: order_id)
)
end
Expand Down Expand Up @@ -104,5 +97,21 @@ def order_cancelled
}
)
end

def assert_success_event(&block)
assert_events_contain(
"ReservationProcess$#{order_id}",
ReservationProcessSuceeded.new(data: { order_id: order_id }),
&block
)
end

def assert_failure_event(&block)
assert_events_contain(
"ReservationProcess$#{order_id}",
ReservationProcessFailed.new(data: { order_id: order_id, unavailable_products: [product_id] }),
&block
)
end
end
end
end
2 changes: 1 addition & 1 deletion rails_application/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ yarn-debug.log*

# Event to handlers and handler to events mappings generated by big_picture.rb script
/lib/event_to_handlers.rb
/lib/handler_to_events.rb
/lib/handler_to_events.rb
1 change: 1 addition & 0 deletions rails_application/.mutant.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ matcher:
- Orders::UpdateOrderTotalValue*
- Orders::SubmitOrder*
- Orders::AssignCustomerToOrder*
- Orders::SubmitService#submit_order
11 changes: 5 additions & 6 deletions rails_application/app/controllers/client/orders_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,12 @@ def new
end

def create
ActiveRecord::Base.transaction do
command_bus.(Ordering::SubmitOrder.new(order_id: params[:order_id]))
command_bus.(Crm::AssignCustomerToOrder.new(customer_id: cookies[:client_id], order_id: params[:order_id]))
Orders::SubmitService.new(order_id: params[:order_id], customer_id: cookies[:client_id]).call.then do |result|
Copy link
Collaborator Author

@marlena-b marlena-b Sep 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code in this controller got more and more complex so I extracted it to a service.
I used a very simple Service object implementation combined with a simple but flexible Result object. This results in an expressive DSL like the one below :)

(the DSL was partly inspired by respond_to method)

result.path(:success) { redirect_to client_order_path(params[:order_id]), notice: "Your order is being submitted" }
result.path(:products_out_of_stock) { |unavailable_products| redirect_to edit_client_order_path(params[:order_id]),
alert: "Order can not be submitted! #{unavailable_products.join(", ")} not available in requested quantity!"}
result.path(:order_is_empty) { redirect_to edit_client_order_path(params[:order_id]), alert: "You can't submit an empty order" }
end
redirect_to client_order_path(params[:order_id]), notice: "Your order is being submitted"
rescue Ordering::Order::IsEmpty
redirect_to edit_client_order_path(params[:order_id]), alert: "You can't submit an empty order"
end

def show
Expand Down
18 changes: 7 additions & 11 deletions rails_application/app/controllers/orders_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,13 @@ def remove_item
end

def create
ApplicationRecord.transaction { submit_order(params[:order_id], params[:customer_id]) }
redirect_to order_path(params[:order_id]), notice: "Your order is being submitted"
rescue Ordering::Order::IsEmpty
redirect_to edit_order_path(params[:order_id]), alert: "You can't submit an empty order"
rescue Crm::Customer::NotExists
redirect_to order_path(params[:order_id]), alert: "Order can not be submitted! Customer does not exist."
Orders::SubmitService.new(order_id: params[:order_id], customer_id: params[:customer_id]).call.then do |result|
Copy link
Collaborator Author

@marlena-b marlena-b Sep 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially wanted to have two separate services for the admin and client side but then they looked almost entirely the same. So I decided to merge them so we don't duplicate too much code and tests.

result.path(:success) { redirect_to order_path(params[:order_id]), notice: "Your order is being submitted" }
result.path(:products_out_of_stock) { |unavailable_products| redirect_to edit_order_path(params[:order_id]),
alert: "Order can not be submitted! #{unavailable_products.join(", ")} not available in requested quantity!"}
result.path(:order_is_empty) { redirect_to edit_order_path(params[:order_id]), alert: "You can't submit an empty order" }
result.path(:customer_not_exists) { redirect_to order_path(params[:order_id]), alert: "Order can not be submitted! Customer does not exist." }
end
end

def expire
Expand Down Expand Up @@ -113,11 +114,6 @@ def cancel

private

def submit_order(order_id, customer_id)
command_bus.(Ordering::SubmitOrder.new(order_id: order_id))
command_bus.(Crm::AssignCustomerToOrder.new(order_id: order_id, customer_id: customer_id))
end

def authorize_payment(order_id)
command_bus.call(authorize_payment_cmd(order_id))
end
Expand Down
17 changes: 17 additions & 0 deletions rails_application/app/services/application_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class ApplicationService
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first time service objects are used in this repo so I wanted to keep them as simple as possible. In my implementation service objects should only implement initialize and call methods and then we can call them like MyService.call(..). This makes it easier to mock them (bc we only need to mock the class method call).

Please let me know if you prefer to use some other implementation instead, this is just my proposal.

def self.call(...)
new(...).call
end

def call
raise NotImplementedError
end

def event_store
Rails.configuration.event_store
end

def command_bus
Rails.configuration.command_bus
end
end
42 changes: 42 additions & 0 deletions rails_application/app/services/orders/submit_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module Orders
class SubmitService < ApplicationService
def initialize(order_id:, customer_id:)
@order_id = order_id
@customer_id = customer_id
end

def call
success = true
unavailable_products = nil

event_store
.within { submit_order }
.subscribe(to: Processes::ReservationProcessFailed) do |event|
success = false
unavailable_products = Products::Product.where(id: event.data.fetch(:unavailable_products)).pluck(:name)
end
.call

if success
Result.new(:success)
else
Result.new(:products_out_of_stock, unavailable_products)
end
rescue Ordering::Order::IsEmpty
Result.new(:order_is_empty)
rescue Crm::Customer::NotExists
Result.new(:customer_not_exists)
Comment on lines +20 to +28
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call method returns a Result object which represents a logical path of execution combined with optional arguments (e.g. unavailable_products).

end

private

attr_reader :order_id, :customer_id

def submit_order
ActiveRecord::Base.transaction do
command_bus.(Ordering::SubmitOrder.new(order_id: order_id))
command_bus.(Crm::AssignCustomerToOrder.new(order_id: order_id, customer_id: customer_id))
end
end
end
end
14 changes: 14 additions & 0 deletions rails_application/app/services/result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class Result
attr_reader :status, :args

def initialize(status, *args)
@status = status
@args = args
end

def path(name, &block)
return unless @status == name.to_sym

block.call(*@args)
end
Comment on lines +9 to +13
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also an alternative implementation which uses more metaprogramming but results in a nicer DSL:

def method_missing(name, *_, &block)
  return unless @status == name.to_sym

  block.call(*@args)
end

and then the DSL looks like this:

MyService.call(params).then do |on|
  on.success { puts "Awesome!" }
  on.validation_failed { |errors| puts "Oh no, there are some errors: #{errors}" }
  on.something_else { |arg1, arg2| puts arg1 * arg2 }
end

Let me know which one you prefer :)

end
16 changes: 16 additions & 0 deletions rails_application/test/integration/orders_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,22 @@ def test_empty_order_cannot_be_submitted
assert_select "#alert", "You can't submit an empty order"
end

def test_order_cannot_be_submitted_with_out_of_stock_product
product_id = register_product("Fearless Refactoring", 4, 10)
shopify_id = register_customer("Shopify")

supply_product(product_id, 1)
order_1_id = SecureRandom.uuid
order_2_id = SecureRandom.uuid
post "/orders/#{order_1_id}/add_item?product_id=#{product_id}"
post "/orders/#{order_2_id}/add_item?product_id=#{product_id}"

post "/orders", params: { order_id: order_1_id, customer_id: shopify_id }
post "/orders", params: { order_id: order_2_id, customer_id: shopify_id }

assert_equal "Order can not be submitted! Fearless Refactoring not available in requested quantity!", flash[:alert]
end

private

def assert_remove_buttons_visible(async_remote_id, fearless_id, order_id)
Expand Down
Loading
Loading