diff --git a/core/app/models/spree/order/store_credit.rb b/core/app/models/spree/order/store_credit.rb index 92f0a701b4a..25cc503d893 100644 --- a/core/app/models/spree/order/store_credit.rb +++ b/core/app/models/spree/order/store_credit.rb @@ -1,6 +1,14 @@ module Spree class Order < Spree::Base module StoreCredit + def add_store_credit_payments(amount = nil) + Spree::Dependencies.checkout_add_store_credit_service.constantize.call(order: self, amount: amount) + end + + def remove_store_credit_payments + Spree::Dependencies.checkout_remove_store_credit_service.constantize.call(order: self) + end + def covered_by_store_credit? return false unless user diff --git a/core/app/models/spree/order_contents.rb b/core/app/models/spree/order_contents.rb new file mode 100644 index 00000000000..d28a20847f0 --- /dev/null +++ b/core/app/models/spree/order_contents.rb @@ -0,0 +1,31 @@ +module Spree + class OrderContents + attr_accessor :order, :currency + + def initialize(order) + @order = order + end + + def add(variant, quantity = 1, options = {}) + Spree::Dependencies.cart_add_item_service.constantize.call(order: order, + variant: variant, + quantity: quantity, + options: options).value + end + + def remove(variant, quantity = 1, options = {}) + Spree::Dependencies.cart_remove_item_service.constantize.call(order: order, + variant: variant, + quantity: quantity, + options: options).value + end + + def remove_line_item(line_item, options = {}) + Spree::Cart::RemoveLineItem.call(order: @order, line_item: line_item, options: options).value + end + + def update_cart(params) + Spree::Dependencies.cart_update_service.constantize.call(order: order, params: params).value + end + end +end diff --git a/core/spec/models/spree/order/store_credit_spec.rb b/core/spec/models/spree/order/store_credit_spec.rb index cbf7ba89e33..0bc69afe5bd 100644 --- a/core/spec/models/spree/order/store_credit_spec.rb +++ b/core/spec/models/spree/order/store_credit_spec.rb @@ -25,6 +25,129 @@ end describe 'Order' do + describe '#add_store_credit_payments' do + subject { order.add_store_credit_payments } + + let(:order_total) { 500.00 } + + before { create(:store_credit_payment_method) } + + context 'there is no store credit' do + let(:order) { create(:store_credits_order_without_user, total: order_total) } + + before do + # callbacks recalculate total based on line items + # this ensures the total is what we expect + order.update_column(:total, order_total) + subject + order.reload + end + + it 'does not create a store credit payment' do + expect(order.payments.count).to eq 0 + end + end + + context 'there is enough store credit to pay for the entire order' do + let(:store_credit) { create(:store_credit, amount: order_total) } + let(:order) { create(:order, user: store_credit.user, total: order_total) } + + before do + subject + order.reload + end + + it 'creates a store credit payment for the full amount' do + expect(order.payments.count).to eq 1 + expect(order.payments.first).to be_store_credit + expect(order.payments.first.amount).to eq order_total + end + end + + context 'the available store credit is not enough to pay for the entire order' do + let(:expected_cc_total) { 100.0 } + let(:store_credit_total) { order_total - expected_cc_total } + let(:store_credit) { create(:store_credit, amount: store_credit_total) } + let(:order) { create(:order, user: store_credit.user, total: order_total) } + + before do + # callbacks recalculate total based on line items + # this ensures the total is what we expect + order.update_column(:total, order_total) + subject + order.reload + end + + it 'creates a store credit payment for the available amount' do + expect(order.payments.count).to eq 1 + expect(order.payments.first).to be_store_credit + expect(order.payments.first.amount).to eq store_credit_total + end + end + + context 'there are multiple store credits' do + context 'they have different credit type priorities' do + let(:amount_difference) { 100 } + let!(:primary_store_credit) { create(:store_credit, amount: (order_total - amount_difference)) } + let!(:secondary_store_credit) do + create(:store_credit, amount: order_total, user: primary_store_credit.user, + credit_type: create(:secondary_credit_type)) + end + let(:order) { create(:order, user: primary_store_credit.user, total: order_total) } + + before do + Timecop.scale(3600) + subject + order.reload + end + + after { Timecop.return } + + it 'uses the primary store credit type over the secondary' do + primary_payment = order.payments.first + secondary_payment = order.payments.last + + expect(order.payments.size).to eq 2 + expect(primary_payment.source).to eq primary_store_credit + expect(secondary_payment.source).to eq secondary_store_credit + expect(primary_payment.amount).to eq(order_total - amount_difference) + expect(secondary_payment.amount).to eq(amount_difference) + end + end + end + end + + describe '#remove_store_credit_payments' do + subject { order.remove_store_credit_payments } + + let(:order_total) { 500.00 } + let(:order) { create(:order, user: store_credit.user, total: order_total) } + + context 'when order is not complete' do + let(:store_credit) { create(:store_credit, amount: order_total - 1) } + + before do + create(:store_credit_payment_method) + order.add_store_credit_payments + end + + it { expect { subject }.to change { order.payments.checkout.store_credits.count }.from(1).to(0) } + it { expect { subject }.to change { order.payments.with_state(:invalid).store_credits.count }.from(0).to(1) } + end + + context 'when order is complete' do + let(:order) { create(:completed_order_with_store_credit_payment) } + let(:store_credit_payments) { order.payments.checkout.store_credits } + + before do + subject + order.reload + end + + it { expect(order.payments.checkout.store_credits).to eq store_credit_payments } + end + end + describe '#covered_by_store_credit' do context "order doesn't have an associated user" do subject { create(:store_credits_order_without_user) } diff --git a/core/spec/models/spree/order_contents_spec.rb b/core/spec/models/spree/order_contents_spec.rb new file mode 100644 index 00000000000..2dfca196b50 --- /dev/null +++ b/core/spec/models/spree/order_contents_spec.rb @@ -0,0 +1,336 @@ +require 'spec_helper' + +describe Spree::OrderContents, type: :model do + subject { described_class.new(order) } + + let!(:zone) { create(:zone_with_country, default_tax: true) } + let(:store) { create(:store, checkout_zone: zone) } + let(:user) { create(:user) } + let(:order) { create(:order, user: user, store: store) } + let(:variant) { create(:variant) } + + context '#add' do + context 'given quantity is not explicitly provided' do + it 'adds one line item' do + line_item = subject.add(variant) + expect(line_item.quantity).to eq(1) + expect(order.line_items.size).to eq(1) + end + end + + context 'given a shipment' do + it 'ensure shipment calls update_amounts instead of order calling ensure_updated_shipments' do + shipment = create(:shipment) + expect(subject.order).not_to receive(:ensure_updated_shipments) + expect(subject.order).to receive(:refresh_shipment_rates).with(Spree::ShippingMethod::DISPLAY_ON_BACK_END) + expect(shipment).to receive(:update_amounts) + subject.add(variant, 1, shipment: shipment) + end + end + + context 'not given a shipment' do + it 'ensures updated shipments' do + expect(subject.order).to receive(:ensure_updated_shipments) + subject.add(variant) + end + end + + it 'adds line item if one does not exist' do + line_item = subject.add(variant, 1) + expect(line_item.quantity).to eq(1) + expect(order.line_items.size).to eq(1) + end + + it 'updates line item if one exists' do + subject.add(variant, 1) + line_item = subject.add(variant, 1) + expect(line_item.quantity).to eq(2) + expect(order.line_items.size).to eq(1) + end + + it 'updates order totals' do + expect(order.item_total.to_f).to eq(0.00) + expect(order.total.to_f).to eq(0.00) + + subject.add(variant, 1) + + expect(order.item_total.to_f).to eq(19.99) + expect(order.total.to_f).to eq(19.99) + end + + context 'when store_credits payment' do + let!(:payment) { create(:store_credit_payment, order: order) } + + it { expect { subject.add(variant, 1) }.to change { order.payments.store_credits.count }.by(-1) } + end + + context 'running promotions' do + let(:promotion) { create(:promotion, stores: [store]) } + let(:calculator) { Spree::Calculator::FlatRate.new(preferred_amount: 10) } + + shared_context 'discount changes order total' do + before { subject.add(variant, 1) } + + it { expect(subject.order.total).not_to eq variant.price } + end + + context 'one active order promotion' do + let!(:action) { Spree::Promotion::Actions::CreateAdjustment.create(promotion: promotion, calculator: calculator) } + + it 'creates valid discount on order' do + subject.add(variant, 1) + expect(subject.order.adjustments.sum(:amount)).not_to eq 0 + end + + include_context 'discount changes order total' + end + + context 'one active line item promotion' do + let!(:action) { Spree::Promotion::Actions::CreateItemAdjustments.create(promotion: promotion, calculator: calculator) } + + it 'creates valid discount on order' do + subject.add(variant, 1) + expect(subject.order.line_item_adjustments.to_a.sum(&:amount)).not_to eq 0 + end + + include_context 'discount changes order total' + end + + xcontext 'VAT for variant with percent promotion' do + let!(:category) { Spree::TaxCategory.create name: 'Taxable Foo' } + let!(:rate) do + Spree::TaxRate.create( + amount: 0.25, + included_in_price: true, + calculator: Spree::Calculator::DefaultTax.create, + tax_category: category, + zone: zone + ) + end + let(:product) { create(:product, stores: [store]) } + let(:variant) { create(:variant, price: 1000, product: product) } + let(:calculator) { Spree::Calculator::PercentOnLineItem.new(preferred_percent: 50) } + let!(:action) { Spree::Promotion::Actions::CreateItemAdjustments.create(promotion: promotion, calculator: calculator) } + + it 'updates included_tax_total' do + expect(order.included_tax_total.to_f).to eq(0.00) + subject.add(variant, 1) + expect(order.included_tax_total.to_f).to eq(100) + end + + it 'updates included_tax_total after adding two line items' do + subject.add(variant, 1) + expect(order.included_tax_total.to_f).to eq(100) + subject.add(variant, 1) + expect(order.included_tax_total.to_f).to eq(200) + end + end + end + end + + context '#remove' do + context 'given an invalid variant' do + it 'raises an exception' do + expect do + subject.remove(variant, 1) + end.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'given quantity is not explicitly provided' do + it 'removes one line item' do + line_item = subject.add(variant, 3) + subject.remove(variant) + + expect(line_item.quantity).to eq(2) + end + end + + context 'given a shipment' do + it 'ensure shipment calls update_amounts instead of order calling ensure_updated_shipments' do + subject.add(variant, 1) # line item + shipment = create(:shipment) + expect(subject.order).not_to receive(:ensure_updated_shipments) + expect(shipment).to receive(:update_amounts) + subject.remove(variant, 1, shipment: shipment) + end + end + + context 'not given a shipment' do + it 'ensures updated shipments' do + subject.add(variant, 1) # line item + expect(subject.order).to receive(:ensure_updated_shipments) + subject.remove(variant) + end + end + + it 'reduces line_item quantity if quantity is less the line_item quantity' do + line_item = subject.add(variant, 3) + subject.remove(variant, 1) + + expect(line_item.quantity).to eq(2) + end + + context 'when store_credits payment' do + let(:payment) { create(:store_credit_payment, order: order) } + + before do + subject.add(variant, 1) + payment + end + + it { expect { subject.remove(variant, 1) }.to change { order.payments.store_credits.count }.by(-1) } + end + + it 'removes line_item if quantity matches line_item quantity' do + subject.add(variant, 1) + removed_line_item = subject.remove(variant, 1) + + # Should reflect the change already in Order#line_item + expect(order.line_items).not_to include(removed_line_item) + end + + it 'updates order totals' do + expect(order.item_total.to_f).to eq(0.00) + expect(order.total.to_f).to eq(0.00) + + subject.add(variant, 2) + + expect(order.item_total.to_f).to eq(39.98) + expect(order.total.to_f).to eq(39.98) + + subject.remove(variant, 1) + expect(order.item_total.to_f).to eq(19.99) + expect(order.total.to_f).to eq(19.99) + end + end + + context '#remove_line_item' do + context 'given a shipment' do + it 'ensure shipment calls update_amounts instead of order calling ensure_updated_shipments' do + line_item = subject.add(variant, 1) + shipment = create(:shipment) + expect(subject.order).not_to receive(:ensure_updated_shipments) + expect(shipment).to receive(:update_amounts) + subject.remove_line_item(line_item, shipment: shipment) + end + end + + context 'not given a shipment' do + it 'ensures updated shipments' do + line_item = subject.add(variant, 1) + expect(subject.order).to receive(:ensure_updated_shipments) + subject.remove_line_item(line_item) + end + end + + context 'when store_credits payment' do + let(:payment) { create(:store_credit_payment, order: order) } + + before do + @line_item = subject.add(variant, 1) + payment + end + + it { expect { subject.remove_line_item(@line_item) }.to change { order.payments.store_credits.count }.by(-1) } + end + + it 'removes line_item' do + line_item = subject.add(variant, 1) + subject.remove_line_item(line_item) + + expect(order.reload.line_items).not_to include(line_item) + end + + it 'updates order totals' do + expect(order.item_total.to_f).to eq(0.00) + expect(order.total.to_f).to eq(0.00) + + line_item = subject.add(variant, 2) + + expect(order.item_total.to_f).to eq(39.98) + expect(order.total.to_f).to eq(39.98) + + subject.remove_line_item(line_item) + expect(order.item_total.to_f).to eq(0.00) + expect(order.total.to_f).to eq(0.00) + end + end + + context 'update cart' do + let!(:shirt) { subject.add variant, 1 } + + let(:params) do + { line_items_attributes: { + '0' => { id: shirt.id, quantity: 3 } + } } + end + + it 'changes item quantity' do + subject.update_cart params + expect(shirt.quantity).to eq 3 + end + + it 'updates order totals' do + expect do + subject.update_cart params + end.to change { subject.order.total } + end + + context 'when store_credits payment' do + let!(:payment) { create(:store_credit_payment, order: order) } + + it { expect { subject.update_cart params }.to change { order.payments.store_credits.count }.by(-1) } + end + + context 'submits item quantity 0' do + let(:params) do + { line_items_attributes: { + '0' => { id: shirt.id, quantity: 0 }, + '1' => { id: '666', quantity: 0 } + } } + end + + it 'removes item from order' do + expect do + subject.update_cart params + end.to change { subject.order.line_items.count } + end + + it 'doesnt try to update unexistent items' do + filtered_params = { line_items_attributes: { + '0' => { id: shirt.id, quantity: 0 } + } } + expect(subject.order).to receive(:update).with(filtered_params) + subject.update_cart params + end + + it 'does not filter if there is only one line item' do + single_line_item_params = { line_items_attributes: { id: shirt.id, quantity: 0 } } + expect(subject.order).to receive(:update).with(single_line_item_params) + subject.update_cart single_line_item_params + end + end + + it 'ensures updated shipments' do + expect(subject.order).to receive(:ensure_updated_shipments) + subject.update_cart params + end + end + + context 'completed order' do + let(:order) { create(:order, state: 'complete', completed_at: Time.current) } + + before { order.shipments.create! stock_location_id: variant.stock_location_ids.first } + + it 'updates order payment state' do + expect do + subject.add variant + end.to change(order, :payment_state) + + expect do + subject.remove variant + end.to change(order, :payment_state) + end + end +end