Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[vouchers] error moving between summary and cart pages #11117

Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
04bbea5
Move voucher processing out of checkout controller
Matt-Yorkley Jun 1, 2023
60c0c54
Update create_adjustment to create with an amount of 0
rioug Jun 26, 2023
87790b2
Fix VoucherAdjustmentsService.calculate so we can call it mutiple time
rioug Jun 26, 2023
366cca7
Prevent voucher adjustment from bein updated when update is called
rioug Jun 26, 2023
ca7dcb8
Apply voucher after transitionning to the confirmation step
rioug Jun 26, 2023
edabc56
Add voucher calculation after updating an order
rioug Jun 26, 2023
a8062e9
Add a scenario to make sure voucher adjustment a recalculated
rioug Jun 26, 2023
789ce39
Fixing Rubocop errors
rioug Jun 26, 2023
751056b
As per review comment, spell out voucher code when entering a voucher
rioug Jun 30, 2023
4127119
Per review, Makes quantity change more explicit
rioug Jun 30, 2023
3d9542f
Per review, rename amount to adjustment_amount
rioug Jun 30, 2023
5a59396
Remove call to VoucherAdjustmentService when creating a voucher
rioug Jun 30, 2023
0e0850e
Add specs to cover re calculation
rioug Jun 30, 2023
a584f9a
Refactor VoucherAdjustmentsService
rioug Jul 18, 2023
a5b2bc6
Per review, Refactor VoucherAdjustmentsService
rioug Jul 18, 2023
895f534
Remove unnecessary extra page load
dacook Jul 27, 2023
be1a727
Pending spec: vouchers should not require payment
dacook Jul 27, 2023
ef62fb8
Check if record actually saved
dacook Jul 27, 2023
f35001d
Update voucher adjustment and order total when adding a voucher
rioug Jul 28, 2023
33ef8de
Update order total after removing a voucher
rioug Jul 28, 2023
089d2b9
Clear any existing payment and payment fees when adding a voucher
rioug Jul 31, 2023
2857930
Fix voucher adjustment request spec
rioug Jul 31, 2023
85adb9f
Fix rubocop warning
rioug Jul 31, 2023
6ed35f4
Per review, delete only incomplete payments
rioug Aug 4, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/controllers/spree/orders_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ def update
if @order.contents.update_cart(order_params)
@order.recreate_all_fees! # Enterprise fees on line items and on the order itself

# Re apply the voucher
VoucherAdjustmentsService.new(@order).update
@order.update_totals_and_states

if @order.complete?
@order.update_payment_fees!
@order.create_tax_charge!
Expand Down
19 changes: 15 additions & 4 deletions app/controllers/voucher_adjustments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ class VoucherAdjustmentsController < BaseController

def create
if add_voucher
VoucherAdjustmentsService.calculate(@order)
@order.update_totals_and_states

update_payment_section
elsif @order.errors.present?
render_error
Expand All @@ -17,6 +14,9 @@ def create
def destroy
@order.voucher_adjustments.find_by(id: params[:id])&.destroy

# Update order to make sure we display the appropriate payment method
@order.update_totals_and_states

update_payment_section
end

Expand All @@ -41,12 +41,17 @@ def add_voucher

adjustment = voucher.create_adjustment(voucher.code, @order)

unless adjustment.valid?
unless adjustment.persisted?
@order.errors.add(:voucher_code, I18n.t('split_checkout.errors.add_voucher_error'))
adjustment.errors.each { |error| @order.errors.import(error) }
return false
end

clear_payments

VoucherAdjustmentsService.new(@order).update
@order.update_totals_and_states

true
end

Expand Down Expand Up @@ -74,4 +79,10 @@ def render_error
def voucher_params
params.require(:order).permit(:voucher_code)
end

# Clear payments and payment fees, to not affect voucher adjustment calculation
def clear_payments
@order.all_adjustments.payment_fee.destroy_all
@order.payments.clear
Copy link
Member

Choose a reason for hiding this comment

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

That's a good question: do we ever have complete payments in this code path?

It looks like they would influence the calculation if we kept them. So that would need special consideration.

Looking into the state machine, we can restart the checkout any time until the order is finalised. At the time of finalising the order, the payment has been taken already. So I think, it can happen that we take a payment and then face a stock conflict and have to restart the checkout.

@rioug, do you agree with my theory? It would mean that you need to calculate totals for voucher amounts considering payments instead of clearing them out, I think.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

My understanding is that payment are processed once we confirm the order, see below:

