Merge pull request #6643 from coopdevs/customer-balance-frontoffice

Customer balance frontoffice
This commit is contained in:
Pau Pérez Fabregat
2021-01-27 19:55:56 +01:00
committed by GitHub
13 changed files with 471 additions and 86 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}"))

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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