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 %> + + + + + + + + + + + + + + + <% @refund_items.each do |refund_item| %> + + + + + + + + + <% end %> + + + + + + + +
ProductQuantityPriceValue
<%= 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 %> +
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 %> - - - - - - - - - - - - - <% @order_lines.each do |order_line| %> - - - - - - - - - <% end %> - - - - - - - -
ProductQuantityPriceValue
<%= 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") %>
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