mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-01 21:47:16 +00:00
Merge pull request #10587 from rioug/10432-vouchers-bare-minimum-checkout
10432 vouchers bare minimum checkout
This commit is contained in:
@@ -30,6 +30,8 @@ module CheckoutCallbacks
|
||||
@order.manual_shipping_selection = true
|
||||
@order.checkout_processing = true
|
||||
|
||||
@voucher_adjustment = @order.voucher_adjustments.first
|
||||
|
||||
redirect_to(main_app.shop_path) && return if redirect_to_shop?
|
||||
redirect_to_cart_path && return unless valid_order_line_items?
|
||||
end
|
||||
|
||||
@@ -30,6 +30,8 @@ class SplitCheckoutController < ::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
return process_voucher if params[:apply_voucher].present?
|
||||
|
||||
if confirm_order || update_order
|
||||
return if performed?
|
||||
|
||||
@@ -59,6 +61,27 @@ class SplitCheckoutController < ::BaseController
|
||||
replace("#flashes", partial("shared/flashes", locals: { flashes: flash }))
|
||||
end
|
||||
|
||||
def render_voucher_section_or_redirect
|
||||
respond_to do |format|
|
||||
format.cable_ready { render_voucher_section }
|
||||
format.html { redirect_to checkout_step_path(:payment) }
|
||||
end
|
||||
end
|
||||
|
||||
# Using the power of cable_car we replace only the #voucher_section instead of reloading the page
|
||||
def render_voucher_section
|
||||
render(
|
||||
status: :ok,
|
||||
cable_ready: cable_car.replace(
|
||||
"#voucher-section",
|
||||
partial(
|
||||
"split_checkout/voucher_section",
|
||||
locals: { order: @order, voucher_adjustment: @order.voucher_adjustments.first }
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def order_error_messages
|
||||
# Remove ship_address.* errors if no shipping method is not selected
|
||||
remove_ship_address_errors if no_ship_address_needed?
|
||||
@@ -179,10 +202,47 @@ class SplitCheckoutController < ::BaseController
|
||||
selected_shipping_method.first.require_ship_address == false
|
||||
end
|
||||
|
||||
def process_voucher
|
||||
if add_voucher
|
||||
render_voucher_section_or_redirect
|
||||
elsif @order.errors.present?
|
||||
render_error
|
||||
end
|
||||
end
|
||||
|
||||
def add_voucher
|
||||
if params.dig(:order, :voucher_code).blank?
|
||||
@order.errors.add(:voucher, I18n.t('split_checkout.errors.voucher_not_found'))
|
||||
return false
|
||||
end
|
||||
|
||||
# Fetch Voucher
|
||||
voucher = Voucher.find_by(code: params[:order][:voucher_code], enterprise: @order.distributor)
|
||||
|
||||
if voucher.nil?
|
||||
@order.errors.add(:voucher, I18n.t('split_checkout.errors.voucher_not_found'))
|
||||
return false
|
||||
end
|
||||
|
||||
adjustment = voucher.create_adjustment(voucher.code, @order)
|
||||
|
||||
if !adjustment.valid?
|
||||
@order.errors.add(:voucher, I18n.t('split_checkout.errors.add_voucher_error'))
|
||||
adjustment.errors.each { |error| @order.errors.import(error) }
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def summary_step?
|
||||
params[:step] == "summary"
|
||||
end
|
||||
|
||||
def payment_step?
|
||||
params[:step] == "payment"
|
||||
end
|
||||
|
||||
def advance_order_state
|
||||
return if @order.complete?
|
||||
|
||||
@@ -252,5 +312,14 @@ class SplitCheckoutController < ::BaseController
|
||||
def recalculate_tax
|
||||
@order.create_tax_charge!
|
||||
@order.update_order!
|
||||
|
||||
apply_voucher if @order.voucher_adjustments.present?
|
||||
end
|
||||
|
||||
def apply_voucher
|
||||
VoucherAdjustmentsService.calculate(@order)
|
||||
|
||||
# update order to take into account the voucher we applied
|
||||
@order.update_order!
|
||||
end
|
||||
end
|
||||
|
||||
32
app/controllers/voucher_adjustments_controller.rb
Normal file
32
app/controllers/voucher_adjustments_controller.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class VoucherAdjustmentsController < BaseController
|
||||
include CablecarResponses
|
||||
|
||||
def destroy
|
||||
@order = current_order
|
||||
|
||||
@order.voucher_adjustments.find_by(id: params[:id])&.destroy
|
||||
|
||||
respond_to do |format|
|
||||
format.cable_ready { render_voucher_section }
|
||||
format.html { redirect_to checkout_step_path(:payment) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Using the power of cable_car we replace only the #voucher_section instead of reloading the page
|
||||
def render_voucher_section
|
||||
render(
|
||||
status: :ok,
|
||||
cable_ready: cable_car.replace(
|
||||
"#voucher-section",
|
||||
partial(
|
||||
"split_checkout/voucher_section",
|
||||
locals: { order: @order, voucher_adjustment: @order.voucher_adjustments.first }
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -42,9 +42,6 @@ module Spree
|
||||
belongs_to :order, class_name: "Spree::Order"
|
||||
belongs_to :tax_category, class_name: 'Spree::TaxCategory'
|
||||
|
||||
belongs_to :tax_rate, -> { where spree_adjustments: { originator_type: 'Spree::TaxRate' } },
|
||||
foreign_key: 'originator_id'
|
||||
|
||||
validates :label, presence: true
|
||||
validates :amount, numericality: true
|
||||
|
||||
|
||||
@@ -62,6 +62,13 @@ module Spree
|
||||
has_many :line_item_adjustments, through: :line_items, source: :adjustments
|
||||
has_many :shipment_adjustments, through: :shipments, source: :adjustments
|
||||
has_many :all_adjustments, class_name: 'Spree::Adjustment', dependent: :destroy
|
||||
has_many :voucher_adjustments,
|
||||
-> {
|
||||
where(originator_type: 'Voucher')
|
||||
.order("#{Spree::Adjustment.table_name}.created_at ASC")
|
||||
},
|
||||
class_name: 'Spree::Adjustment',
|
||||
dependent: :destroy
|
||||
|
||||
belongs_to :order_cycle
|
||||
belongs_to :distributor, class_name: 'Enterprise'
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# frozen_string_literal: false
|
||||
|
||||
class Voucher < ApplicationRecord
|
||||
acts_as_paranoid
|
||||
|
||||
belongs_to :enterprise
|
||||
|
||||
has_many :adjustments,
|
||||
as: :originator,
|
||||
class_name: 'Spree::Adjustment',
|
||||
dependent: :nullify
|
||||
|
||||
validates :code, presence: true, uniqueness: { scope: :enterprise_id }
|
||||
|
||||
def value
|
||||
@@ -12,4 +19,31 @@ class Voucher < ApplicationRecord
|
||||
def display_value
|
||||
Spree::Money.new(value)
|
||||
end
|
||||
|
||||
# Ideally we would use `include CalculatedAdjustments` to be consistent with other adjustments,
|
||||
# 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
|
||||
def create_adjustment(label, order)
|
||||
amount = compute_amount(order)
|
||||
|
||||
adjustment_attributes = {
|
||||
amount: amount,
|
||||
originator: self,
|
||||
order: order,
|
||||
label: label,
|
||||
mandatory: false,
|
||||
state: "open",
|
||||
tax_category: nil
|
||||
}
|
||||
|
||||
order.adjustments.create(adjustment_attributes)
|
||||
end
|
||||
|
||||
# We limit adjustment to the maximum amount needed to cover the order, ie if the voucher
|
||||
# covers more than the order.total we only need to create an adjustment covering the order.total
|
||||
def compute_amount(order)
|
||||
-value.clamp(0, order.total)
|
||||
end
|
||||
end
|
||||
|
||||
75
app/services/voucher_adjustments_service.rb
Normal file
75
app/services/voucher_adjustments_service.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class VoucherAdjustmentsService
|
||||
def self.calculate(order)
|
||||
return if order.nil?
|
||||
|
||||
# Find open Voucher Adjustment
|
||||
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
|
||||
|
||||
# Recalculate 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)
|
||||
else
|
||||
handle_tax_included_in_price(order, amount)
|
||||
end
|
||||
|
||||
# Move to closed state
|
||||
adjustment.close
|
||||
end
|
||||
|
||||
def self.handle_tax_excluded_from_price(order, amount)
|
||||
voucher_rate = amount / order.total
|
||||
|
||||
# Adding the voucher tax part
|
||||
tax_amount = voucher_rate * order.additional_tax_total
|
||||
|
||||
adjustment = order.voucher_adjustments.first
|
||||
adjustment_attributes = {
|
||||
amount: tax_amount,
|
||||
originator: adjustment.originator,
|
||||
order: order,
|
||||
label: "Tax #{adjustment.label}",
|
||||
mandatory: false,
|
||||
state: 'closed',
|
||||
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
|
||||
)
|
||||
end
|
||||
|
||||
def self.handle_tax_included_in_price(order, amount)
|
||||
voucher_rate = amount / order.total
|
||||
included_tax = voucher_rate * order.included_tax_total
|
||||
|
||||
# Update Adjustment
|
||||
adjustment = order.voucher_adjustments.first
|
||||
|
||||
return unless amount != adjustment.amount || included_tax != 0
|
||||
|
||||
adjustment.update_columns(
|
||||
amount: amount,
|
||||
included_tax: included_tax,
|
||||
updated_at: Time.zone.now
|
||||
)
|
||||
end
|
||||
|
||||
private_class_method :handle_tax_included_in_price, :handle_tax_excluded_from_price
|
||||
end
|
||||
@@ -1,5 +1,7 @@
|
||||
.medium-6
|
||||
%div.checkout-substep{"data-controller": "paymentmethod"}
|
||||
= render partial: "split_checkout/voucher_section", formats: [:cable_ready], locals: { order: @order, voucher_adjustment: @voucher_adjustment }
|
||||
|
||||
%div.checkout-title
|
||||
= t("split_checkout.step2.payment_method.title")
|
||||
|
||||
|
||||
@@ -85,8 +85,12 @@
|
||||
|
||||
- checkout_adjustments_for(@order, exclude: [:line_item]).reverse_each do |adjustment|
|
||||
.summary-right-line
|
||||
.summary-right-line-label= adjustment.label
|
||||
.summary-right-line-value= adjustment.display_amount.to_html
|
||||
-if adjustment.originator_type == 'Voucher'
|
||||
.summary-right-line-label.voucher= adjustment.label
|
||||
.summary-right-line-value.voucher= adjustment.display_amount.to_html
|
||||
-else
|
||||
.summary-right-line-label= adjustment.label
|
||||
.summary-right-line-value= adjustment.display_amount.to_html
|
||||
|
||||
- if @order.total_tax > 0
|
||||
.summary-right-line
|
||||
|
||||
19
app/views/split_checkout/_voucher_section.cable_ready.haml
Normal file
19
app/views/split_checkout/_voucher_section.cable_ready.haml
Normal file
@@ -0,0 +1,19 @@
|
||||
%div#voucher-section
|
||||
- if order.distributor.vouchers.present?
|
||||
.checkout-title
|
||||
= t("split_checkout.step2.voucher.apply_voucher")
|
||||
.checkout-input
|
||||
.two-columns-inputs.voucher{"data-controller": "toggle-button-disabled"}
|
||||
- if voucher_adjustment.present?
|
||||
%span.button.voucher-added
|
||||
%i.ofn-i_051-check-big
|
||||
= t("split_checkout.step2.voucher.voucher", voucher_amount: voucher_adjustment.originator.display_value)
|
||||
= link_to t("split_checkout.step2.voucher.remove_code"), voucher_adjustment_path(id: voucher_adjustment.id), method: "delete", data: { confirm: t("split_checkout.step2.voucher.confirm_delete") }
|
||||
- # This might not be true, ie payment method including a fee which wouldn't be covered by voucher or tax implication raising total to be bigger than the voucher amount ?
|
||||
- if voucher_adjustment.originator.value > order.total
|
||||
.checkout-input
|
||||
%span.formError.standalone
|
||||
= t("split_checkout.step2.voucher.warning_forfeit_remaining_amount")
|
||||
- else
|
||||
= text_field_tag "[order][voucher_code]", params.dig(:order, :voucher_code), data: { action: "input->toggle-button-disabled#inputIsChanged", }, placeholder: t("split_checkout.step2.voucher.placeholder") , class: "voucher"
|
||||
= submit_tag t("split_checkout.step2.voucher.apply"), name: "apply_voucher", disabled: true, class: "button cancel voucher", "data-disable-with": false, data: { "toggle-button-disabled-target": "button" }
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller } from "stimulus";
|
||||
|
||||
// Since Rails 7 it adds "data-disabled-with" property to submit, you'll need to add
|
||||
// 'data-disable-with="false' for this to function as expected, ie:
|
||||
//
|
||||
// <input id="test-submit" type="submit" data-disable-with="false" data-toggle-button-disabled-target="button"/>
|
||||
//
|
||||
export default class extends Controller {
|
||||
static targets = ["button"];
|
||||
|
||||
connect() {
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
inputIsChanged(e) {
|
||||
if (e.target.value !== "") {
|
||||
this.buttonTarget.disabled = false;
|
||||
} else {
|
||||
this.buttonTarget.disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,6 +332,10 @@
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
border-right: 1px solid #DDD;
|
||||
|
||||
.voucher {
|
||||
color: $teal-500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,6 +408,48 @@
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
|
||||
&.voucher {
|
||||
justify-content: normal;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.button {
|
||||
&.cancel {
|
||||
width: 30%;
|
||||
border-radius: 0.5em;
|
||||
padding:0;
|
||||
height: 2.5em;
|
||||
background-color: $teal-400
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voucher-added {
|
||||
padding: 10px;
|
||||
background-color: $teal-300;
|
||||
color: $teal-500;
|
||||
margin: 0;
|
||||
cursor: default;
|
||||
|
||||
i.ofn-i_051-check-big:before {
|
||||
background-color: $teal-500;
|
||||
color: $teal-300;
|
||||
border-radius: 50%;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
i.ofn-i_051-check-big {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
> .checkout-input {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -418,6 +464,11 @@
|
||||
&:last-child > .checkout-input {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
&.voucher {
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2055,6 +2055,14 @@ en:
|
||||
explaination: You can review and confirm your order in the next step which includes the final costs.
|
||||
submit: Next - Order summary
|
||||
cancel: Back to Your details
|
||||
voucher:
|
||||
voucher: "%{voucher_amount} Voucher"
|
||||
apply_voucher: Apply voucher
|
||||
apply: Apply
|
||||
placeholder: Enter voucher code
|
||||
remove_code: Remove code
|
||||
confirm_delete: Are you sure you want to remove the voucher?
|
||||
warning_forfeit_remaining_amount: Your voucher value is more than your order. By using this voucher you are forfeiting the remaining value.
|
||||
step3:
|
||||
delivery_details:
|
||||
title: Delivery details
|
||||
@@ -2087,6 +2095,8 @@ en:
|
||||
select_a_shipping_method: Select a shipping method
|
||||
select_a_payment_method: Select a payment method
|
||||
no_shipping_methods_available: Checkout is not possible due to absence of shipping options. Please contact the shop owner.
|
||||
voucher_not_found: Not found
|
||||
add_voucher_error: There was an error while adding the voucher
|
||||
order_paid: PAID
|
||||
order_not_paid: NOT PAID
|
||||
order_total: Total order
|
||||
|
||||
@@ -74,7 +74,7 @@ Openfoodnetwork::Application.routes.draw do
|
||||
match "/checkout", via: :get, controller: "payment_gateways/stripe", action: "confirm"
|
||||
match "/orders/:order_number", via: :get, controller: "payment_gateways/stripe", action: "authorize"
|
||||
end
|
||||
|
||||
|
||||
namespace :payment_gateways do
|
||||
get "/paypal", to: "paypal#express", as: :paypal_express
|
||||
get "/paypal/confirm", to: "paypal#confirm", as: :confirm_paypal
|
||||
@@ -121,6 +121,8 @@ Openfoodnetwork::Application.routes.draw do
|
||||
get '/:id/shop', to: 'enterprises#shop', as: 'enterprise_shop'
|
||||
get "/enterprises/:permalink", to: redirect("/") # Legacy enterprise URL
|
||||
|
||||
resources :voucher_adjustments, only: [:destroy]
|
||||
|
||||
get 'sitemap.xml', to: 'sitemap#index', defaults: { format: 'xml' }
|
||||
|
||||
# Mount Spree's routes
|
||||
|
||||
6
db/migrate/20230315040748_add_deleted_at_to_vouchers.rb
Normal file
6
db/migrate/20230315040748_add_deleted_at_to_vouchers.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class AddDeletedAtToVouchers < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :vouchers, :deleted_at, :datetime
|
||||
add_index :vouchers, :deleted_at
|
||||
end
|
||||
end
|
||||
@@ -1192,11 +1192,13 @@ ActiveRecord::Schema[7.0].define(version: 2023_04_24_141213) do
|
||||
|
||||
create_table "vouchers", force: :cascade do |t|
|
||||
t.string "code", limit: 255, null: false
|
||||
t.datetime "expiry_date"
|
||||
t.datetime "expiry_date", precision: nil
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "enterprise_id"
|
||||
t.datetime "deleted_at", precision: nil
|
||||
t.index ["code", "enterprise_id"], name: "index_vouchers_on_code_and_enterprise_id", unique: true
|
||||
t.index ["deleted_at"], name: "index_vouchers_on_deleted_at"
|
||||
t.index ["enterprise_id"], name: "index_vouchers_on_enterprise_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -170,6 +170,8 @@ RSpec.configure do |config|
|
||||
config.include OpenFoodNetwork::ApiHelper, type: :controller
|
||||
config.include OpenFoodNetwork::ControllerHelper, type: :controller
|
||||
|
||||
config.include Devise::Test::IntegrationHelpers, type: :request
|
||||
|
||||
config.include Features::DatepickerHelper, type: :system
|
||||
config.include DownloadsHelper, type: :system
|
||||
end
|
||||
|
||||
@@ -235,6 +235,73 @@ describe SplitCheckoutController, type: :controller do
|
||||
expect(order.payments.first.source.id).to eq saved_card.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "Vouchers" do
|
||||
let(:voucher) { Voucher.create(code: 'some_code', enterprise: distributor) }
|
||||
|
||||
describe "adding a voucher" do
|
||||
let(:checkout_params) do
|
||||
{
|
||||
apply_voucher: "true",
|
||||
order: {
|
||||
voucher_code: voucher.code
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it "adds a voucher to the order" do
|
||||
# Set the headers to simulate a cable_ready request
|
||||
request.headers["accept"] = "text/vnd.cable-ready.json"
|
||||
|
||||
put :update, params: params
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(order.reload.voucher_adjustments.length).to eq(1)
|
||||
end
|
||||
|
||||
context "when voucher doesn't exist" do
|
||||
let(:checkout_params) do
|
||||
{
|
||||
apply_voucher: "true",
|
||||
order: {
|
||||
voucher_code: "non_voucher"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it "returns 422 and an error message" do
|
||||
put :update, params: params
|
||||
|
||||
expect(response.status).to eq 422
|
||||
expect(flash[:error]).to match "Voucher Not found"
|
||||
end
|
||||
end
|
||||
|
||||
context "when adding fails" do
|
||||
it "returns 422 and an error message" do
|
||||
# Create a non valid adjustment
|
||||
adjustment = build(:adjustment, label: nil)
|
||||
allow(voucher).to receive(:create_adjustment).and_return(adjustment)
|
||||
allow(Voucher).to receive(:find_by).and_return(voucher)
|
||||
|
||||
put :update, params: params
|
||||
|
||||
expect(response.status).to eq 422
|
||||
expect(flash[:error]).to match(
|
||||
"There was an error while adding the voucher and Label can't be blank"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "with an html request" do
|
||||
it "redirects to the payment step" do
|
||||
put :update, params: params
|
||||
|
||||
expect(response).to redirect_to(checkout_step_path(:payment))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "summary step" do
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { Application } from "stimulus"
|
||||
import toggle_button_disabled_controller from "../../../app/webpacker/controllers/toggle_button_disabled_controller"
|
||||
|
||||
describe("ButtonEnableToggleController", () => {
|
||||
beforeAll(() => {
|
||||
const application = Application.start()
|
||||
application.register("toggle-button-disabled", toggle_button_disabled_controller)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<form id="test-form" data-controller="toggle-button-disabled">
|
||||
<input id="test-input" type="input" data-action="input->toggle-button-disabled#inputIsChanged" />
|
||||
<input
|
||||
id="test-submit"
|
||||
type="submit"
|
||||
data-disable-with="false"
|
||||
data-toggle-button-disabled-target="button"
|
||||
/>
|
||||
</form>
|
||||
`
|
||||
})
|
||||
|
||||
describe("#connect", () => {
|
||||
it("disables the target submit button", () => {
|
||||
const submit = document.getElementById("test-submit")
|
||||
expect(submit.disabled).toBe(true)
|
||||
})
|
||||
|
||||
describe("when no button present", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<form id="test-form" data-controller="toggle-button-disabled">
|
||||
<input id="test-input" type="input" data-action="input->toggle-button-disabled#inputIsChanged" />
|
||||
</form>
|
||||
`
|
||||
})
|
||||
|
||||
// I am not sure if it's possible to manually trigger the loading/connect of the controller to
|
||||
// try catch the error, so leaving as this. It will break if the missing target isn't handled
|
||||
// properly
|
||||
it("doesn't break", () => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#formIsChanged", () => {
|
||||
let input
|
||||
let submit
|
||||
|
||||
beforeEach(() => {
|
||||
input = document.getElementById("test-input")
|
||||
submit = document.getElementById("test-submit")
|
||||
})
|
||||
|
||||
describe("when the input value is not empty", () => {
|
||||
it("enables the target button", () => {
|
||||
input.value = "test"
|
||||
input.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(submit.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when the input value is empty", () => {
|
||||
it("disables the target button", () => {
|
||||
// setting up state where target button is enabled
|
||||
input.value = "test"
|
||||
input.dispatchEvent(new Event("input"));
|
||||
|
||||
input.value = ""
|
||||
input.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(submit.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,16 @@ module Spree
|
||||
let(:order) { build(:order) }
|
||||
let(:adjustment) { Spree::Adjustment.create(label: "Adjustment", amount: 5) }
|
||||
|
||||
describe "associations" do
|
||||
it { is_expected.to have_one(:metadata) }
|
||||
it { is_expected.to have_many(:adjustments) }
|
||||
|
||||
it { is_expected.to belong_to(:adjustable) }
|
||||
it { is_expected.to belong_to(:originator) }
|
||||
it { is_expected.to belong_to(:order) }
|
||||
it { is_expected.to belong_to(:tax_category) }
|
||||
end
|
||||
|
||||
describe "scopes" do
|
||||
let!(:arbitrary_adjustment) { create(:adjustment, label: "Arbitrary") }
|
||||
let!(:return_authorization_adjustment) {
|
||||
|
||||
@@ -1430,4 +1430,21 @@ describe Spree::Order do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#voucher_adjustments" do
|
||||
let(:voucher) { Voucher.create(code: 'new_code', enterprise: order.distributor) }
|
||||
|
||||
context "when no voucher adjustment" do
|
||||
it 'returns an empty array' do
|
||||
expect(order.voucher_adjustments).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
it "returns an array of voucher adjusment" do
|
||||
order.save!
|
||||
expected_adjustments = Array.new(2) { voucher.create_adjustment(voucher.code, order) }
|
||||
|
||||
expect(order.voucher_adjustments).to eq(expected_adjustments)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,16 +3,55 @@
|
||||
require 'spec_helper'
|
||||
|
||||
describe Voucher do
|
||||
let(:enterprise) { build(:enterprise) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:enterprise) }
|
||||
it { is_expected.to have_many(:adjustments) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { Voucher.new(code: 'new_code', enterprise: enterprise) }
|
||||
|
||||
let(:enterprise) { build(:enterprise) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:code) }
|
||||
it { is_expected.to validate_uniqueness_of(:code).scoped_to(:enterprise_id) }
|
||||
end
|
||||
|
||||
describe '#compute_amount' do
|
||||
subject { Voucher.create(code: 'new_code', enterprise: enterprise) }
|
||||
|
||||
let(:order) { create(:order_with_totals) }
|
||||
|
||||
it 'returns -10' do
|
||||
expect(subject.compute_amount(order).to_f).to eq(-10)
|
||||
end
|
||||
|
||||
context 'when order total is smaller than 10' do
|
||||
it 'returns minus the order total' do
|
||||
order.total = 6
|
||||
order.save!
|
||||
|
||||
expect(subject.compute_amount(order).to_f).to eq(-6)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_adjustment' do
|
||||
subject(:adjustment) { voucher.create_adjustment(voucher.code, order) }
|
||||
|
||||
let(:voucher) { Voucher.create(code: 'new_code', enterprise: enterprise) }
|
||||
let(:order) { create(:order_with_line_items, line_items_count: 1, distributor: enterprise) }
|
||||
|
||||
it 'includes the full voucher amount' do
|
||||
expect(adjustment.amount.to_f).to eq(-10.0)
|
||||
end
|
||||
|
||||
it 'has no included_tax' do
|
||||
expect(adjustment.included_tax.to_f).to eq(0.0)
|
||||
end
|
||||
|
||||
it 'sets the adjustment as open' do
|
||||
expect(adjustment.state).to eq("open")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
61
spec/requests/voucher_adjustments_spec.rb
Normal file
61
spec/requests/voucher_adjustments_spec.rb
Normal file
@@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe VoucherAdjustmentsController, type: :request do
|
||||
let(:user) { order.user }
|
||||
let(:distributor) { create(:distributor_enterprise, with_payment_and_shipping: true) }
|
||||
let(:order) { create( :order_with_line_items, line_items_count: 1, distributor: distributor) }
|
||||
let(:voucher) { Voucher.create(code: 'some_code', enterprise: distributor) }
|
||||
let!(:adjustment) { voucher.create_adjustment(voucher.code, order) }
|
||||
|
||||
before do
|
||||
Flipper.enable(:split_checkout)
|
||||
|
||||
# Make sure the order is created by the order user, the factory doesn't set ip properly
|
||||
order.created_by = user
|
||||
order.save!
|
||||
|
||||
sign_in user
|
||||
end
|
||||
|
||||
describe "DELETE voucher_adjustments/:id" do
|
||||
let(:cable_ready_header) { { accept: "text/vnd.cable-ready.json" } }
|
||||
|
||||
context "with a cable ready request" do
|
||||
it "deletes the voucher adjustment" do
|
||||
delete("/voucher_adjustments/#{adjustment.id}", headers: cable_ready_header)
|
||||
|
||||
expect(order.voucher_adjustments.length).to eq(0)
|
||||
end
|
||||
|
||||
it "render a succesful response" do
|
||||
delete("/voucher_adjustments/#{adjustment.id}", headers: cable_ready_header)
|
||||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
|
||||
context "when adjustment doesn't exits" do
|
||||
it "does nothing" do
|
||||
delete "/voucher_adjustments/-1", headers: cable_ready_header
|
||||
|
||||
expect(order.voucher_adjustments.length).to eq(1)
|
||||
end
|
||||
|
||||
it "render a succesful response" do
|
||||
delete "/voucher_adjustments/-1", headers: cable_ready_header
|
||||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with an html request" do
|
||||
it "redirect to checkout payment step" do
|
||||
delete "/voucher_adjustments/#{adjustment.id}"
|
||||
|
||||
expect(response).to redirect_to(checkout_step_path(:payment))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
131
spec/services/voucher_adjustments_service_spec.rb
Normal file
131
spec/services/voucher_adjustments_service_spec.rb
Normal file
@@ -0,0 +1,131 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe VoucherAdjustmentsService do
|
||||
describe '.calculate' do
|
||||
let(:enterprise) { build(:enterprise) }
|
||||
let(:voucher) { Voucher.create(code: 'new_code', enterprise: enterprise) }
|
||||
|
||||
context 'when voucher covers the order total' do
|
||||
subject { order.voucher_adjustments.first }
|
||||
|
||||
let(:order) { create(:order_with_totals) }
|
||||
|
||||
it 'updates the adjustment amount to -order.total' do
|
||||
voucher.create_adjustment(voucher.code, order)
|
||||
|
||||
order.total = 6
|
||||
order.save!
|
||||
|
||||
VoucherAdjustmentsService.calculate(order)
|
||||
|
||||
expect(subject.amount.to_f).to eq(-6.0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with price included in order price' do
|
||||
subject { order.voucher_adjustments.first }
|
||||
|
||||
let(:order) do
|
||||
create(
|
||||
:order_with_taxes,
|
||||
distributor: enterprise,
|
||||
ship_address: create(:address),
|
||||
product_price: 110,
|
||||
tax_rate_amount: 0.10,
|
||||
included_in_price: true,
|
||||
tax_rate_name: "Tax 1"
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# create adjustment before tax are set
|
||||
voucher.create_adjustment(voucher.code, order)
|
||||
|
||||
# Update taxes
|
||||
order.create_tax_charge!
|
||||
order.update_shipping_fees!
|
||||
order.update_order!
|
||||
|
||||
VoucherAdjustmentsService.calculate(order)
|
||||
end
|
||||
|
||||
it 'updates the adjustment included_tax' do
|
||||
# voucher_rate = amount / order.total
|
||||
# -10 / 150 = -0.066666667
|
||||
# included_tax = voucher_rate * order.included_tax_total
|
||||
# -0.66666666 * 10 = -0.67
|
||||
expect(subject.included_tax.to_f).to eq(-0.67)
|
||||
end
|
||||
|
||||
it 'moves the adjustment state to closed' do
|
||||
expect(subject.state).to eq('closed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with price not included in order price' do
|
||||
let(:order) do
|
||||
create(
|
||||
:order_with_taxes,
|
||||
distributor: enterprise,
|
||||
ship_address: create(:address),
|
||||
product_price: 110,
|
||||
tax_rate_amount: 0.10,
|
||||
included_in_price: false,
|
||||
tax_rate_name: "Tax 1"
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# create adjustment before tax are set
|
||||
voucher.create_adjustment(voucher.code, order)
|
||||
|
||||
# Update taxes
|
||||
order.create_tax_charge!
|
||||
order.update_shipping_fees!
|
||||
order.update_order!
|
||||
|
||||
VoucherAdjustmentsService.calculate(order)
|
||||
end
|
||||
|
||||
it 'includes amount withou tax' do
|
||||
adjustment = order.voucher_adjustments.first
|
||||
# voucher_rate = amount / order.total
|
||||
# -10 / 161 = -0.062111801
|
||||
# amount = voucher_rate * (order.total - order.additional_tax_total)
|
||||
# -0.062111801 * (161 -11) = -9.32
|
||||
expect(adjustment.amount.to_f).to eq(-9.32)
|
||||
end
|
||||
|
||||
it 'creates a tax adjustment' do
|
||||
# voucher_rate = amount / order.total
|
||||
# -10 / 161 = -0.062111801
|
||||
# amount = voucher_rate * order.additional_tax_total
|
||||
# -0.0585 * 11 = -0.68
|
||||
tax_adjustment = order.voucher_adjustments.second
|
||||
expect(tax_adjustment.amount.to_f).to eq(-0.68)
|
||||
expect(tax_adjustment.label).to match("Tax")
|
||||
end
|
||||
|
||||
it 'moves the adjustment state to closed' do
|
||||
adjustment = order.voucher_adjustments.first
|
||||
expect(adjustment.state).to eq('closed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no order given' do
|
||||
it "doesn't blow up" do
|
||||
expect { VoucherAdjustmentsService.calculate(nil) }.to_not raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no voucher used on the given order' do
|
||||
let(:order) { create(:order_with_line_items, line_items_count: 1, distributor: enterprise) }
|
||||
|
||||
it "doesn't blow up" do
|
||||
expect { VoucherAdjustmentsService.calculate(order) }.to_not raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -33,7 +33,7 @@ describe "As a consumer, I want to checkout my order" do
|
||||
let(:enterprise_fee) { create(:enterprise_fee, amount: 1.23, tax_category: fee_tax_category) }
|
||||
|
||||
let(:free_shipping_with_required_address) {
|
||||
create(:shipping_method, require_ship_address: true,
|
||||
create(:shipping_method, require_ship_address: true,
|
||||
name: "A Free Shipping with required address")
|
||||
}
|
||||
let(:free_shipping) {
|
||||
@@ -708,6 +708,93 @@ describe "As a consumer, I want to checkout my order" do
|
||||
end
|
||||
end
|
||||
|
||||
describe "vouchers" do
|
||||
context "with no voucher available" do
|
||||
before do
|
||||
visit checkout_step_path(:payment)
|
||||
end
|
||||
|
||||
it "doesn't show voucher input" do
|
||||
expect(page).not_to have_content "Apply voucher"
|
||||
end
|
||||
end
|
||||
|
||||
context "with voucher available" do
|
||||
let!(:voucher) { Voucher.create(code: 'some_code', enterprise: distributor) }
|
||||
|
||||
before do
|
||||
visit checkout_step_path(:payment)
|
||||
end
|
||||
|
||||
it "shows voucher input" do
|
||||
expect(page).to have_content "Apply voucher"
|
||||
end
|
||||
|
||||
describe "adding voucher to the order" do
|
||||
shared_examples "adding voucher to the order" do
|
||||
before do
|
||||
fill_in "Enter voucher code", with: voucher.code
|
||||
click_button("Apply")
|
||||
end
|
||||
|
||||
it "adds a voucher to the order" do
|
||||
expect(page).to have_content("$10.00 Voucher")
|
||||
expect(order.reload.voucher_adjustments.length).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like "adding voucher to the order"
|
||||
|
||||
context "when voucher covers more then the order total" do
|
||||
before do
|
||||
order.total = 6
|
||||
order.save!
|
||||
end
|
||||
|
||||
it_behaves_like "adding voucher to the order"
|
||||
|
||||
it "shows a warning message" do
|
||||
fill_in "Enter voucher code", with: voucher.code
|
||||
click_button("Apply")
|
||||
|
||||
expect(page).to have_content(
|
||||
"Your voucher value is more than your order. " \
|
||||
"By using this voucher you are forfeiting the remaining value."
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "voucher doesn't exist" do
|
||||
it "show an error" do
|
||||
fill_in "Enter voucher code", with: "non_code"
|
||||
click_button("Apply")
|
||||
|
||||
expect(page).to have_content("Voucher Not found")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "removing voucher from order" do
|
||||
before do
|
||||
voucher.create_adjustment(voucher.code, order)
|
||||
# Reload the page so we pickup the voucher
|
||||
visit checkout_step_path(:payment)
|
||||
end
|
||||
|
||||
it "removes voucher" do
|
||||
accept_confirm "Are you sure you want to remove the voucher?" do
|
||||
click_on "Remove code"
|
||||
end
|
||||
|
||||
within '.voucher' do
|
||||
expect(page).to have_button("Apply", disabled: true)
|
||||
end
|
||||
expect(order.voucher_adjustments.length).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "choosing" do
|
||||
shared_examples "different payment methods" do |pay_method|
|
||||
context "checking out with #{pay_method}", if: pay_method.eql?("Stripe SCA") == false do
|
||||
@@ -937,8 +1024,6 @@ describe "As a consumer, I want to checkout my order" do
|
||||
end
|
||||
|
||||
context "when the terms have been accepted in the past" do
|
||||
|
||||
|
||||
context "with a dedicated ToS file" do
|
||||
before do
|
||||
TermsOfServiceFile.create!(
|
||||
@@ -1024,6 +1109,25 @@ describe "As a consumer, I want to checkout my order" do
|
||||
}.to change { order.reload.state }.from("confirmation").to("address")
|
||||
end
|
||||
end
|
||||
|
||||
describe "vouchers" do
|
||||
let(:voucher) { Voucher.create(code: 'some_code', enterprise: distributor) }
|
||||
|
||||
before do
|
||||
# Add voucher to the order
|
||||
voucher.create_adjustment(voucher.code, order)
|
||||
# Update order so voucher adjustment is properly taken into account
|
||||
order.update_order!
|
||||
|
||||
visit checkout_step_path(:summary)
|
||||
end
|
||||
|
||||
it "shows the applied voucher" do
|
||||
within ".summary-right" do
|
||||
expect(page).to have_content "some_code"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with previous open orders" do
|
||||
|
||||
@@ -134,6 +134,44 @@ describe "As a consumer, I want to see adjustment breakdown" do
|
||||
# DB checks
|
||||
assert_db_tax_incl
|
||||
end
|
||||
|
||||
context "when using a voucher" do
|
||||
let!(:voucher) { Voucher.create(code: 'some_code', enterprise: distributor) }
|
||||
|
||||
it "will include a tax included amount on the voucher adjustment" do
|
||||
visit checkout_step_path(:details)
|
||||
|
||||
choose "Delivery"
|
||||
|
||||
click_button "Next - Payment method"
|
||||
|
||||
# add Voucher
|
||||
fill_in "Enter voucher code", with: voucher.code
|
||||
click_button("Apply")
|
||||
|
||||
# Choose payment ??
|
||||
click_on "Next - Order summary"
|
||||
click_on "Complete order"
|
||||
|
||||
# UI checks
|
||||
expect(page).to have_content("Confirmed")
|
||||
expect(page).to have_selector('#order_total', text: with_currency(0.00))
|
||||
expect(page).to have_selector('#tax-row', text: with_currency(1.15))
|
||||
|
||||
# Voucher
|
||||
within "#line-items" do
|
||||
expect(page).to have_content(voucher.code)
|
||||
expect(page).to have_content(with_currency(-10.00))
|
||||
end
|
||||
|
||||
# DB check
|
||||
order_within_zone.reload
|
||||
voucher_adjustment = order_within_zone.voucher_adjustments.first
|
||||
|
||||
expect(voucher_adjustment.amount.to_f).to eq(-10)
|
||||
expect(voucher_adjustment.included_tax.to_f).to eq(-1.15)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -142,6 +142,47 @@ describe "As a consumer, I want to see adjustment breakdown" do
|
||||
expect(page).to have_selector('#order_total', text: with_currency(11.30))
|
||||
expect(page).to have_selector('#tax-row', text: with_currency(1.30))
|
||||
end
|
||||
|
||||
context "when using a voucher" do
|
||||
let!(:voucher) { Voucher.create(code: 'some_code', enterprise: distributor) }
|
||||
|
||||
it "will include a tax included amount on the voucher adjustment" do
|
||||
visit checkout_step_path(:details)
|
||||
|
||||
choose "Delivery"
|
||||
|
||||
click_button "Next - Payment method"
|
||||
# add Voucher
|
||||
fill_in "Enter voucher code", with: voucher.code
|
||||
click_button("Apply")
|
||||
|
||||
# Choose payment ??
|
||||
click_on "Next - Order summary"
|
||||
click_on "Complete order"
|
||||
|
||||
# UI checks
|
||||
expect(page).to have_content("Confirmed")
|
||||
expect(page).to have_selector('#order_total', text: with_currency(1.30))
|
||||
expect(page).to have_selector('#tax-row', text: with_currency(1.30))
|
||||
|
||||
# Voucher
|
||||
within "#line-items" do
|
||||
expect(page).to have_content(voucher.code)
|
||||
expect(page).to have_content(with_currency(-8.85))
|
||||
|
||||
expect(page).to have_content("Tax #{voucher.code}")
|
||||
expect(page).to have_content(with_currency(-1.15))
|
||||
end
|
||||
|
||||
# DB check
|
||||
order_within_zone.reload
|
||||
voucher_adjustment = order_within_zone.voucher_adjustments.first
|
||||
voucher_tax_adjustment = order_within_zone.voucher_adjustments.second
|
||||
|
||||
expect(voucher_adjustment.amount.to_f).to eq(-8.85)
|
||||
expect(voucher_tax_adjustment.amount.to_f).to eq(-1.15)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user