def confirm_order
return unless summary_step? && @order.confirmation?
return unless validate_summary! && @order.errors.empty?
@order.customer.touch :terms_and_conditions_accepted_at
return true if redirect_to_payment_gateway
@order.process_payments!
@order.confirm!
order_completion_reset @order
end

and once the order is confirmed then the state machine will finalise the order. So If I understand correctly we shouldn't have any completed payments when navigating between checkout steps.
Also if the checkout is restarted, the the user will be going through the payment step again and will be able to (re) choose the payment method.

I added this to fix a specific scenario where the user goes through the checkout till the confirmation step, then goes back to the payment step and add a voucher covering the order amount. In this case we need to remove any existing payment/payment fee as now there is no payment required.

Would we have an already completed payment if we face a stock conflict and have to restart the checkout ? You would think that we would check the stock situation before taking a payment.
From what I can see, we would be redirected to cart if there is a stock issue when navigation checkout steps:

redirect_to_cart_path && return unless valid_order_line_items?

def valid_order_line_items?
@order.insufficient_stock_lines.empty? &&
OrderCycleDistributedVariants.new(@order.order_cycle, @order.distributor).
distributes_order_variants?(@order)
end

I couldn't see any other checks so maybe I am missing something here.

Anyway, I'll apply @dacook suggestion to be on the safer side.

Copy link
Member

Choose a reason for hiding this comment

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

Would we have an already completed payment if we face a stock conflict and have to restart the checkout ? You would think that we would check the stock situation before taking a payment.
From what I can see, we would be redirected to cart if there is a stock issue when navigation checkout steps

Of course we check the stock before we take a payment but there's always room for race conditions. We often process several checkouts in the same second and it can happen that two people try to checkout the same last item at the same time. It's really hard to predict what exactly happens because it depends on the timing and where the error is raised.

Actually, the CurrentOrderLocker should prevent this. So maybe it's not a problem any more. I'm just scared that this race condition will come back to haunt us... 😨

end
end
6 changes: 6 additions & 0 deletions app/models/spree/order/checkout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ def self.define_state_machine!
order.create_tax_charge!
order.update_totals_and_states
end

after_transition to: :confirmation do |order|
VoucherAdjustmentsService.new(order).update
order.update_totals_and_states
end

after_transition to: :complete, do: :finalize!
after_transition to: :resumed, do: :after_resume
after_transition to: :canceled, do: :after_cancel
Expand Down
7 changes: 3 additions & 4 deletions app/models/voucher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,11 @@ def display_value
# but vouchers have complicated calculation so we can't easily use Spree::Calculator. We keep
# the same method to stay as consistent as possible.
#
# Creates a new voucher adjustment for the given order
# Creates a new voucher adjustment for the given order with an amount of 0
# The amount will be calculated via VoucherAdjustmentsService#update
def create_adjustment(label, order)
amount = compute_amount(order)

