diff --git a/app/controllers/concerns/checkout_callbacks.rb b/app/controllers/concerns/checkout_callbacks.rb index 4b6608b947..159c6041ea 100644 --- a/app/controllers/concerns/checkout_callbacks.rb +++ b/app/controllers/concerns/checkout_callbacks.rb @@ -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 diff --git a/app/controllers/split_checkout_controller.rb b/app/controllers/split_checkout_controller.rb index d65a661f32..1ccf66f27c 100644 --- a/app/controllers/split_checkout_controller.rb +++ b/app/controllers/split_checkout_controller.rb @@ -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 diff --git a/app/controllers/voucher_adjustments_controller.rb b/app/controllers/voucher_adjustments_controller.rb new file mode 100644 index 0000000000..b45b60fd9b --- /dev/null +++ b/app/controllers/voucher_adjustments_controller.rb @@ -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 diff --git a/app/models/spree/adjustment.rb b/app/models/spree/adjustment.rb index 06131597db..57ef3d549c 100644 --- a/app/models/spree/adjustment.rb +++ b/app/models/spree/adjustment.rb @@ -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 diff --git a/app/models/spree/order.rb b/app/models/spree/order.rb index 40dfbcb093..74a4f332d9 100644 --- a/app/models/spree/order.rb +++ b/app/models/spree/order.rb @@ -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' diff --git a/app/models/voucher.rb b/app/models/voucher.rb index 28c74ca7de..376c1979dd 100644 --- a/app/models/voucher.rb +++ b/app/models/voucher.rb @@ -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 diff --git a/app/services/voucher_adjustments_service.rb b/app/services/voucher_adjustments_service.rb new file mode 100644 index 0000000000..2569010d95 --- /dev/null +++ b/app/services/voucher_adjustments_service.rb @@ -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 diff --git a/app/views/split_checkout/_payment.html.haml b/app/views/split_checkout/_payment.html.haml index 0e98fe6df4..82d3adcb3a 100644 --- a/app/views/split_checkout/_payment.html.haml +++ b/app/views/split_checkout/_payment.html.haml @@ -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") diff --git a/app/views/split_checkout/_summary.html.haml b/app/views/split_checkout/_summary.html.haml index 692be1510d..e395bd7411 100644 --- a/app/views/split_checkout/_summary.html.haml +++ b/app/views/split_checkout/_summary.html.haml @@ -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 diff --git a/app/views/split_checkout/_voucher_section.cable_ready.haml b/app/views/split_checkout/_voucher_section.cable_ready.haml new file mode 100644 index 0000000000..786eff7288 --- /dev/null +++ b/app/views/split_checkout/_voucher_section.cable_ready.haml @@ -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" } diff --git a/app/webpacker/controllers/toggle_button_disabled_controller.js b/app/webpacker/controllers/toggle_button_disabled_controller.js new file mode 100644 index 0000000000..7ef9233217 --- /dev/null +++ b/app/webpacker/controllers/toggle_button_disabled_controller.js @@ -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: +// +// +// +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; + } + } +} diff --git a/app/webpacker/css/darkswarm/split-checkout.scss b/app/webpacker/css/darkswarm/split-checkout.scss index 96c351e198..3d9c05b929 100644 --- a/app/webpacker/css/darkswarm/split-checkout.scss +++ b/app/webpacker/css/darkswarm/split-checkout.scss @@ -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; + } } } diff --git a/config/locales/en.yml b/config/locales/en.yml index c8a9ab96fc..889ffa5836 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 04a800bfd5..be14086b9c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20230315040748_add_deleted_at_to_vouchers.rb b/db/migrate/20230315040748_add_deleted_at_to_vouchers.rb new file mode 100644 index 0000000000..d9993f2b93 --- /dev/null +++ b/db/migrate/20230315040748_add_deleted_at_to_vouchers.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index dbcb97b5a1..f0935b6e16 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/spec/base_spec_helper.rb b/spec/base_spec_helper.rb index 19206bd0b5..e30ddc382c 100644 --- a/spec/base_spec_helper.rb +++ b/spec/base_spec_helper.rb @@ -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 diff --git a/spec/controllers/split_checkout_controller_spec.rb b/spec/controllers/split_checkout_controller_spec.rb index 7f3ad6b074..774be4ca76 100644 --- a/spec/controllers/split_checkout_controller_spec.rb +++ b/spec/controllers/split_checkout_controller_spec.rb @@ -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 diff --git a/spec/javascripts/stimulus/toggle_button_disabled_controller_test.js b/spec/javascripts/stimulus/toggle_button_disabled_controller_test.js new file mode 100644 index 0000000000..2c367fdb11 --- /dev/null +++ b/spec/javascripts/stimulus/toggle_button_disabled_controller_test.js @@ -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 = ` +
+ ` + }) + + 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 = ` + + ` + }) + + // 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) + }) + }) + }) +}) diff --git a/spec/models/spree/adjustment_spec.rb b/spec/models/spree/adjustment_spec.rb index 2dde510c6e..66d243fd59 100644 --- a/spec/models/spree/adjustment_spec.rb +++ b/spec/models/spree/adjustment_spec.rb @@ -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) { diff --git a/spec/models/spree/order_spec.rb b/spec/models/spree/order_spec.rb index 4ea1933d6c..5b3d0ef71a 100644 --- a/spec/models/spree/order_spec.rb +++ b/spec/models/spree/order_spec.rb @@ -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 diff --git a/spec/models/voucher_spec.rb b/spec/models/voucher_spec.rb index 9d9590455f..3ef8792b1c 100644 --- a/spec/models/voucher_spec.rb +++ b/spec/models/voucher_spec.rb @@ -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 diff --git a/spec/requests/voucher_adjustments_spec.rb b/spec/requests/voucher_adjustments_spec.rb new file mode 100644 index 0000000000..056526ee67 --- /dev/null +++ b/spec/requests/voucher_adjustments_spec.rb @@ -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 diff --git a/spec/services/voucher_adjustments_service_spec.rb b/spec/services/voucher_adjustments_service_spec.rb new file mode 100644 index 0000000000..d2b95cdb78 --- /dev/null +++ b/spec/services/voucher_adjustments_service_spec.rb @@ -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 diff --git a/spec/system/consumer/split_checkout_spec.rb b/spec/system/consumer/split_checkout_spec.rb index e114184a0e..f0e5fb6fc6 100644 --- a/spec/system/consumer/split_checkout_spec.rb +++ b/spec/system/consumer/split_checkout_spec.rb @@ -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 diff --git a/spec/system/consumer/split_checkout_tax_incl_spec.rb b/spec/system/consumer/split_checkout_tax_incl_spec.rb index 146104e824..5cb07f67bb 100644 --- a/spec/system/consumer/split_checkout_tax_incl_spec.rb +++ b/spec/system/consumer/split_checkout_tax_incl_spec.rb @@ -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 diff --git a/spec/system/consumer/split_checkout_tax_not_incl_spec.rb b/spec/system/consumer/split_checkout_tax_not_incl_spec.rb index 435d02d658..4fa0f0531a 100644 --- a/spec/system/consumer/split_checkout_tax_not_incl_spec.rb +++ b/spec/system/consumer/split_checkout_tax_not_incl_spec.rb @@ -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