diff --git a/app/models/spree/address.rb b/app/models/spree/address.rb new file mode 100644 index 0000000000..1285ca3380 --- /dev/null +++ b/app/models/spree/address.rb @@ -0,0 +1,123 @@ +module Spree + class Address < ActiveRecord::Base + belongs_to :country, class_name: "Spree::Country" + belongs_to :state, class_name: "Spree::State" + + has_many :shipments + + validates :firstname, :lastname, :address1, :city, :country, presence: true + validates :zipcode, presence: true, if: :require_zipcode? + validates :phone, presence: true, if: :require_phone? + + validate :state_validate + + alias_attribute :first_name, :firstname + alias_attribute :last_name, :lastname + + def self.default + country = Spree::Country.find(Spree::Config[:default_country_id]) rescue Spree::Country.first + new(country: country) + end + + # Can modify an address if it's not been used in an order (but checkouts controller has finer control) + # def editable? + # new_record? || (shipments.empty? && checkouts.empty?) + # end + + def full_name + "#{firstname} #{lastname}".strip + end + + def state_text + state.try(:abbr) || state.try(:name) || state_name + end + + def same_as?(other) + return false if other.nil? + attributes.except('id', 'updated_at', 'created_at') == other.attributes.except('id', 'updated_at', 'created_at') + end + + alias same_as same_as? + + def to_s + "#{full_name}: #{address1}" + end + + def clone + self.class.new(self.attributes.except('id', 'updated_at', 'created_at')) + end + + def ==(other_address) + self_attrs = self.attributes + other_attrs = other_address.respond_to?(:attributes) ? other_address.attributes : {} + + [self_attrs, other_attrs].each { |attrs| attrs.except!('id', 'created_at', 'updated_at', 'order_id') } + + self_attrs == other_attrs + end + + def empty? + attributes.except('id', 'created_at', 'updated_at', 'order_id', 'country_id').all? { |_, v| v.nil? } + end + + # Generates an ActiveMerchant compatible address hash + def active_merchant_hash + { + name: full_name, + address1: address1, + address2: address2, + city: city, + state: state_text, + zip: zipcode, + country: country.try(:iso), + phone: phone + } + end + + private + def require_phone? + true + end + + def require_zipcode? + true + end + + def state_validate + # Skip state validation without country (also required) + # or when disabled by preference + return if country.blank? || !Spree::Config[:address_requires_state] + return unless country.states_required + + # ensure associated state belongs to country + if state.present? + if state.country == country + self.state_name = nil #not required as we have a valid state and country combo + else + if state_name.present? + self.state = nil + else + errors.add(:state, :invalid) + end + end + end + + # ensure state_name belongs to country without states, or that it matches a predefined state name/abbr + if state_name.present? + if country.states.present? + states = country.states.find_all_by_name_or_abbr(state_name) + + if states.size == 1 + self.state = states.first + self.state_name = nil + else + errors.add(:state, :invalid) + end + end + end + + # ensure at least one state field is populated + errors.add :state, :blank if state.blank? && state_name.blank? + end + end +end diff --git a/app/models/spree/shipping_category.rb b/app/models/spree/shipping_category.rb new file mode 100644 index 0000000000..c3d7ca3f24 --- /dev/null +++ b/app/models/spree/shipping_category.rb @@ -0,0 +1,8 @@ +module Spree + class ShippingCategory < ActiveRecord::Base + validates :name, presence: true + has_many :products + has_many :shipping_method_categories + has_many :shipping_methods, through: :shipping_method_categories + end +end diff --git a/app/models/spree/shipping_method.rb b/app/models/spree/shipping_method.rb new file mode 100644 index 0000000000..6d610c9159 --- /dev/null +++ b/app/models/spree/shipping_method.rb @@ -0,0 +1,60 @@ +module Spree + class ShippingMethod < ActiveRecord::Base + include Spree::Core::CalculatedAdjustments + DISPLAY = [:both, :front_end, :back_end] + + default_scope -> { where(deleted_at: nil) } + + has_many :shipments + has_many :shipping_method_categories + has_many :shipping_categories, through: :shipping_method_categories + has_many :shipping_rates + + has_and_belongs_to_many :zones, :join_table => 'spree_shipping_methods_zones', + :class_name => 'Spree::Zone', + :foreign_key => 'shipping_method_id' + + validates :name, presence: true + + validate :at_least_one_shipping_category + + def adjustment_label + Spree.t(:shipping) + end + + def include?(address) + return false unless address + zones.any? do |zone| + zone.include?(address) + end + end + + def build_tracking_url(tracking) + tracking_url.gsub(/:tracking/, tracking) unless tracking.blank? || tracking_url.blank? + end + + def self.calculators + spree_calculators.send(model_name_without_spree_namespace).select{ |c| c < Spree::ShippingCalculator } + end + + # Some shipping methods are only meant to be set via backend + def frontend? + self.display_on != "back_end" + end + + private + def at_least_one_shipping_category + if self.shipping_categories.empty? + self.errors[:base] << "You need to select at least one shipping category" + end + end + + def self.on_backend_query + "#{table_name}.display_on != 'front_end' OR #{table_name}.display_on IS NULL" + end + + def self.on_frontend_query + "#{table_name}.display_on != 'back_end' OR #{table_name}.display_on IS NULL" + end + end +end diff --git a/app/models/spree/shipping_method_category.rb b/app/models/spree/shipping_method_category.rb new file mode 100644 index 0000000000..08c691114b --- /dev/null +++ b/app/models/spree/shipping_method_category.rb @@ -0,0 +1,6 @@ +module Spree + class ShippingMethodCategory < ActiveRecord::Base + belongs_to :shipping_method, class_name: 'Spree::ShippingMethod' + belongs_to :shipping_category, class_name: 'Spree::ShippingCategory' + end +end diff --git a/app/models/spree/shipping_rate.rb b/app/models/spree/shipping_rate.rb new file mode 100644 index 0000000000..b930cda43b --- /dev/null +++ b/app/models/spree/shipping_rate.rb @@ -0,0 +1,32 @@ +module Spree + class ShippingRate < ActiveRecord::Base + belongs_to :shipment, class_name: 'Spree::Shipment' + belongs_to :shipping_method, class_name: 'Spree::ShippingMethod' + + scope :frontend, + -> { includes(:shipping_method). + where(ShippingMethod.on_frontend_query). + references(:shipping_method). + order("cost ASC") } + scope :backend, + -> { includes(:shipping_method). + where(ShippingMethod.on_backend_query). + references(:shipping_method). + order("cost ASC") } + + delegate :order, :currency, to: :shipment + delegate :name, to: :shipping_method + + def display_price + if Spree::Config[:shipment_inc_vat] + price = (1 + Spree::TaxRate.default) * cost + else + price = cost + end + + Spree::Money.new(price, { currency: currency }) + end + + alias_method :display_cost, :display_price + end +end diff --git a/spec/models/spree/address_spec.rb b/spec/models/spree/address_spec.rb new file mode 100644 index 0000000000..96accfa587 --- /dev/null +++ b/spec/models/spree/address_spec.rb @@ -0,0 +1,228 @@ +require 'spec_helper' + +describe Spree::Address do + describe "clone" do + it "creates a copy of the address with the exception of the id, updated_at and created_at attributes" do + state = create(:state) + original = create(:address, + :address1 => 'address1', + :address2 => 'address2', + :alternative_phone => 'alternative_phone', + :city => 'city', + :country => Spree::Country.first, + :firstname => 'firstname', + :lastname => 'lastname', + :company => 'company', + :phone => 'phone', + :state_id => state.id, + :state_name => state.name, + :zipcode => 'zip_code') + + cloned = original.clone + + cloned.address1.should == original.address1 + cloned.address2.should == original.address2 + cloned.alternative_phone.should == original.alternative_phone + cloned.city.should == original.city + cloned.country_id.should == original.country_id + cloned.firstname.should == original.firstname + cloned.lastname.should == original.lastname + cloned.company.should == original.company + cloned.phone.should == original.phone + cloned.state_id.should == original.state_id + cloned.state_name.should == original.state_name + cloned.zipcode.should == original.zipcode + + cloned.id.should_not == original.id + cloned.created_at.should_not == original.created_at + cloned.updated_at.should_not == original.updated_at + end + end + + context "aliased attributes" do + let(:address) { Spree::Address.new } + + it "first_name" do + address.firstname = "Ryan" + address.first_name.should == "Ryan" + end + + it "last_name" do + address.lastname = "Bigg" + address.last_name.should == "Bigg" + end + end + + context "validation" do + before do + configure_spree_preferences do |config| + config.address_requires_state = true + end + end + + let(:country) { mock_model(Spree::Country, :states => [state], :states_required => true) } + let(:state) { stub_model(Spree::State, :name => 'maryland', :abbr => 'md') } + let(:address) { build(:address, :country => country) } + + before do + country.states.stub :find_all_by_name_or_abbr => [state] + end + + it "state_name is not nil and country does not have any states" do + address.state = nil + address.state_name = 'alabama' + address.should be_valid + end + + it "errors when state_name is nil" do + address.state_name = nil + address.state = nil + address.should_not be_valid + end + + it "full state name is in state_name and country does contain that state" do + address.state_name = 'alabama' + # called by state_validate to set up state_id. + # Perhaps this should be a before_validation instead? + address.should be_valid + address.state.should_not be_nil + address.state_name.should be_nil + end + + it "state abbr is in state_name and country does contain that state" do + address.state_name = state.abbr + address.should be_valid + address.state_id.should_not be_nil + address.state_name.should be_nil + end + + it "state is entered but country does not contain that state" do + address.state = state + address.country = stub_model(Spree::Country) + address.valid? + address.errors["state"].should == ['is invalid'] + end + + it "both state and state_name are entered but country does not contain the state" do + address.state = state + address.state_name = 'maryland' + address.country = stub_model(Spree::Country) + address.should be_valid + address.state_id.should be_nil + end + + it "both state and state_name are entered and country does contain the state" do + address.state = state + address.state_name = 'maryland' + address.should be_valid + address.state_name.should be_nil + end + + it "address_requires_state preference is false" do + Spree::Config.set :address_requires_state => false + address.state = nil + address.state_name = nil + address.should be_valid + end + + it "requires phone" do + address.phone = "" + address.valid? + address.errors["phone"].should == ["can't be blank"] + end + + it "requires zipcode" do + address.zipcode = "" + address.valid? + address.should have(1).error_on(:zipcode) + end + + context "phone not required" do + before { address.instance_eval{ self.stub :require_phone? => false } } + + it "shows no errors when phone is blank" do + address.phone = "" + address.valid? + address.should have(:no).errors_on(:phone) + end + end + + context "zipcode not required" do + before { address.instance_eval{ self.stub :require_zipcode? => false } } + + it "shows no errors when phone is blank" do + address.zipcode = "" + address.valid? + address.should have(:no).errors_on(:zipcode) + end + end + end + + context ".default" do + before do + @default_country_id = Spree::Config[:default_country_id] + new_country = create(:country) + Spree::Config[:default_country_id] = new_country.id + end + + after do + Spree::Config[:default_country_id] = @default_country_id + end + it "sets up a new record with Spree::Config[:default_country_id]" do + Spree::Address.default.country.should == Spree::Country.find(Spree::Config[:default_country_id]) + end + + # Regression test for #1142 + it "uses the first available country if :default_country_id is set to an invalid value" do + Spree::Config[:default_country_id] = "0" + Spree::Address.default.country.should == Spree::Country.first + end + end + + context '#full_name' do + context 'both first and last names are present' do + let(:address) { stub_model(Spree::Address, :firstname => 'Michael', :lastname => 'Jackson') } + specify { address.full_name.should == 'Michael Jackson' } + end + + context 'first name is blank' do + let(:address) { stub_model(Spree::Address, :firstname => nil, :lastname => 'Jackson') } + specify { address.full_name.should == 'Jackson' } + end + + context 'last name is blank' do + let(:address) { stub_model(Spree::Address, :firstname => 'Michael', :lastname => nil) } + specify { address.full_name.should == 'Michael' } + end + + context 'both first and last names are blank' do + let(:address) { stub_model(Spree::Address, :firstname => nil, :lastname => nil) } + specify { address.full_name.should == '' } + end + + end + + context '#state_text' do + context 'state is blank' do + let(:address) { stub_model(Spree::Address, :state => nil, :state_name => 'virginia') } + specify { address.state_text.should == 'virginia' } + end + + context 'both name and abbr is present' do + let(:state) { stub_model(Spree::State, :name => 'virginia', :abbr => 'va') } + let(:address) { stub_model(Spree::Address, :state => state) } + specify { address.state_text.should == 'va' } + end + + context 'only name is present' do + let(:state) { stub_model(Spree::State, :name => 'virginia', :abbr => nil) } + let(:address) { stub_model(Spree::Address, :state => state) } + specify { address.state_text.should == 'virginia' } + end + end + + context "defines require_phone? helper method" do + let(:address) { stub_model(Spree::Address) } + specify { address.instance_eval{ require_phone? }.should be_true} + end +end diff --git a/spec/models/spree/shipping_rate_spec.rb b/spec/models/spree/shipping_rate_spec.rb new file mode 100644 index 0000000000..c66ffd62e9 --- /dev/null +++ b/spec/models/spree/shipping_rate_spec.rb @@ -0,0 +1,38 @@ +# encoding: utf-8 + +require 'spec_helper' + +describe Spree::ShippingRate do + let(:shipment) { create(:shipment) } + let(:shipping_method) { create(:shipping_method) } + let(:shipping_rate) { Spree::ShippingRate.new(:shipment => shipment, + :shipping_method => shipping_method, + :cost => 10.55) } + before { Spree::TaxRate.stub(:default => 0.05) } + + context "#display_price" do + context "when shipment includes VAT" do + before { Spree::Config[:shipment_inc_vat] = true } + it "displays the correct price" do + shipping_rate.display_price.to_s.should == "$11.08" # $10.55 * 1.05 == $11.08 + end + end + + context "when shipment does not include VAT" do + before { Spree::Config[:shipment_inc_vat] = false } + it "displays the correct price" do + shipping_rate.display_price.to_s.should == "$10.55" + end + end + + context "when the currency is JPY" do + let(:shipping_rate) { shipping_rate = Spree::ShippingRate.new(:cost => 205) + shipping_rate.stub(:currency => "JPY") + shipping_rate } + + it "displays the price in yen" do + shipping_rate.display_price.to_s.should == "¥205" + end + end + end +end