diff --git a/ecommerce/ordering/lib/ordering.rb b/ecommerce/ordering/lib/ordering.rb
index ca4ad0c1..2f0be385 100644
--- a/ecommerce/ordering/lib/ordering.rb
+++ b/ecommerce/ordering/lib/ordering.rb
@@ -5,16 +5,24 @@
require_relative "ordering/events/order_expired"
require_relative "ordering/events/order_submitted"
require_relative "ordering/events/order_rejected"
+require_relative "ordering/events/draft_refund_created"
+require_relative "ordering/events/item_added_to_refund"
+require_relative "ordering/events/item_removed_from_refund"
require_relative "ordering/commands/add_item_to_basket"
require_relative "ordering/commands/remove_item_from_basket"
require_relative "ordering/commands/submit_order"
require_relative "ordering/commands/set_order_as_expired"
require_relative "ordering/commands/accept_order"
require_relative "ordering/commands/reject_order"
+require_relative "ordering/commands/create_draft_refund"
+require_relative "ordering/commands/add_item_to_refund"
+require_relative "ordering/commands/remove_item_from_refund"
require_relative "ordering/fake_number_generator"
require_relative "ordering/number_generator"
require_relative "ordering/service"
require_relative "ordering/order"
+require_relative "ordering/refund"
+require_relative "ordering/refundable_products"
module Ordering
class Configuration
@@ -32,6 +40,9 @@ def call(event_store, command_bus)
command_bus.register(SetOrderAsExpired, OnSetOrderAsExpired.new(event_store))
command_bus.register(AcceptOrder, OnAcceptOrder.new(event_store))
command_bus.register(RejectOrder, OnRejectOrder.new(event_store))
+ command_bus.register(CreateDraftRefund, OnCreateDraftRefund.new(event_store))
+ command_bus.register(AddItemToRefund, OnAddItemToRefund.new(event_store))
+ command_bus.register(RemoveItemFromRefund, OnRemoveItemFromRefund.new(event_store))
end
end
end
diff --git a/ecommerce/ordering/lib/ordering/commands/add_item_to_refund.rb b/ecommerce/ordering/lib/ordering/commands/add_item_to_refund.rb
new file mode 100644
index 00000000..2825d3e8
--- /dev/null
+++ b/ecommerce/ordering/lib/ordering/commands/add_item_to_refund.rb
@@ -0,0 +1,9 @@
+module Ordering
+ class AddItemToRefund < Infra::Command
+ attribute :refund_id, Infra::Types::UUID
+ attribute :order_id, Infra::Types::UUID
+ attribute :product_id, Infra::Types::UUID
+
+ alias aggregate_id refund_id
+ end
+end
diff --git a/ecommerce/ordering/lib/ordering/commands/create_draft_refund.rb b/ecommerce/ordering/lib/ordering/commands/create_draft_refund.rb
new file mode 100644
index 00000000..f3942f34
--- /dev/null
+++ b/ecommerce/ordering/lib/ordering/commands/create_draft_refund.rb
@@ -0,0 +1,8 @@
+module Ordering
+ class CreateDraftRefund < Infra::Command
+ attribute :refund_id, Infra::Types::UUID
+ attribute :order_id, Infra::Types::UUID
+
+ alias aggregate_id refund_id
+ end
+end
diff --git a/ecommerce/ordering/lib/ordering/commands/remove_item_from_refund.rb b/ecommerce/ordering/lib/ordering/commands/remove_item_from_refund.rb
new file mode 100644
index 00000000..e63a1a3a
--- /dev/null
+++ b/ecommerce/ordering/lib/ordering/commands/remove_item_from_refund.rb
@@ -0,0 +1,9 @@
+module Ordering
+ class RemoveItemFromRefund < Infra::Command
+ attribute :refund_id, Infra::Types::UUID
+ attribute :order_id, Infra::Types::UUID
+ attribute :product_id, Infra::Types::UUID
+
+ alias aggregate_id refund_id
+ end
+end
diff --git a/ecommerce/ordering/lib/ordering/events/draft_refund_created.rb b/ecommerce/ordering/lib/ordering/events/draft_refund_created.rb
new file mode 100644
index 00000000..f10f3136
--- /dev/null
+++ b/ecommerce/ordering/lib/ordering/events/draft_refund_created.rb
@@ -0,0 +1,12 @@
+module Ordering
+ class DraftRefundCreated < Infra::Event
+ attribute :refund_id, Infra::Types::UUID
+ attribute :order_id, Infra::Types::UUID
+ attribute :refundable_products, Infra::Types::Array.of(
+ Infra::Types::Hash.schema(
+ product_id: Infra::Types::UUID,
+ quantity: Infra::Types::Integer
+ )
+ )
+ end
+end
diff --git a/ecommerce/ordering/lib/ordering/events/item_added_to_refund.rb b/ecommerce/ordering/lib/ordering/events/item_added_to_refund.rb
new file mode 100644
index 00000000..40b4a207
--- /dev/null
+++ b/ecommerce/ordering/lib/ordering/events/item_added_to_refund.rb
@@ -0,0 +1,7 @@
+module Ordering
+ class ItemAddedToRefund < Infra::Event
+ attribute :refund_id, Infra::Types::UUID
+ attribute :order_id, Infra::Types::UUID
+ attribute :product_id, Infra::Types::UUID
+ end
+end
diff --git a/ecommerce/ordering/lib/ordering/events/item_removed_from_refund.rb b/ecommerce/ordering/lib/ordering/events/item_removed_from_refund.rb
new file mode 100644
index 00000000..7c352528
--- /dev/null
+++ b/ecommerce/ordering/lib/ordering/events/item_removed_from_refund.rb
@@ -0,0 +1,7 @@
+module Ordering
+ class ItemRemovedFromRefund < Infra::Event
+ attribute :refund_id, Infra::Types::UUID
+ attribute :order_id, Infra::Types::UUID
+ attribute :product_id, Infra::Types::UUID
+ end
+end
diff --git a/ecommerce/ordering/lib/ordering/refund.rb b/ecommerce/ordering/lib/ordering/refund.rb
new file mode 100644
index 00000000..64a2f503
--- /dev/null
+++ b/ecommerce/ordering/lib/ordering/refund.rb
@@ -0,0 +1,72 @@
+module Ordering
+ class Refund
+ include AggregateRoot
+
+ ExceedsOrderQuantityError = Class.new(StandardError)
+ RefundHaveNotBeenRequestedForThisProductError = Class.new(StandardError)
+
+ def initialize(id)
+ @id = id
+ @refund_items = ItemsList.new
+ end
+
+ def create_draft(order_id, refundable_products)
+ apply DraftRefundCreated.new(data: { refund_id: @id, order_id: order_id, refundable_products: refundable_products })
+ end
+
+ def add_item(product_id)
+ raise ExceedsOrderQuantityError unless enough_items?(product_id)
+ apply ItemAddedToRefund.new(data: { refund_id: @id, order_id: @order_id, product_id: product_id })
+ end
+
+ def remove_item(product_id)
+ raise RefundHaveNotBeenRequestedForThisProductError unless @refund_items.quantity(product_id).positive?
+ apply ItemRemovedFromRefund.new(data: { refund_id: @id, order_id: @order_id, product_id: product_id })
+ end
+
+ on DraftRefundCreated do |event|
+ @order_id = event.data[:order_id]
+ @refundable_products = event.data[:refundable_products]
+ end
+
+ on ItemAddedToRefund do |event|
+ @refund_items.increase_quantity(event.data[:product_id])
+ end
+
+ on ItemRemovedFromRefund do |event|
+ @refund_items.decrease_quantity(event.data[:product_id])
+ end
+
+ private
+
+ def enough_items?(product_id)
+ @refund_items.quantity(product_id) < refundable_quantity(product_id)
+ end
+
+ def refundable_quantity(product_id)
+ product = @refundable_products.find { |product| product.fetch(:product_id) == product_id }
+ product.fetch(:quantity)
+ end
+ end
+
+ class ItemsList
+ attr_reader :refund_items
+
+ def initialize
+ @refund_items = Hash.new(0)
+ end
+
+ def increase_quantity(product_id)
+ refund_items[product_id] = quantity(product_id) + 1
+ end
+
+ def decrease_quantity(product_id)
+ refund_items[product_id] -= 1
+ refund_items.delete(product_id) if quantity(product_id).equal?(0)
+ end
+
+ def quantity(product_id)
+ refund_items[product_id]
+ end
+ end
+end
diff --git a/ecommerce/ordering/lib/ordering/refundable_products.rb b/ecommerce/ordering/lib/ordering/refundable_products.rb
new file mode 100644
index 00000000..d436bc7e
--- /dev/null
+++ b/ecommerce/ordering/lib/ordering/refundable_products.rb
@@ -0,0 +1,32 @@
+module Ordering
+ class RefundableProducts
+ class << self
+ def call(order_id)
+ RubyEventStore::Projection
+ .from_stream("Ordering::Order$#{order_id}")
+ .init(-> { [] })
+ .when(ItemAddedToBasket, -> (state, event) { increase_quantity(state, event.data.fetch(:product_id)) })
+ .when(ItemRemovedFromBasket, -> (state, event) { decrease_quantity(state, event.data.fetch(:product_id)) })
+ end
+
+ private
+
+ def increase_quantity(state, product_id)
+ prod_quantity = state.find { |prod_quantity| prod_quantity.fetch(:product_id) == product_id }
+
+ if prod_quantity
+ prod_quantity[:quantity] += 1
+ else
+ state << { product_id: product_id, quantity: 1 }
+ end
+ end
+
+ def decrease_quantity(state, product_id)
+ prod_quantity = state.find { |prod_quantity| prod_quantity.fetch(:product_id) == product_id }
+
+ prod_quantity[:quantity] -= 1
+ state.delete(prod_quantity) if prod_quantity.fetch(:quantity).zero?
+ end
+ end
+ end
+end
diff --git a/ecommerce/ordering/lib/ordering/service.rb b/ecommerce/ordering/lib/ordering/service.rb
index 4ab6dde8..02f2373b 100644
--- a/ecommerce/ordering/lib/ordering/service.rb
+++ b/ecommerce/ordering/lib/ordering/service.rb
@@ -72,4 +72,52 @@ def call(command)
end
end
end
+
+ class OnCreateDraftRefund
+ def initialize(event_store)
+ @repository = Infra::AggregateRootRepository.new(event_store)
+ @event_store = event_store
+ end
+
+ def call(command)
+ @repository.with_aggregate(Refund, command.aggregate_id) do |refund|
+ refund.create_draft(
+ command.order_id,
+ refundable_products(command.order_id)
+ )
+ end
+ end
+
+ private
+
+ def refundable_products(order_id)
+ RefundableProducts
+ .call(order_id)
+ .run(@event_store)
+ end
+ end
+
+ class OnAddItemToRefund
+ def initialize(event_store)
+ @repository = Infra::AggregateRootRepository.new(event_store)
+ end
+
+ def call(command)
+ @repository.with_aggregate(Refund, command.aggregate_id) do |refund|
+ refund.add_item(command.product_id)
+ end
+ end
+ end
+
+ class OnRemoveItemFromRefund
+ def initialize(event_store)
+ @repository = Infra::AggregateRootRepository.new(event_store)
+ end
+
+ def call(command)
+ @repository.with_aggregate(Refund, command.aggregate_id) do |refund|
+ refund.remove_item(command.product_id)
+ end
+ end
+ end
end
diff --git a/ecommerce/ordering/test/add_item_to_refund_test.rb b/ecommerce/ordering/test/add_item_to_refund_test.rb
new file mode 100644
index 00000000..7ce8f9b9
--- /dev/null
+++ b/ecommerce/ordering/test/add_item_to_refund_test.rb
@@ -0,0 +1,69 @@
+require_relative "test_helper"
+
+module Ordering
+ class AddItemToRefundTest < Test
+ cover "Ordering::OnAddItemToRefund*"
+
+ def test_add_item_to_refund
+ order_id = SecureRandom.uuid
+ aggregate_id = SecureRandom.uuid
+ product_1_id = SecureRandom.uuid
+ product_2_id = SecureRandom.uuid
+ product_3_id = SecureRandom.uuid
+ stream = "Ordering::Refund$#{aggregate_id}"
+
+ arrange(
+ AddItemToBasket.new(order_id: order_id, product_id: product_1_id),
+ AddItemToBasket.new(order_id: order_id, product_id: product_2_id),
+ AddItemToBasket.new(order_id: order_id, product_id: product_2_id),
+ AddItemToBasket.new(order_id: order_id, product_id: product_3_id),
+ CreateDraftRefund.new(refund_id: aggregate_id, order_id: order_id),
+ AddItemToRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_2_id
+ )
+ )
+
+ expected_events = [
+ ItemAddedToRefund.new(
+ data: {
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_2_id
+ }
+ )
+ ]
+
+ assert_events(stream, *expected_events) do
+ act(
+ AddItemToRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_2_id
+ )
+ )
+ end
+ end
+
+ def test_add_item_raises_exceeds_order_quantity_error
+ aggregate_id = SecureRandom.uuid
+ order_id = SecureRandom.uuid
+ product_id = SecureRandom.uuid
+
+ arrange(
+ AddItemToBasket.new(order_id: order_id, product_id: product_id),
+ CreateDraftRefund.new(refund_id: aggregate_id, order_id: order_id),
+ AddItemToRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_id
+ )
+ )
+
+ assert_raises(Ordering::Refund::ExceedsOrderQuantityError) do
+ act(AddItemToRefund.new(refund_id: aggregate_id, order_id: order_id, product_id: product_id))
+ end
+ end
+ end
+end
diff --git a/ecommerce/ordering/test/create_draft_refund_test.rb b/ecommerce/ordering/test/create_draft_refund_test.rb
new file mode 100644
index 00000000..3ba34416
--- /dev/null
+++ b/ecommerce/ordering/test/create_draft_refund_test.rb
@@ -0,0 +1,32 @@
+require_relative "test_helper"
+
+module Ordering
+ class CreateDraftRefundTest < Test
+ cover "Ordering::OnCreateDraftRefund*"
+
+ def test_draft_refund_created
+ order_id = SecureRandom.uuid
+ aggregate_id = SecureRandom.uuid
+ stream = "Ordering::Refund$#{aggregate_id}"
+
+ expected_events = [
+ DraftRefundCreated.new(
+ data: {
+ refund_id: aggregate_id,
+ order_id: order_id,
+ refundable_products: []
+ }
+ )
+ ]
+
+ assert_events(stream, *expected_events) do
+ act(
+ CreateDraftRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id
+ )
+ )
+ end
+ end
+ end
+end
diff --git a/ecommerce/ordering/test/refund_items_list_test.rb b/ecommerce/ordering/test/refund_items_list_test.rb
new file mode 100644
index 00000000..f9592c77
--- /dev/null
+++ b/ecommerce/ordering/test/refund_items_list_test.rb
@@ -0,0 +1,48 @@
+require_relative "test_helper"
+
+module Ordering
+ class RefundItemsListTest < Test
+
+ def test_initialize
+ list = ItemsList.new
+
+ assert_equal 0, list.refund_items.size
+ end
+
+ def test_increase_item_quantity
+ product_one_id = SecureRandom.uuid
+ product_two_id = SecureRandom.uuid
+ list = ItemsList.new
+
+ list.increase_quantity(product_one_id)
+
+ assert_equal 1, list.refund_items.size
+ assert_equal 1, list.quantity(product_one_id)
+
+ list.increase_quantity(product_two_id)
+
+ assert_equal 2, list.refund_items.size
+ assert_equal 1, list.quantity(product_two_id)
+ end
+
+ def test_decrease_item_quantity
+ product_id = SecureRandom.uuid
+ list = ItemsList.new
+
+ list.increase_quantity(product_id)
+ list.increase_quantity(product_id)
+
+ assert_equal 1, list.refund_items.size
+ assert_equal 2, list.quantity(product_id)
+
+ list.decrease_quantity(product_id)
+
+ assert_equal 1, list.refund_items.size
+ assert_equal 1, list.quantity(product_id)
+
+ list.decrease_quantity(product_id)
+
+ assert_equal 0, list.refund_items.size
+ end
+ end
+end
diff --git a/ecommerce/ordering/test/refundable_products_test.rb b/ecommerce/ordering/test/refundable_products_test.rb
new file mode 100644
index 00000000..b9c81091
--- /dev/null
+++ b/ecommerce/ordering/test/refundable_products_test.rb
@@ -0,0 +1,29 @@
+require_relative "test_helper"
+
+module Ordering
+ class RefundableProductsTest < Test
+ cover "Ordering::RefundableProducts"
+
+ def test_product_quantity_available_to_refund
+ order_id = SecureRandom.uuid
+ product_1_id = SecureRandom.uuid
+ product_2_id = SecureRandom.uuid
+ product_3_id = SecureRandom.uuid
+ stream_name = "Ordering::Order$#{order_id}"
+ projection = RefundableProducts.call(order_id)
+
+ event_store = RubyEventStore::Client.new(repository: RubyEventStore::InMemoryRepository.new)
+
+ event_store.publish(ItemAddedToBasket.new(data: { order_id: order_id, product_id: product_3_id }), stream_name: stream_name)
+ event_store.publish(ItemAddedToBasket.new(data: { order_id: order_id, product_id: product_1_id }), stream_name: stream_name)
+ event_store.publish(ItemAddedToBasket.new(data: { order_id: order_id, product_id: product_2_id }), stream_name: stream_name)
+ event_store.publish(ItemAddedToBasket.new(data: { order_id: order_id, product_id: product_2_id }), stream_name: stream_name)
+ event_store.publish(ItemRemovedFromBasket.new(data: { order_id: order_id, product_id: product_2_id }), stream_name: stream_name)
+ event_store.publish(ItemRemovedFromBasket.new(data: { order_id: order_id, product_id: product_3_id }), stream_name: stream_name)
+
+ refundable_products = projection.run(event_store)
+
+ assert_equal([{ product_id: product_1_id, quantity: 1}, {product_id: product_2_id, quantity: 1 }], refundable_products)
+ end
+ end
+end
diff --git a/ecommerce/ordering/test/remove_item_from_refund_test.rb b/ecommerce/ordering/test/remove_item_from_refund_test.rb
new file mode 100644
index 00000000..ba582e1d
--- /dev/null
+++ b/ecommerce/ordering/test/remove_item_from_refund_test.rb
@@ -0,0 +1,86 @@
+require_relative "test_helper"
+
+module Ordering
+ class RemoveItemFromRefundTest < Test
+ cover "Ordering::OnRemoveItemFromRefund*"
+
+ def test_removing_items_from_refund
+ order_id = SecureRandom.uuid
+ aggregate_id = SecureRandom.uuid
+ product_1_id = SecureRandom.uuid
+ product_2_id = SecureRandom.uuid
+ product_3_id = SecureRandom.uuid
+ stream = "Ordering::Refund$#{aggregate_id}"
+
+ arrange(
+ AddItemToBasket.new(order_id: order_id, product_id: product_1_id),
+ AddItemToBasket.new(order_id: order_id, product_id: product_2_id),
+ AddItemToBasket.new(order_id: order_id, product_id: product_2_id),
+ AddItemToBasket.new(order_id: order_id, product_id: product_3_id),
+ CreateDraftRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id
+ ),
+ AddItemToRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_1_id
+ )
+ )
+
+ expected_events = [
+ ItemRemovedFromRefund.new(
+ data: {
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_1_id
+ }
+ )
+ ]
+
+ assert_events(stream, *expected_events) do
+ act(
+ RemoveItemFromRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_1_id
+ )
+ )
+ end
+ end
+
+ def test_cant_remove_item_with_0_quantity
+ order_id = SecureRandom.uuid
+ aggregate_id = SecureRandom.uuid
+ product_id = SecureRandom.uuid
+
+ arrange(
+ AddItemToBasket.new(order_id: order_id, product_id: product_id),
+ CreateDraftRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id
+ ),
+ AddItemToRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_id
+ ),
+ RemoveItemFromRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_id
+ )
+ )
+
+ assert_raises(Refund::RefundHaveNotBeenRequestedForThisProductError) do
+ act(
+ RemoveItemFromRefund.new(
+ refund_id: aggregate_id,
+ order_id: order_id,
+ product_id: product_id
+ )
+ )
+ end
+ end
+ end
+end
diff --git a/rails_application/app/controllers/refunds_controller.rb b/rails_application/app/controllers/refunds_controller.rb
index 2638a976..02d5c18a 100644
--- a/rails_application/app/controllers/refunds_controller.rb
+++ b/rails_application/app/controllers/refunds_controller.rb
@@ -1,7 +1,71 @@
class RefundsController < ApplicationController
- def new
- @order = Orders::Order.find_by_uid(params[:order_id])
- @refund = Refunds::Refund.new
- @order_lines = Orders::OrderLine.where(order_uid: params[:order_id])
+ def edit
+ @refund = Refunds::Refund.find_by_uid!(params[:id])
+ @order = Orders::Order.find_by_uid!(@refund.order_uid)
+ @refund_items = build_refund_items_list(@order.order_lines, @refund.refund_items)
+ end
+
+ def create
+ refund_id = SecureRandom.uuid
+ create_draft_refund(refund_id)
+
+ redirect_to edit_order_refund_path(refund_id, order_id: params[:order_id])
+ end
+
+ def add_item
+ add_item_to_refund
+ redirect_to edit_order_refund_path(params[:id], order_id: params[:order_id])
+ rescue Ordering::Refund::ExceedsOrderQuantityError
+ flash[:alert] = "You cannot add more of this product to the refund than is in the original order."
+ redirect_to edit_order_refund_path(params[:id], order_id: params[:order_id])
+ end
+
+ def remove_item
+ remove_item_from_refund
+ redirect_to edit_order_refund_path(params[:id], order_id: params[:order_id])
+ rescue Ordering::Refund::RefundHaveNotBeenRequestedForThisProductError
+ flash[:alert] = "This product is not added to the refund."
+ redirect_to edit_order_refund_path(params[:id], order_id: params[:order_id])
+ end
+
+ private
+
+ def create_draft_refund_cmd(refund_id)
+ Ordering::CreateDraftRefund.new(refund_id: refund_id, order_id: params[:order_id])
+ end
+
+ def create_draft_refund(refund_id)
+ command_bus.(create_draft_refund_cmd(refund_id))
+ end
+
+ def add_item_to_refund_cmd
+ Ordering::AddItemToRefund.new(refund_id: params[:id], order_id: params[:order_id], product_id: params[:product_id])
+ end
+
+ def add_item_to_refund
+ command_bus.(add_item_to_refund_cmd)
+ end
+
+ def remove_item_from_refund_cmd
+ Ordering::RemoveItemFromRefund.new(refund_id: params[:id], order_id: params[:order_id], product_id: params[:product_id])
+ end
+
+ def remove_item_from_refund
+ command_bus.(remove_item_from_refund_cmd)
+ end
+
+ def build_refund_items_list(order_lines, refund_items)
+ order_lines.map { |order_line| build_refund_item(order_line, refund_items) }
+ end
+
+ def build_refund_item(order_line, refund_items)
+ refund_item = refund_items.find { |item| item.product_uid == order_line.product_id } || initialize_refund_item(order_line)
+
+ refund_item.order_line = order_line
+ refund_item
+ end
+
+ def initialize_refund_item(order_line)
+ Refunds::RefundItem.new(product_uid: order_line.product_id, quantity: 0, price: order_line.price)
end
end
diff --git a/rails_application/app/read_models/refunds/add_item_to_refund.rb b/rails_application/app/read_models/refunds/add_item_to_refund.rb
new file mode 100644
index 00000000..c33c19e6
--- /dev/null
+++ b/rails_application/app/read_models/refunds/add_item_to_refund.rb
@@ -0,0 +1,21 @@
+module Refunds
+ class AddItemToRefund
+ def call(event)
+ refund = Refund.find_by!(uid: event.data.fetch(:refund_id))
+ product = Orders::Product.find_by!(uid: event.data.fetch(:product_id))
+
+ item = refund.refund_items.find_or_create_by(product_uid: product.uid) do |item|
+ item.price = product.price
+ item.quantity = 0
+ end
+
+ refund.total_value += item.price
+ item.quantity += 1
+
+ ActiveRecord::Base.transaction do
+ refund.save!
+ item.save!
+ end
+ end
+ end
+end
diff --git a/rails_application/app/read_models/refunds/configuration.rb b/rails_application/app/read_models/refunds/configuration.rb
index 271a8405..763fd760 100644
--- a/rails_application/app/read_models/refunds/configuration.rb
+++ b/rails_application/app/read_models/refunds/configuration.rb
@@ -4,17 +4,34 @@ class Refund < ApplicationRecord
has_many :refund_items,
class_name: "Refunds::RefundItem",
- foreign_key: :order_uid,
+ foreign_key: :refund_uid,
primary_key: :uid
end
class RefundItem < ApplicationRecord
self.table_name = "refund_items"
+
+ attr_accessor :order_line
+ delegate :product_name, to: :order_line
+
+ def max_quantity?
+ quantity == order_quantity
+ end
+
+ def order_quantity
+ order_line.quantity
+ end
+
+ def value
+ quantity * price
+ end
end
class Configuration
def call(event_store)
- @event_store = event_store
+ event_store.subscribe(CreateDraftRefund.new, to: [Ordering::DraftRefundCreated])
+ event_store.subscribe(AddItemToRefund.new, to: [Ordering::ItemAddedToRefund])
+ event_store.subscribe(RemoveItemFromRefund.new, to: [Ordering::ItemRemovedFromRefund])
end
end
end
diff --git a/rails_application/app/read_models/refunds/create_draft_refund.rb b/rails_application/app/read_models/refunds/create_draft_refund.rb
new file mode 100644
index 00000000..be0e9cd0
--- /dev/null
+++ b/rails_application/app/read_models/refunds/create_draft_refund.rb
@@ -0,0 +1,7 @@
+module Refunds
+ class CreateDraftRefund
+ def call(event)
+ Refund.create!(uid: event.data[:refund_id], order_uid: event.data[:order_id], status: "Draft", total_value: 0)
+ end
+ end
+end
diff --git a/rails_application/app/read_models/refunds/remove_item_from_refund.rb b/rails_application/app/read_models/refunds/remove_item_from_refund.rb
new file mode 100644
index 00000000..ce1e775c
--- /dev/null
+++ b/rails_application/app/read_models/refunds/remove_item_from_refund.rb
@@ -0,0 +1,16 @@
+module Refunds
+ class RemoveItemFromRefund
+ def call(event)
+ refund = Refund.find_by!(uid: event.data.fetch(:refund_id))
+ item = refund.refund_items.find_by!(product_uid: event.data.fetch(:product_id))
+
+ refund.total_value -= item.price
+ item.quantity -= 1
+
+ ActiveRecord::Base.transaction do
+ refund.save!
+ item.quantity > 0 ? item.save! : item.destroy!
+ end
+ end
+ end
+end
diff --git a/rails_application/app/views/orders/show.html.erb b/rails_application/app/views/orders/show.html.erb
index 979967ca..43659d24 100644
--- a/rails_application/app/views/orders/show.html.erb
+++ b/rails_application/app/views/orders/show.html.erb
@@ -23,8 +23,8 @@
<%= button_to("Pay", pay_order_path(@order.uid), class: "mr-3 ml-3 inline-flex items-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 border-transparent text-white bg-blue-600 hover:bg-blue-700") %>
<% end %>
- <% if @order.state == "Submitted" %>
- <%= link_to("Refund", new_order_refund_path(order_id: @order.uid), class: "mr-3 inline-flex items-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-50 border-gray-300 text-gray-700 bg-white hover:bg-gray-50") %>
+ <% if @order.state == "Paid" %>
+ <%= button_to("Refund", order_refunds_path(order_id: @order.uid), class: "ml-3 inline-flex items-center px-4 py-2 border rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-50 border-gray-300 text-gray-700 bg-white hover:bg-gray-50") %>
<% end %>
<% if (@order.state == "Submitted") %>
diff --git a/rails_application/app/views/refunds/edit.html.erb b/rails_application/app/views/refunds/edit.html.erb
new file mode 100644
index 00000000..57747b08
--- /dev/null
+++ b/rails_application/app/views/refunds/edit.html.erb
@@ -0,0 +1,53 @@
+<% content_for(:header) do %>
+ Refund for Order <%= @order.number %>
+<% end %>
+
+<% content_for(:actions) do %>
+ <%= secondary_action_button do %>
+ <%= link_to 'Back', order_path(@order.uid) %>
+ <% end %>
+
+ <%= primary_form_action_button do %>
+ Submit Refund
+ <% end %>
+<% end %>
+
+
+
+
+ | Product |
+ Quantity |
+ Price |
+ Value |
+ |
+ |
+
+
+
+
+ <% @refund_items.each do |refund_item| %>
+
+ | <%= refund_item.product_name %> |
+ <%= refund_item.quantity %> / <%= refund_item.order_quantity %> |
+ <%= number_to_currency(refund_item.price) %> |
+ <%= number_to_currency(refund_item.value) %> |
+
+ <% unless refund_item.max_quantity? %>
+ <%= button_to "Add", add_item_order_refund_path(order_id: @order.uid, id: @refund.uid, product_id: refund_item.product_uid), class: "hover:underline text-blue-500" %>
+ <% end %>
+ |
+
+ <% unless refund_item.quantity.zero? %>
+ <%= button_to "Remove", remove_item_order_refund_path(order_id: @order.uid, id: @refund.uid, product_id: refund_item.product_uid), class: "hover:underline text-blue-500" %>
+ <% end %>
+ |
+
+ <% end %>
+
+
+
+ | Total |
+ <%= number_to_currency(@refund.total_value) %> |
+
+
+
diff --git a/rails_application/app/views/refunds/new.html.erb b/rails_application/app/views/refunds/new.html.erb
deleted file mode 100644
index c9cf843b..00000000
--- a/rails_application/app/views/refunds/new.html.erb
+++ /dev/null
@@ -1,43 +0,0 @@
-<% content_for(:header) do %>
- Refund for Order <%= @order.number %>
-<% end %>
-
-<% content_for(:actions) do %>
- <%= secondary_action_button do %>
- <%= link_to 'Back', orders_path %>
- <% end %>
-
- <%= primary_form_action_button do %>
- Submit Refund
- <% end %>
-<% end %>
-
-
-
-
- | Product |
- Quantity |
- Price |
- Value |
-
-
-
-
- <% @order_lines.each do |order_line| %>
-
- | <%= order_line.product_name %> |
- 0 / <%= order_line.quantity %> |
- <%= number_to_currency(order_line.price) %> |
- <%= number_to_currency(order_line.value) %> |
- <%= button_to "Add", "#", class: "hover:underline text-blue-500" %> |
- <%= button_to("Remove", "#", class: "hover:underline text-blue-500") %> |
-
- <% end %>
-
-
-
- | Total |
- |
-
-
-
diff --git a/rails_application/config/routes.rb b/rails_application/config/routes.rb
index 079b59b5..43c155ed 100644
--- a/rails_application/config/routes.rb
+++ b/rails_application/config/routes.rb
@@ -17,7 +17,12 @@
resource :shipping_address, only: [:edit, :update]
resource :billing_address, only: [:edit, :update]
resource :invoice, only: [:create]
- resources :refunds, only: [:new]
+ resources :refunds, only: [:edit, :create] do
+ member do
+ post :add_item
+ post :remove_item
+ end
+ end
end
resources :shipments, only: [:index, :show]
diff --git a/rails_application/test/integration/refunds_test.rb b/rails_application/test/integration/refunds_test.rb
index 816a1b4b..b46ff133 100644
--- a/rails_application/test/integration/refunds_test.rb
+++ b/rails_application/test/integration/refunds_test.rb
@@ -16,17 +16,60 @@ def test_happy_path
add_product_to_basket(order_id, fearless_id)
add_product_to_basket(order_id, fearless_id)
submit_order(shopify_id, order_id)
+ pay_order(order_id)
get "/orders/#{order_id}"
- assert_select("a", "Refund")
+ assert_select("button", "Refund")
- get "/orders/#{order_id}/refunds/new"
+ post "/orders/#{order_id}/refunds"
+ follow_redirect!
assert_order_line_row(async_remote_id, "Async Remote", 1)
assert_order_line_row(fearless_id, "Fearless Refactoring", 2)
end
+ def test_renders_error_when_exceeds_available_quantity_to_refund
+ shopify_id = register_customer("Shopify")
+ order_id = SecureRandom.uuid
+ async_remote_id = register_product("Async Remote", 39, 10)
+
+ add_product_to_basket(order_id, async_remote_id)
+ submit_order(shopify_id, order_id)
+ pay_order(order_id)
+
+ post "/orders/#{order_id}/refunds"
+ follow_redirect!
+
+ refund = Refunds::Refund.last
+
+ add_item_to_refund(order_id, refund.uid, async_remote_id)
+ add_item_to_refund(order_id, refund.uid, async_remote_id)
+ follow_redirect!
+
+ assert_select("#alert", "You cannot add more of this product to the refund than is in the original order.")
+ end
+
+ def test_renders_error_when_trying_to_remove_not_added_product
+ shopify_id = register_customer("Shopify")
+ order_id = SecureRandom.uuid
+ async_remote_id = register_product("Async Remote", 39, 10)
+
+ add_product_to_basket(order_id, async_remote_id)
+ submit_order(shopify_id, order_id)
+ pay_order(order_id)
+
+ post "/orders/#{order_id}/refunds"
+ follow_redirect!
+
+ refund = Refunds::Refund.last
+
+ remove_item_from_refund(order_id, refund.uid, async_remote_id)
+ follow_redirect!
+
+ assert_select("#alert", "This product is not added to the refund.")
+ end
+
private
def assert_order_line_row(product_id, product_name, quantity)
diff --git a/rails_application/test/orders/item_added_to_refund_test.rb b/rails_application/test/orders/item_added_to_refund_test.rb
new file mode 100644
index 00000000..50d79629
--- /dev/null
+++ b/rails_application/test/orders/item_added_to_refund_test.rb
@@ -0,0 +1,54 @@
+require "test_helper"
+
+module Refunds
+ class ItemAddedToRefundTest < InMemoryTestCase
+ cover "Orders*"
+
+ def test_add_item_to_refund
+ refund_id = SecureRandom.uuid
+ product_id = SecureRandom.uuid
+ order_id = SecureRandom.uuid
+ prepare_product(product_id, 50)
+ create_draft_refund(refund_id, order_id)
+
+ AddItemToRefund.new.call(item_added_to_refund(refund_id, order_id, product_id))
+
+ assert_equal(1, Refunds::RefundItem.count)
+ refund_item = Refunds::RefundItem.find_by(refund_uid: refund_id, product_uid: product_id)
+ assert_equal(product_id, refund_item.product_uid)
+ assert_equal(1, refund_item.quantity)
+ assert_equal(50, refund_item.price)
+
+ assert_equal(1, Refunds::Refund.count)
+ refund = Refunds::Refund.find_by(uid: refund_id)
+ assert_equal("Draft", refund.status)
+ end
+
+ private
+
+ def prepare_product(product_id, price)
+ run_command(
+ ProductCatalog::RegisterProduct.new(
+ product_id: product_id,
+ )
+ )
+ run_command(
+ ProductCatalog::NameProduct.new(
+ product_id: product_id,
+ name: "Async Remote"
+ )
+ )
+ run_command(Pricing::SetPrice.new(product_id: product_id, price: price))
+ end
+
+ def create_draft_refund(refund_id, order_id)
+ run_command(
+ Ordering::CreateDraftRefund.new(refund_id: refund_id, order_id: order_id)
+ )
+ end
+
+ def item_added_to_refund(refund_id, order_id, product_id)
+ Ordering::ItemAddedToRefund.new(data: { refund_id: refund_id, order_id: order_id, product_id: product_id })
+ end
+ end
+end
diff --git a/rails_application/test/orders/item_removed_from_refund_test.rb b/rails_application/test/orders/item_removed_from_refund_test.rb
new file mode 100644
index 00000000..c459c1fa
--- /dev/null
+++ b/rails_application/test/orders/item_removed_from_refund_test.rb
@@ -0,0 +1,62 @@
+require "test_helper"
+
+module Refunds
+ class ItemRemovedFromRefundTest < InMemoryTestCase
+ cover "Orders*"
+
+ def test_remove_item_from_refund
+ refund_id = SecureRandom.uuid
+ product_id = SecureRandom.uuid
+ another_product_id = SecureRandom.uuid
+ order_id = SecureRandom.uuid
+ refundable_products = [{product_id: product_id, quantity: 1}, {product_id: another_product_id, quantity: 1}]
+ create_draft_refund(refund_id, order_id, refundable_products)
+ prepare_product(product_id, 50)
+ prepare_product(another_product_id, 30)
+ AddItemToRefund.new.call(item_added_to_refund(refund_id, order_id, product_id))
+ AddItemToRefund.new.call(item_added_to_refund(refund_id, order_id, another_product_id))
+
+ RemoveItemFromRefund.new.call(item_removed_from_refund(refund_id, order_id, product_id))
+
+ assert_equal(1, Refunds::RefundItem.count)
+ refund_item = Refunds::RefundItem.find_by(refund_uid: refund_id, product_uid: another_product_id)
+ assert_equal(another_product_id, refund_item.product_uid)
+ assert_equal(1, refund_item.quantity)
+ assert_equal(30, refund_item.price)
+
+ assert_equal(1, Refunds::Refund.count)
+ refund = Refunds::Refund.find_by(uid: refund_id)
+ assert_equal("Draft", refund.status)
+ end
+
+ private
+
+ def create_draft_refund(refund_id, order_id, refundable_products)
+ draft_refund_created = Ordering::DraftRefundCreated.new(data: { refund_id: refund_id, order_id: order_id, refundable_products: refundable_products })
+ CreateDraftRefund.new.call(draft_refund_created)
+ end
+
+ def prepare_product(product_id, price)
+ run_command(
+ ProductCatalog::RegisterProduct.new(
+ product_id: product_id,
+ )
+ )
+ run_command(
+ ProductCatalog::NameProduct.new(
+ product_id: product_id,
+ name: "Async Remote"
+ )
+ )
+ run_command(Pricing::SetPrice.new(product_id: product_id, price: price))
+ end
+
+ def item_added_to_refund(refund_id, order_id, product_id)
+ Ordering::ItemAddedToRefund.new(data: { refund_id: refund_id, order_id: order_id, product_id: product_id })
+ end
+
+ def item_removed_from_refund(refund_id, order_id, product_id)
+ Ordering::ItemRemovedFromRefund.new(data: { refund_id: refund_id, order_id: order_id, product_id: product_id })
+ end
+ end
+end
diff --git a/rails_application/test/test_helper.rb b/rails_application/test/test_helper.rb
index 1b30559f..7b981a52 100644
--- a/rails_application/test/test_helper.rb
+++ b/rails_application/test/test_helper.rb
@@ -127,6 +127,14 @@ def add_product_to_basket(order_id, product_id)
post "/orders/#{order_id}/add_item?product_id=#{product_id}"
end
+ def add_item_to_refund(order_id, refund_id, product_id)
+ post "/orders/#{order_id}/refunds/#{refund_id}/add_item?product_id=#{product_id}"
+ end
+
+ def remove_item_from_refund(order_id, refund_id, product_id)
+ post "/orders/#{order_id}/refunds/#{refund_id}/remove_item?product_id=#{product_id}"
+ end
+
def run_command(command)
Rails.configuration.command_bus.call(command)
end