adjustment_attributes = {
amount: amount,
amount: 0,
originator: self,
order: order,
label: label,
Expand Down
78 changes: 44 additions & 34 deletions app/services/voucher_adjustments_service.rb
Original file line number Diff line number Diff line change
@@ -1,68 +1,80 @@
# frozen_string_literal: true

class VoucherAdjustmentsService
def self.calculate(order)
return if order.nil?
def initialize(order)
@order = order
end

def update
return if @order.nil?

# Find open Voucher Adjustment
return if order.voucher_adjustments.empty?
return if @order.voucher_adjustments.empty?

# We only support one voucher per order right now, we could just loop on voucher_adjustments
adjustment = order.voucher_adjustments.first
adjustment = @order.voucher_adjustments.first

# Recalculate value
amount = adjustment.originator.compute_amount(order)
# Calculate value
amount = adjustment.originator.compute_amount(@order)

# It is quite possible to have an order with both tax included in and tax excluded from price.
# We should be able to caculate the relevant amount apply the current calculation.
#
# For now we just assume it is either all tax included in price or all tax excluded from price.
if order.additional_tax_total.positive?
handle_tax_excluded_from_price(order, amount)
elsif order.included_tax_total.positive?
handle_tax_included_in_price(order, amount)
if @order.additional_tax_total.positive?
handle_tax_excluded_from_price(amount)
elsif @order.included_tax_total.positive?
handle_tax_included_in_price(amount)
else
adjustment.amount = amount
adjustment.save
rioug marked this conversation as resolved.
Show resolved Hide resolved
end

# Move to closed state
adjustment.close
end

def self.handle_tax_excluded_from_price(order, amount)
voucher_rate = amount / order.total
private

def handle_tax_excluded_from_price(amount)
voucher_rate = amount / @order.pre_discount_total

adjustment = @order.voucher_adjustments.first

# Adding the voucher tax part
tax_amount = voucher_rate * order.additional_tax_total
tax_amount = voucher_rate * @order.additional_tax_total

update_tax_adjustment_for(adjustment, tax_amount)

# Update the adjustment amount
adjustment_amount = voucher_rate * (@order.pre_discount_total - @order.additional_tax_total)

adjustment.update_columns(
amount: adjustment_amount,
updated_at: Time.zone.now
)
end

adjustment = order.voucher_adjustments.first
def update_tax_adjustment_for(adjustment, tax_amount)
adjustment_attributes = {
amount: tax_amount,
originator: adjustment.originator,
order: order,
order: @order,
label: "Tax #{adjustment.label}",
mandatory: false,
state: 'closed',
state: 'open',
tax_category: nil,
included_tax: 0
}
order.adjustments.create(adjustment_attributes)

# Update the adjustment amount
amount = voucher_rate * (order.total - order.additional_tax_total)

adjustment.update_columns(
amount: amount,
updated_at: Time.zone.now
)
# Update the amount if tax adjustment already exist, create if not
tax_adjustment = @order.adjustments.find_or_initialize_by(adjustment_attributes)
tax_adjustment.amount = tax_amount
tax_adjustment.save
end

def self.handle_tax_included_in_price(order, amount)
voucher_rate = amount / order.total
included_tax = voucher_rate * order.included_tax_total
def handle_tax_included_in_price(amount)
voucher_rate = amount / @order.pre_discount_total
included_tax = voucher_rate * @order.included_tax_total

# Update Adjustment
adjustment = order.voucher_adjustments.first
adjustment = @order.voucher_adjustments.first

return unless amount != adjustment.amount || included_tax != 0

Expand All @@ -72,6 +84,4 @@ def self.handle_tax_included_in_price(order, amount)
updated_at: Time.zone.now
)
end

private_class_method :handle_tax_included_in_price, :handle_tax_excluded_from_price
end
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ def update_payment_state
end

def update_all_adjustments
order.all_adjustments.reload.each(&:update_adjustment!)
# Voucher are modelled as a Spree::Adjustment but they don't behave like all the other
# adjustments, so we don't want voucher adjustment to be updated here.
# Calculation are handled by VoucherAdjustmentsService.calculate
order.all_adjustments.non_voucher.reload.each(&:update_adjustment!)
end

def before_save_hook
Expand Down
4 changes: 2 additions & 2 deletions spec/models/voucher_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@
let(:voucher) { create(:voucher, code: 'new_code', enterprise: enterprise, amount: 25) }
let(:order) { create(:order_with_line_items, line_items_count: 3, distributor: enterprise) }

it 'includes the full voucher amount' do
expect(adjustment.amount.to_f).to eq(-25.0)
it 'includes an amount of 0' do
expect(adjustment.amount.to_f).to eq(0.0)
end

it 'has no included_tax' do
Expand Down
29 changes: 24 additions & 5 deletions spec/requests/voucher_adjustments_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
let(:distributor) { create(:distributor_enterprise, with_payment_and_shipping: true) }
let(:order_cycle) { create(:order_cycle, distributors: [distributor]) }
let(:exchange) { order_cycle.exchanges.outgoing.first }
let(:order) do
let!(:order) do
create(
:order_with_line_items,
line_items_count: 1,
Expand Down Expand Up @@ -61,9 +61,28 @@
post "/voucher_adjustments", params: params

expect(response).to be_unprocessable
expect(flash[:error]).to match(
"There was an error while adding the voucher and Label can't be blank"
)
expect(flash[:error]).to match("Voucher code There was an error while adding the voucher")
end
end

context "when the order has a payment and payment feed" do
let(:payment_method) { create(:payment_method, calculator: calculator) }
let(:calculator) { Calculator::FlatPercentItemTotal.new(preferred_flat_percent: 10) }

before do
create(:payment, order: order, payment_method: payment_method, amount: order.total)
end

it "removes existing payments" do
expect do
post "/voucher_adjustments", params: params
end.to change { order.reload.payments.count }.from(1).to(0)
end

it "removes existing payment fees" do
expect do
post "/voucher_adjustments", params: params
end.to change { order.reload.all_adjustments.payment_fee.count }.from(1).to(0)
end
end
end
Expand All @@ -83,7 +102,7 @@
expect(response).to be_successful
end

context "when adjustment doesn't exits" do
context "when adjustment doesn't exist" do
it "does nothing" do
delete "/voucher_adjustments/-1"

Expand Down
Loading