diff --git a/app/controllers/spree/users_controller.rb b/app/controllers/spree/users_controller.rb index 1fb03af1f9..c9ee2e15fe 100644 --- a/app/controllers/spree/users_controller.rb +++ b/app/controllers/spree/users_controller.rb @@ -12,9 +12,13 @@ module Spree before_action :set_locale before_action :enable_embedded_shopfront - # Ignores invoice orders, only order where state: 'complete' def show - @orders = @user.orders.where(state: 'complete').order('completed_at desc') + @orders = orders_collection + + customers = spree_current_user.customers + @shops = Enterprise + .where(id: @orders.pluck(:distributor_id).uniq | customers.pluck(:enterprise_id)) + @unconfirmed_email = spree_current_user.unconfirmed_email end @@ -54,6 +58,14 @@ module Spree private + def orders_collection + if OpenFoodNetwork::FeatureToggle.enabled?(:customer_balance, spree_current_user) + CompleteOrdersWithBalance.new(@user).query + else + @user.orders.where(state: 'complete').order('completed_at desc') + end + end + def load_object @user ||= spree_current_user if @user diff --git a/app/models/spree/order.rb b/app/models/spree/order.rb index ceb603e275..105566aa0d 100644 --- a/app/models/spree/order.rb +++ b/app/models/spree/order.rb @@ -130,6 +130,11 @@ module Spree where("state != ?", state) } + # All the states an order can be in after completing the checkout + FINALIZED_STATES = %w(complete canceled resumed awaiting_return returned).freeze + + scope :finalized, -> { where(state: FINALIZED_STATES) } + def self.by_number(number) where(number: number) end diff --git a/app/queries/complete_orders_with_balance.rb b/app/queries/complete_orders_with_balance.rb new file mode 100644 index 0000000000..57223e5c6e --- /dev/null +++ b/app/queries/complete_orders_with_balance.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Fetches complete orders of the specified user including their balance as a computed column +class CompleteOrdersWithBalance + def initialize(user) + @user = user + end + + def query + OutstandingBalance.new(sorted_finalized_orders).query + end + + private + + def sorted_finalized_orders + @user.orders + .finalized + .select('spree_orders.*') + .order(completed_at: :desc) + end +end diff --git a/app/queries/customers_with_balance.rb b/app/queries/customers_with_balance.rb new file mode 100644 index 0000000000..4abd070d34 --- /dev/null +++ b/app/queries/customers_with_balance.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Fetches the customers of the specified enterprise including the aggregated balance across the +# customer's orders. That is, we get the total balance for each customer with this enterprise. +class CustomersWithBalance + def initialize(enterprise) + @enterprise = enterprise + end + + def query + Customer.of(enterprise). + joins(left_join_complete_orders). + group("customers.id"). + select("customers.*"). + select(outstanding_balance_sum) + end + + private + + attr_reader :enterprise + + def outstanding_balance_sum + "SUM(#{OutstandingBalance.new.statement}) AS balance_value" + end + + # The resulting orders are in states that belong after the checkout. Only these can be considered + # for a customer's balance. + def left_join_complete_orders + <<-SQL.strip_heredoc + LEFT JOIN spree_orders ON spree_orders.customer_id = customers.id + AND #{finalized_states.to_sql} + SQL + end + + def finalized_states + states = Spree::Order::FINALIZED_STATES.map { |state| Arel::Nodes.build_quoted(state) } + Arel::Nodes::In.new(Spree::Order.arel_table[:state], states) + end +end diff --git a/app/queries/outstanding_balance.rb b/app/queries/outstanding_balance.rb new file mode 100644 index 0000000000..ce4d4d78f4 --- /dev/null +++ b/app/queries/outstanding_balance.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Encapsulates the SQL statement that computes the balance of an order as a new column in the result +# set. This can then be reused chaining it with the ActiveRecord::Relation objects you pass in the +# constructor. +# +# Alternatively, you can get the SQL by calling #statement, which is suitable for more complex +# cases. +# +# See CompleteOrdersWithBalance or CustomersWithBalance as examples. +class OutstandingBalance + # All the states of a finished order but that shouldn't count towards the balance (the customer + # didn't get the order for whatever reason). Note it does not include complete + FINALIZED_NON_SUCCESSFUL_STATES = %w(canceled returned).freeze + + # The relation must be an ActiveRecord::Relation object with `spree_orders` in the SQL statement + # FROM for #statement to work. + def initialize(relation = nil) + @relation = relation + end + + def query + relation.select("#{statement} AS balance_value") + end + + # Arel doesn't support CASE statements until v7.1.0 so we'll have to wait with SQL literals + # a little longer. See https://github.com/rails/arel/pull/400 for details. + def statement + <<-SQL.strip_heredoc + CASE WHEN state IN #{non_fulfilled_states_group.to_sql} THEN payment_total + WHEN state IS NOT NULL THEN payment_total - total + ELSE 0 END + SQL + end + + private + + attr_reader :relation + + def non_fulfilled_states_group + states = FINALIZED_NON_SUCCESSFUL_STATES.map { |state| Arel::Nodes.build_quoted(state) } + Arel::Nodes::Grouping.new(states) + end +end diff --git a/app/serializers/api/order_serializer.rb b/app/serializers/api/order_serializer.rb index 427c222ea2..73532a1704 100644 --- a/app/serializers/api/order_serializer.rb +++ b/app/serializers/api/order_serializer.rb @@ -7,6 +7,14 @@ module Api has_many :payments, serializer: Api::PaymentSerializer + def outstanding_balance + if OpenFoodNetwork::FeatureToggle.enabled?(:customer_balance, object.user) + -object.balance_value + else + object.outstanding_balance + end + end + def payments object.payments.joins(:payment_method).completed end diff --git a/app/services/customers_with_balance.rb b/app/services/customers_with_balance.rb deleted file mode 100644 index 56a3c2d171..0000000000 --- a/app/services/customers_with_balance.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -class CustomersWithBalance - def initialize(enterprise) - @enterprise = enterprise - end - - def query - Customer.of(enterprise). - joins(left_join_complete_orders). - group("customers.id"). - select("customers.*"). - select(outstanding_balance) - end - - private - - attr_reader :enterprise - - # Arel doesn't support CASE statements until v7.1.0 so we'll have to wait with SQL literals - # a little longer. See https://github.com/rails/arel/pull/400 for details. - def outstanding_balance - <<-SQL.strip_heredoc - SUM( - CASE WHEN state IN #{non_fulfilled_states_group.to_sql} THEN payment_total - WHEN state IS NOT NULL THEN payment_total - total - ELSE 0 END - ) AS balance_value - SQL - end - - # The resulting orders are in states that belong after the checkout. Only these can be considered - # for a customer's balance. - def left_join_complete_orders - <<-SQL.strip_heredoc - LEFT JOIN spree_orders ON spree_orders.customer_id = customers.id - AND #{complete_orders.to_sql} - SQL - end - - def complete_orders - states_group = prior_to_completion_states.map { |state| Arel::Nodes.build_quoted(state) } - Arel::Nodes::NotIn.new(Spree::Order.arel_table[:state], states_group) - end - - def non_fulfilled_states_group - states_group = non_fulfilled_states.map { |state| Arel::Nodes.build_quoted(state) } - Arel::Nodes::Grouping.new(states_group) - end - - # All the states an order can be in before completing the checkout - def prior_to_completion_states - %w(cart address delivery payment) - end - - # All the states of a complete order but that shouldn't count towards the balance. Those that the - # customer won't enjoy. - def non_fulfilled_states - %w(canceled returned) - end -end diff --git a/app/views/spree/users/show.html.haml b/app/views/spree/users/show.html.haml index bddce94ce9..ec81685156 100644 --- a/app/views/spree/users/show.html.haml +++ b/app/views/spree/users/show.html.haml @@ -1,7 +1,8 @@ - content_for :injection_data do - = inject_orders - = inject_shops + = inject_json_array("orders", @orders.all, Api::OrderSerializer) + = inject_json_array("shops", @shops.all, Api::ShopForOrdersSerializer) = inject_saved_credit_cards + - if Stripe.publishable_key :javascript angular.module('Darkswarm').value("stripeObject", Stripe("#{Stripe.publishable_key}")) diff --git a/spec/controllers/spree/users_controller_spec.rb b/spec/controllers/spree/users_controller_spec.rb index 8c5c6b80b1..8f7b4257c7 100644 --- a/spec/controllers/spree/users_controller_spec.rb +++ b/spec/controllers/spree/users_controller_spec.rb @@ -40,6 +40,22 @@ describe Spree::UsersController, type: :controller do # Doesn't return uncompleted orders" do expect(orders).not_to include d1o3 end + + context 'when the customer_balance feature is enabled' do + let(:outstanding_balance) { double(:outstanding_balance) } + + before do + allow(OpenFoodNetwork::FeatureToggle) + .to receive(:enabled?).with(:customer_balance, controller.spree_current_user) { true } + end + + it 'calls OutstandingBalance' do + allow(OutstandingBalance).to receive(:new).and_return(outstanding_balance) + expect(outstanding_balance).to receive(:query) { Spree::Order.none } + + spree_get :show + end + end end describe "registered_email" do diff --git a/spec/queries/complete_orders_with_balance_spec.rb b/spec/queries/complete_orders_with_balance_spec.rb new file mode 100644 index 0000000000..9d46feb7da --- /dev/null +++ b/spec/queries/complete_orders_with_balance_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe CompleteOrdersWithBalance do + let(:complete_orders_with_balance) { described_class.new(user) } + + describe '#query' do + let(:user) { order.user } + let(:outstanding_balance) { instance_double(OutstandingBalance) } + + context 'when the user has complete orders' do + let(:order) do + create(:order, state: 'complete', total: 2.0, payment_total: 1.0, completed_at: 2.day.ago) + end + let!(:other_order) do + create( + :order, + user: user, + state: 'complete', + total: 2.0, + payment_total: 1.0, + completed_at: 1.days.ago + ) + end + + it 'calls OutstandingBalance#query' do + allow(OutstandingBalance).to receive(:new).and_return(outstanding_balance) + expect(outstanding_balance).to receive(:query) + + complete_orders_with_balance.query + end + + it 'returns complete orders including their balance' do + order = complete_orders_with_balance.query.first + expect(order[:balance_value]).to eq(-1.0) + end + + it 'sorts them by their completed_at with the most recent first' do + orders = complete_orders_with_balance.query + expect(orders.pluck(:id)).to eq([other_order.id, order.id]) + end + end + + context 'when the user has no complete orders' do + let(:order) { create(:order) } + + it 'calls OutstandingBalance' do + allow(OutstandingBalance).to receive(:new).and_return(outstanding_balance) + expect(outstanding_balance).to receive(:query) + + complete_orders_with_balance.query + end + + it 'returns an empty array' do + order = complete_orders_with_balance.query + expect(order).to be_empty + end + end + end +end diff --git a/spec/services/customers_with_balance_spec.rb b/spec/queries/customers_with_balance_spec.rb similarity index 88% rename from spec/services/customers_with_balance_spec.rb rename to spec/queries/customers_with_balance_spec.rb index fefd1d9090..479be6b410 100644 --- a/spec/services/customers_with_balance_spec.rb +++ b/spec/queries/customers_with_balance_spec.rb @@ -9,6 +9,14 @@ describe CustomersWithBalance do let(:customer) { create(:customer) } let(:total) { 200.00 } let(:order_total) { 100.00 } + let(:outstanding_balance) { instance_double(OutstandingBalance) } + + it 'calls CustomersWithBalance#statement' do + allow(OutstandingBalance).to receive(:new).and_return(outstanding_balance) + expect(outstanding_balance).to receive(:statement) + + customers_with_balance.query + end context 'when orders are in cart state' do before do @@ -61,9 +69,9 @@ describe CustomersWithBalance do context 'when no orders where paid' do before do order = create(:order, customer: customer, total: order_total, payment_total: 0) - order.update_attribute(:state, 'checkout') + order.update_attribute(:state, 'complete') order = create(:order, customer: customer, total: order_total, payment_total: 0) - order.update_attribute(:state, 'checkout') + order.update_attribute(:state, 'complete') end it 'returns the customer balance' do @@ -77,9 +85,9 @@ describe CustomersWithBalance do before do order = create(:order, customer: customer, total: order_total, payment_total: 0) - order.update_attribute(:state, 'checkout') + order.update_attribute(:state, 'complete') order = create(:order, customer: customer, total: order_total, payment_total: payment_total) - order.update_attribute(:state, 'checkout') + order.update_attribute(:state, 'complete') end it 'returns the customer balance' do @@ -94,7 +102,7 @@ describe CustomersWithBalance do before do order = create(:order, customer: customer, total: order_total, payment_total: 0) - order.update_attribute(:state, 'checkout') + order.update_attribute(:state, 'complete') create( :order, customer: customer, @@ -115,7 +123,7 @@ describe CustomersWithBalance do before do order = create(:order, customer: customer, total: order_total, payment_total: 0) - order.update_attribute(:state, 'checkout') + order.update_attribute(:state, 'complete') order = create(:order, customer: customer, total: order_total, payment_total: payment_total) order.update_attribute(:state, 'resumed') end @@ -131,7 +139,7 @@ describe CustomersWithBalance do before do order = create(:order, customer: customer, total: order_total, payment_total: 0) - order.update_attribute(:state, 'checkout') + order.update_attribute(:state, 'complete') order = create(:order, customer: customer, total: order_total, payment_total: payment_total) order.update_attribute(:state, 'payment') end @@ -147,7 +155,7 @@ describe CustomersWithBalance do before do order = create(:order, customer: customer, total: order_total, payment_total: 0) - order.update_attribute(:state, 'checkout') + order.update_attribute(:state, 'complete') order = create(:order, customer: customer, total: order_total, payment_total: payment_total) order.update_attribute(:state, 'awaiting_return') end @@ -164,7 +172,7 @@ describe CustomersWithBalance do before do order = create(:order, customer: customer, total: order_total, payment_total: 0) - order.update_attribute(:state, 'checkout') + order.update_attribute(:state, 'complete') order = create(:order, customer: customer, total: order_total, payment_total: payment_total) order.update_attribute(:state, 'returned') end diff --git a/spec/queries/outstanding_balance_spec.rb b/spec/queries/outstanding_balance_spec.rb new file mode 100644 index 0000000000..af880528f7 --- /dev/null +++ b/spec/queries/outstanding_balance_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe OutstandingBalance do + let(:outstanding_balance) { described_class.new(relation) } + + describe '#statement' do + let(:relation) { Spree::Order.none } + + it 'returns the CASE statement necessary to compute the order balance' do + normalized_sql_statement = normalize(outstanding_balance.statement) + + expect(normalized_sql_statement).to eq(normalize(<<-SQL)) + CASE WHEN state IN ('canceled', 'returned') THEN payment_total + WHEN state IS NOT NULL THEN payment_total - total + ELSE 0 END + SQL + end + + def normalize(sql) + sql.strip_heredoc.gsub("\n", '').squeeze(' ') + end + end + + describe '#query' do + let(:relation) { Spree::Order.all } + let(:total) { 200.00 } + let(:order_total) { 100.00 } + + context 'when orders are in cart state' do + before do + create(:order, total: order_total, payment_total: 0, state: 'cart') + create(:order, total: order_total, payment_total: 0, state: 'cart') + end + + it 'returns the order balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(-order_total) + end + end + + context 'when orders are in address state' do + before do + create(:order, total: order_total, payment_total: 0, state: 'address') + create(:order, total: order_total, payment_total: 50, state: 'address') + end + + it 'returns the order balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(-order_total) + end + end + + context 'when orders are in delivery state' do + before do + create(:order, total: order_total, payment_total: 0, state: 'delivery') + create(:order, total: order_total, payment_total: 50, state: 'delivery') + end + + it 'returns the order balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(-order_total) + end + end + + context 'when orders are in payment state' do + before do + create(:order, total: order_total, payment_total: 0, state: 'payment') + create(:order, total: order_total, payment_total: 50, state: 'payment') + end + + it 'returns the order balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(-order_total) + end + end + + context 'when no orders where paid' do + before do + order = create(:order, total: order_total, payment_total: 0) + order.update_attribute(:state, 'complete') + order = create(:order, total: order_total, payment_total: 0) + order.update_attribute(:state, 'complete') + end + + it 'returns the customer balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(-order_total) + end + end + + context 'when an order was paid' do + let(:payment_total) { order_total } + + before do + order = create(:order, total: order_total, payment_total: 0) + order.update_attribute(:state, 'complete') + order = create(:order, total: order_total, payment_total: payment_total) + order.update_attribute(:state, 'complete') + end + + it 'returns the customer balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(payment_total - 200.0) + end + end + + context 'when an order is canceled' do + let(:payment_total) { order_total } + let(:non_canceled_orders_total) { order_total } + + before do + create(:order, total: order_total, payment_total: order_total, state: 'canceled') + order = create(:order, total: order_total, payment_total: 0) + order.update_attribute(:state, 'complete') + end + + it 'returns the customer balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(payment_total) + end + end + + context 'when an order is resumed' do + let(:payment_total) { order_total } + + before do + order = create(:order, total: order_total, payment_total: 0) + order.update_attribute(:state, 'complete') + order = create(:order, total: order_total, payment_total: payment_total) + order.update_attribute(:state, 'resumed') + end + + it 'returns the customer balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(payment_total - 200.0) + end + end + + context 'when an order is in payment' do + let(:payment_total) { order_total } + + before do + order = create(:order, total: order_total, payment_total: 0) + order.update_attribute(:state, 'complete') + order = create(:order, total: order_total, payment_total: payment_total) + order.update_attribute(:state, 'payment') + end + + it 'returns the customer balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(payment_total - 200.0) + end + end + + context 'when an order is awaiting_return' do + let(:payment_total) { order_total } + + before do + order = create(:order, total: order_total, payment_total: 0) + order.update_attribute(:state, 'complete') + order = create(:order, total: order_total, payment_total: payment_total) + order.update_attribute(:state, 'awaiting_return') + end + + it 'returns the customer balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(payment_total - 200.0) + end + end + + context 'when an order is returned' do + let(:payment_total) { order_total } + let(:non_returned_orders_total) { order_total } + + before do + order = create(:order, total: order_total, payment_total: payment_total) + order.update_attribute(:state, 'returned') + order = create(:order, total: order_total, payment_total: 0) + order.update_attribute(:state, 'complete') + end + + it 'returns the customer balance' do + order = outstanding_balance.query.first + expect(order.balance_value).to eq(payment_total) + end + end + + context 'when there are no orders' do + it 'returns the order balance' do + orders = outstanding_balance.query + expect(orders).to be_empty + end + end + end +end diff --git a/spec/serializers/api/order_serializer_spec.rb b/spec/serializers/api/order_serializer_spec.rb index 04ef728d59..d635cf0ada 100644 --- a/spec/serializers/api/order_serializer_spec.rb +++ b/spec/serializers/api/order_serializer_spec.rb @@ -6,21 +6,55 @@ describe Api::OrderSerializer do let(:serializer) { Api::OrderSerializer.new order } let(:order) { create(:completed_order_with_totals) } - let!(:completed_payment) { create(:payment, order: order, state: 'completed', amount: order.total - 1) } - let!(:payment) { create(:payment, order: order, state: 'checkout', amount: 123.45) } + describe '#serializable_hash' do + let!(:completed_payment) do + create(:payment, order: order, state: 'completed', amount: order.total - 1) + end + let!(:payment) { create(:payment, order: order, state: 'checkout', amount: 123.45) } - it "serializes an order" do - expect(serializer.to_json).to match order.number.to_s + it "serializes an order" do + expect(serializer.serializable_hash[:number]).to eq(order.number) + end + + it "convert the state attributes to translatable keys" do + hash = serializer.serializable_hash + + expect(hash[:state]).to eq("complete") + expect(hash[:payment_state]).to eq("balance_due") + end + + it "only serializes completed payments" do + hash = serializer.serializable_hash + + expect(hash[:payments].first[:amount]).to eq(completed_payment.amount) + end end - it "convert the state attributes to translatable keys" do - # byebug if serializer.to_json =~ /balance_due/ - expect(serializer.to_json).to match "complete" - expect(serializer.to_json).to match "balance_due" - end + describe '#outstanding_balance' do + context 'when the customer_balance feature is enabled' do + before do + allow(OpenFoodNetwork::FeatureToggle) + .to receive(:enabled?).with(:customer_balance, order.user) { true } - it "only serializes completed payments" do - expect(serializer.to_json).to match completed_payment.amount.to_s - expect(serializer.to_json).to_not match payment.amount.to_s + allow(order).to receive(:balance_value).and_return(-1.23) + end + + it "returns the object's balance_value from the users perspective" do + expect(serializer.serializable_hash[:outstanding_balance]).to eq(1.23) + end + end + + context 'when the customer_balance is not enabled' do + before do + allow(OpenFoodNetwork::FeatureToggle) + .to receive(:enabled?).with(:customer_balance, order.user) { false } + + allow(order).to receive(:outstanding_balance).and_return(123.0) + end + + it 'calls #outstanding_balance on the object' do + expect(serializer.serializable_hash[:outstanding_balance]).to eq(123.0) + end + end end end