| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| module Admin::CheckoutsHelper | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| module Admin::LineItemsHelper | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| module Admin::NavigationHelper | ||
|
|
||
| # Make an admin tab that coveres one or more resources supplied by symbols | ||
| # Option hash may follow. Valid options are | ||
| # * :label to override link text, otherwise based on the first resource name (translated) | ||
| # * :route to override automatically determining the default route | ||
| # * :match_path as an alternative way to control when the tab is active, /products would match /admin/products, /admin/products/5/variants etc. | ||
| def tab(*args) | ||
| options = {:label => args.first.to_s} | ||
| if args.last.is_a?(Hash) | ||
| options = options.merge(args.pop) | ||
| end | ||
| options[:route] ||= "admin_#{args.first}" | ||
|
|
||
| destination_url = send("#{options[:route]}_path") | ||
|
|
||
| return("") unless url_options_authenticate?(ActionController::Routing::Routes.recognize_path(destination_url)) | ||
|
|
||
| ## if more than one form, it'll capitalize all words | ||
| label_with_first_letters_capitalized = t(options[:label]).gsub(/\b\w/){$&.upcase} | ||
|
|
||
| link = link_to(label_with_first_letters_capitalized, destination_url) | ||
|
|
||
| css_classes = [] | ||
|
|
||
| selected = if options[:match_path] | ||
| request.request_uri.starts_with?("/admin#{options[:match_path]}") | ||
| else | ||
| args.include?(@controller.controller_name.to_sym) | ||
| end | ||
| css_classes << 'selected' if selected | ||
|
|
||
| if options[:css_class] | ||
| css_classes << options[:css_class] | ||
| end | ||
| content_tag('li', link, :class => css_classes.join(' ')) | ||
| end | ||
|
|
||
|
|
||
| def link_to_new(resource) | ||
| link_to_with_icon('add', t("new"), edit_object_url(resource)) | ||
| end | ||
|
|
||
| def link_to_edit(resource) | ||
| link_to_with_icon('edit', t("edit"), edit_object_url(resource)) | ||
| end | ||
|
|
||
| def link_to_clone(resource) | ||
| link_to_with_icon('exclamation', t("clone"), clone_admin_product_url(resource)) | ||
| end | ||
|
|
||
| def link_to_delete(resource, options = {}) | ||
| options.assert_valid_keys(:url, :caption, :title, :dataType, :success) | ||
|
|
||
| options.reverse_merge! :url => object_url(resource) unless options.key? :url | ||
| options.reverse_merge! :caption => t('are_you_sure') | ||
| options.reverse_merge! :title => t('confirm_delete') | ||
| options.reverse_merge! :dataType => 'script' | ||
| options.reverse_merge! :success => "function(r){ jQuery('##{dom_id resource}').fadeOut('hide'); }" | ||
|
|
||
| #link_to_with_icon('delete', t("delete"), object_url(resource), :confirm => t('are_you_sure'), :method => :delete ) | ||
| link_to_function icon("delete") + ' ' + t("delete"), "jConfirm('#{options[:caption]}', '#{options[:title]}', function(r) { | ||
| if(r){ | ||
| jQuery.ajax({ | ||
| type: 'POST', | ||
| url: '#{options[:url]}', | ||
| data: ({_method: 'delete', authenticity_token: AUTH_TOKEN}), | ||
| dataType:'#{options[:dataType]}', | ||
| success: #{options[:success]} | ||
| }); | ||
| } | ||
| });" | ||
| end | ||
|
|
||
| def link_to_with_icon(icon_name, text, url, options = {}) | ||
| options[:class] = (options[:class].to_s + " icon_link").strip | ||
| link_to(icon(icon_name) + ' ' + text, url, options) | ||
| end | ||
|
|
||
| def icon(icon_name) | ||
| image_tag("/images/admin/icons/#{icon_name}.png") | ||
| end | ||
|
|
||
| def button(text, icon = nil, button_type = 'submit', options={}) | ||
| content_tag('button', content_tag('span', text), options.merge(:type => button_type)) | ||
| end | ||
|
|
||
| def button_link_to(text, url, html_options = {}) | ||
| link_to(text_for_button_link(text, html_options), url, html_options_for_button_link(html_options)) | ||
| end | ||
|
|
||
| def button_link_to_function(text, function, html_options = {}) | ||
| link_to_function(text_for_button_link(text, html_options), function, html_options_for_button_link(html_options)) | ||
| end | ||
|
|
||
| def button_link_to_remote(text, options, html_options = {}) | ||
| link_to_remote(text_for_button_link(text, html_options), options, html_options_for_button_link(html_options)) | ||
| end | ||
|
|
||
| def link_to_remote(name, options = {}, html_options = {}) | ||
| options[:before] ||= "jQuery(this).parent().hide(); jQuery('#busy_indicator').show();" | ||
| options[:complete] ||= "jQuery('#busy_indicator').hide()" | ||
| link_to_function(name, remote_function(options), html_options || options.delete(:html)) | ||
| end | ||
|
|
||
| def text_for_button_link(text, html_options) | ||
| s = '' | ||
| if html_options[:icon] | ||
| s << icon(html_options.delete(:icon)) + ' ' | ||
| end | ||
| s << text | ||
| content_tag('span', s) | ||
| end | ||
|
|
||
| def html_options_for_button_link(html_options) | ||
| options = {:class => 'button'}.update(html_options) | ||
| end | ||
|
|
||
| def configurations_menu_item(link_text, url, description = '') | ||
| %(<tr> | ||
| <td>#{link_to(link_text, url)}</td> | ||
| <td>#{description}</td> | ||
| </tr> | ||
| ) | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,35 @@ | ||
| module Admin::OrdersHelper | ||
|
|
||
| # Renders all the txn partials that may have been specified in the extensions | ||
| def render_txn_partials(order) | ||
| @txn_partials.inject("") do |extras, partial| | ||
| extras += render :partial => partial, :locals => {:payment => order} | ||
| end | ||
| end | ||
|
|
||
| # Renders all the extension partials that may have been specified in the extensions | ||
| def event_links | ||
| links = [] | ||
| @order_events.sort.each do |event| | ||
| if @order.send("can_#{event}?") | ||
| links << button_link_to(t(event), fire_admin_order_url(@order, :e => event), | ||
| { :method => :put, :confirm => t("order_sure_want_to", :event => t(event)) }) | ||
| end | ||
| end | ||
| links.join(' ') | ||
| end | ||
|
|
||
| def generate_html(form_builder, method, options = {}) | ||
| options[:object] ||= form_builder.object.class.reflect_on_association(method).klass.new | ||
| options[:partial] ||= method.to_s.singularize | ||
| options[:form_builder_local] ||= :f | ||
|
|
||
| form_builder.fields_for(method, options[:object], :child_index => 'NEW_RECORD') do |f| | ||
| render(:partial => options[:partial], :locals => { options[:form_builder_local] => f }) | ||
| end | ||
| end | ||
|
|
||
| def generate_template(form_builder, method, options = {}) | ||
| escape_javascript generate_html(form_builder, method, options) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| module Admin::PaymentsHelper | ||
| def payment_method_name(payment) | ||
| # hack to allow us to retrieve the name of a "deleted" payment method | ||
| id = payment.payment_method_id | ||
| PaymentMethod.find_with_destroyed(id).name | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| module Admin::ReturnAuthorizationsHelper | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,35 @@ | ||
| module CheckoutsHelper | ||
|
|
||
| def checkout_progress | ||
| steps = Checkout.state_names.reject { |n| n == "complete" }.map do |state| | ||
| text = t("checkout_steps.#{state}") | ||
|
|
||
| css_classes = [] | ||
| current_index = Checkout.state_names.index(@checkout.state) | ||
| state_index = Checkout.state_names.index(state) | ||
|
|
||
| if state_index < current_index | ||
| css_classes << 'completed' | ||
| text = link_to text, edit_order_checkout_url(@order, :step => state) | ||
| end | ||
|
|
||
| css_classes << 'next' if state_index == current_index + 1 | ||
| css_classes << 'current' if state == @checkout.state | ||
| css_classes << 'first' if state_index == 0 | ||
| css_classes << 'last' if state_index == Checkout.state_names.length - 1 | ||
|
|
||
| # It'd be nice to have separate classes but combining them with a dash helps out for IE6 which only sees the last class | ||
| content_tag('li', content_tag('span', text), :class => css_classes.join('-')) | ||
| end | ||
| content_tag('ol', steps.join("\n"), :class => 'progress-steps', :id => "checkout-step-#{@checkout.state}") + '<br clear="left" />' | ||
| end | ||
|
|
||
| def billing_firstname | ||
| @checkout.bill_address.firstname rescue '' | ||
| end | ||
|
|
||
| def billing_lastname | ||
| @checkout.bill_address.lastname rescue '' | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| module HookHelper | ||
|
|
||
| # Allow hooks to be used in views like this: | ||
| # | ||
| # <%= hook :some_hook %> | ||
| # | ||
| # <% hook :some_hook do %> | ||
| # <p>Some HTML</p> | ||
| # <% end %> | ||
| # | ||
| def hook(hook_name, locals = {}, &block) | ||
| content = block_given? ? capture(&block) : '' | ||
| result = Spree::ThemeSupport::Hook.render_hook(hook_name, content, self, locals) | ||
| block_given? ? concat(result.to_s) : result | ||
| end | ||
|
|
||
| def locals_hash(names, binding) | ||
| names.inject({}) {|memo, key| memo[key.to_sym] = eval(key, binding); memo} | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| module TrackersHelper | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| module UsersHelper | ||
| def password_style(user) | ||
| show_openid ? "display:none" : "" | ||
| end | ||
| def openid_style(user) | ||
| show_openid ? "": "display:none" | ||
| end | ||
|
|
||
| private | ||
| def show_openid | ||
| Spree::Config[:allow_openid] and @user.openid_identifier | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| class RedirectLegacyProductUrl | ||
|
|
||
| def self.call(env) | ||
| if env["PATH_INFO"] =~ %r{/t/.+/p/(.+)} | ||
| return [301, {'Location'=> "/products/#{$1}" }, []] | ||
| end | ||
| [404, {"Content-Type" => "text/html"}, "Not Found"] | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # Allow the metal piece to run in isolation | ||
| require(File.dirname(__FILE__) + "/../../config/environment") unless defined?(Rails) | ||
|
|
||
| # Make redirects for SEO needs | ||
| class SeoAssist | ||
|
|
||
| def self.call(env) | ||
| request = Rack::Request.new(env) | ||
| params = request.params | ||
| taxon_id = params['taxon'] | ||
| if !taxon_id.blank? && !taxon_id.is_a?(Hash) && @taxon = Taxon.find(taxon_id) | ||
| params.delete('taxon') | ||
| query = build_query(params) | ||
| permalink = @taxon.permalink[0...-1] #ensures no trailing / for taxon urls | ||
| return [301, { 'Location'=> "/t/#{permalink}?#{query}" }, []] | ||
| elsif env["PATH_INFO"] =~ /^\/(t|products)(\/\S+)?\/$/ | ||
| #ensures no trailing / for taxon and product urls | ||
| query = build_query(params) | ||
| new_location = env["PATH_INFO"][0...-1] | ||
| new_location += '?' + query unless query.blank? | ||
| return [301, { 'Location'=> new_location }, []] | ||
| end | ||
| [404, {"Content-Type" => "text/html"}, "Not Found"] | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def self.build_query(params) | ||
| params.map { |k, v| | ||
| if v.class == Array | ||
| build_query(v.map { |x| ["#{k}[]", x] }) | ||
| else | ||
| k + "=" + Rack::Utils.escape(v) | ||
| end | ||
| }.join("&") | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,37 +1,76 @@ | ||
| # *Adjustment* model is a super class of all models that change order total. | ||
| # | ||
| # All adjustments associated with order are added to _item_total_. | ||
| # charges always have positive amount (they increase total), | ||
| # credits always have negative totals as they decrease the order total. | ||
| # | ||
| # h3. Basic usage | ||
| # | ||
| # Before checkout is completed, adjustments are recalculated each time #amount is called, after checkout | ||
| # all adjustments are frozen, and can be later modified, but will not be automatically recalculated. | ||
| # When displaying or using Adjustments #amount method should be always used, #update_adjustment | ||
| # and #calculate_adjustment should be considered private, and might be subject to change before 1.0. | ||
| # | ||
| # h3. Creating new Charge and Credit types | ||
| # | ||
| # When creating new type of Charge or Credit, you can either use default behaviour of Adjustment | ||
| # or override #calculate_adjustment and #applicable? to provide your own custom behaviour. | ||
| # | ||
| # All custom credits and charges should inherit either from Charge or Credit classes, | ||
| # and they name *MUST* end with either _Credit_ or _Charge_, so allowed names are for example: | ||
| # _CouponCredit_, _WholesaleCredit_ or _CodCharge_. | ||
| # | ||
| # By default Adjustment expects _adjustment_source_ to provide #calculator method | ||
| # to which _adjustment_source_ will be passed as parameter (this way adjustment source can provide | ||
| # calculator instance that is shared with other adjustment sources, or even singleton calculator). | ||
| # | ||
| class Adjustment < ActiveRecord::Base | ||
| acts_as_list :scope => :order | ||
|
|
||
| belongs_to :order | ||
| belongs_to :adjustment_source, :polymorphic => true | ||
|
|
||
| validates_presence_of :description | ||
| validates_numericality_of :amount, :allow_nil => true | ||
|
|
||
| # Tries to calculate the adjustment, returns nil if adjustment could not be calculated. | ||
| # raises RuntimeError if adjustment source didn't provide the caculator. | ||
| def calculate_adjustment | ||
| if adjustment_source | ||
| calc = adjustment_source.respond_to?(:calculator) && adjustment_source.calculator | ||
| calc.compute(adjustment_source) if calc | ||
| end | ||
| end | ||
|
|
||
| # Checks if adjustment is applicable for the order. | ||
| # Should return _true_ if adjustment should be preserved and _false_ if removed. | ||
| # Default behaviour is to preserve adjustment if amount is present and non 0. | ||
| # Might (and should) be overriden in descendant classes, to provide adjustment specific behaviour. | ||
| def applicable? | ||
| amount && amount != 0 | ||
| end | ||
|
|
||
| # Retrieves amount of adjustment, if order hasn't been completed and amount is not set tries to calculate new amount. | ||
| def amount | ||
| db_amount = read_attribute(:amount) | ||
| if (order && order.checkout_complete) | ||
| result = db_amount | ||
| elsif db_amount && db_amount != 0 | ||
| result = db_amount | ||
| else | ||
| result = self.calculate_adjustment | ||
| end | ||
| return(result || 0) | ||
| end | ||
|
|
||
| def update_amount | ||
| new_amount = calculate_adjustment | ||
| update_attribute(:amount, new_amount) if new_amount | ||
| end | ||
|
|
||
| def secondary_type; type; end | ||
|
|
||
| class << self | ||
| public :subclasses | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| class BillingIntegration < PaymentMethod | ||
|
|
||
| validates_presence_of :name, :type | ||
|
|
||
| preference :server, :string, :default => 'test' | ||
| preference :test_mode, :boolean, :default => true | ||
|
|
||
| def provider | ||
| integration_options = options | ||
| ActiveMerchant::Billing::Base.integration_mode = integration_options[:server] | ||
| integration_options = options | ||
| integration_options[:test] = true if integration_options[:test_mode] | ||
| @provider ||= provider_class.new(integration_options) | ||
| end | ||
|
|
||
| def options | ||
| options_hash = {} | ||
| self.preferences.each do |key,value| | ||
| options_hash[key.to_sym] = value | ||
| end | ||
| options_hash | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| class Calculator::PriceBucket < Calculator | ||
| preference :minimal_amount, :decimal, :default => 0 | ||
| preference :normal_amount, :decimal, :default => 0 | ||
| preference :discount_amount, :decimal, :default => 0 | ||
|
|
||
| def self.description | ||
| I18n.t("price_bucket") | ||
| end | ||
|
|
||
| def self.register | ||
| super | ||
| Coupon.register_calculator(self) | ||
| ShippingMethod.register_calculator(self) | ||
| ShippingRate.register_calculator(self) | ||
| end | ||
|
|
||
| # as object we always get line items, as calculable we have Coupon, ShippingMethod or ShippingRate | ||
| def compute(object) | ||
| if object.is_a?(Array) | ||
| base = object.map{ |o| o.respond_to?(:amount) ? o.amount : o.to_d }.sum | ||
| else | ||
| base = object.respond_to?(:amount) ? object.amount : object.to_d | ||
| end | ||
|
|
||
| if base >= self.preferred_minimal_amount | ||
| self.preferred_normal_amount | ||
| else | ||
| self.preferred_discount_amount | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,37 +1,19 @@ | ||
| class Charge < Adjustment | ||
| before_save :ensure_positive_amount | ||
|
|
||
| private | ||
| # Ensures Charge has always positive amount. | ||
| # | ||
| # Amount should be modified ONLY when it's going to be saved to the database | ||
| # (read_attribute returns value) | ||
| # | ||
| # WARNING! It does not protect from Credits getting negative amounts while | ||
| # amount is autocalculated! Descending classes should ensure amount is always | ||
| # negative in their calculate_adjustment methods. | ||
| # This method should be threated as a last resort for keeping integrity of adjustments | ||
| def ensure_positive_amount | ||
| if (db_amount = read_attribute(:amount)) && db_amount < 0 | ||
| self.amount *= -1 | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,46 +1,140 @@ | ||
| class Checkout < ActiveRecord::Base | ||
| extend ValidationGroup::ActiveRecord::ActsMethods | ||
|
|
||
| before_update :check_addresses_on_duplication, :if => "!ship_address.nil? && !bill_address.nil?" | ||
| after_save :process_coupon_code | ||
| after_save :update_order_shipment | ||
| before_validation :clone_billing_address, :if => "@use_billing" | ||
|
|
||
| belongs_to :order | ||
| belongs_to :bill_address, :foreign_key => "bill_address_id", :class_name => "Address" | ||
| belongs_to :ship_address, :foreign_key => "ship_address_id", :class_name => "Address" | ||
| belongs_to :shipping_method | ||
| has_many :payments, :as => :payable | ||
|
|
||
| accepts_nested_attributes_for :bill_address | ||
| accepts_nested_attributes_for :ship_address | ||
| accepts_nested_attributes_for :payments | ||
|
|
||
| attr_accessor :coupon_code | ||
| attr_accessor :use_billing | ||
|
|
||
| validates_presence_of :order_id, :shipping_method_id | ||
| validates_format_of :email, :with => /^\S+@\S+\.\S+$/, :allow_blank => true | ||
|
|
||
| validation_group :register, :fields => ["email"] | ||
|
|
||
| validation_group :address, :fields=>["bill_address.firstname", "bill_address.lastname", "bill_address.phone", | ||
| "bill_address.zipcode", "bill_address.state", "bill_address.lastname", | ||
| "bill_address.address1", "bill_address.city", "bill_address.statename", | ||
| "bill_address.zipcode", "ship_address.firstname", "ship_address.lastname", "ship_address.phone", | ||
| "ship_address.zipcode", "ship_address.state", "ship_address.lastname", | ||
| "ship_address.address1", "ship_address.city", "ship_address.statename", | ||
| "ship_address.zipcode"] | ||
| validation_group :delivery, :fields => ["shipping_method_id"] | ||
|
|
||
| def completed_at | ||
| order.completed_at | ||
| end | ||
|
|
||
| # This is a temporary Shipment object for the purpose of showing available shiping rates in delivery step of checkout | ||
| def shipment | ||
| @shipment ||= Shipment.new(:order => order, :address => ship_address) | ||
| end | ||
|
|
||
|
|
||
| alias :ar_valid? :valid? | ||
| def valid? | ||
| # will perform partial validation when @checkout.enabled_validation_group :step is called | ||
| result = ar_valid? | ||
| return result unless validation_group_enabled? | ||
|
|
||
| relevant_errors = errors.select { |attr, msg| @current_validation_fields.include?(attr) } | ||
| errors.clear | ||
| relevant_errors.each { |attr, msg| errors.add(attr, msg) } | ||
| relevant_errors.empty? | ||
| end | ||
|
|
||
| # checkout state machine (see http://github.com/pluginaweek/state_machine/tree/master for details) | ||
| state_machine :initial => 'address' do | ||
| after_transition :to => 'complete', :do => :complete_order | ||
| before_transition :to => 'complete', :do => :process_payment | ||
| event :next do | ||
| transition :to => 'delivery', :from => 'address' | ||
| transition :to => 'payment', :from => 'delivery' | ||
| transition :to => 'confirm', :from => 'payment' | ||
| transition :to => 'complete', :from => 'confirm' | ||
| end | ||
| end | ||
| def self.state_names | ||
| state_machine.states.by_priority.map(&:name) | ||
| end | ||
|
|
||
| def shipping_methods | ||
| return [] unless ship_address | ||
| ShippingMethod.all_available(order) | ||
| end | ||
|
|
||
| def payment | ||
| payments.first | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def check_addresses_on_duplication | ||
| if order.user | ||
| if order.user.ship_address.nil? | ||
| order.user.update_attribute(:ship_address, ship_address) | ||
| elsif ship_address.same_as?(order.user.ship_address) | ||
| #self.ship_address = order.user.ship_address | ||
| end | ||
| if order.user.bill_address.nil? | ||
| order.user.update_attribute(:bill_address, bill_address) | ||
| elsif bill_address.same_as?(order.user.bill_address) | ||
| #self.bill_address = order.user.bill_address | ||
| end | ||
| end | ||
| true | ||
| end | ||
|
|
||
| def clone_billing_address | ||
| if self.ship_address.nil? | ||
| self.ship_address = bill_address.clone | ||
| else | ||
| self.ship_address.attributes = bill_address.attributes.except("id", "updated_at", "created_at") | ||
| end | ||
| true | ||
| end | ||
|
|
||
| def complete_order | ||
| order.complete! | ||
| order.pay! if Spree::Config[:auto_capture] | ||
| end | ||
|
|
||
| def process_payment | ||
| return if order.payments.total == order.total | ||
| payments.each(&:process!) | ||
| end | ||
|
|
||
| def process_coupon_code | ||
| return unless @coupon_code and coupon = Coupon.find_by_code(@coupon_code.upcase) | ||
| coupon.create_discount(order) | ||
| # recalculate order totals | ||
| order.save | ||
| end | ||
|
|
||
| # list of countries available for checkout | ||
| def self.countries | ||
| return Country.all unless zone = Zone.find_by_name(Spree::Config[:checkout_zone]) | ||
| zone.country_list | ||
| end | ||
|
|
||
| def update_order_shipment | ||
| if order.shipment | ||
| order.shipment.shipping_method = shipping_method | ||
| order.shipment.address_id = ship_address.id unless ship_address.nil? | ||
| order.shipment.save | ||
| end | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| class CouponCredit < Credit | ||
| named_scope :with_order, :conditions => "order_id IS NOT NULL" | ||
|
|
||
| def calculate_adjustment | ||
| adjustment_source && calculate_coupon_credit | ||
| end | ||
|
|
||
| # Checks if credit is still applicable to order | ||
| # If source of adjustment is credit, it checks if it's still valid | ||
| def applicable? | ||
| adjustment_source && adjustment_source.eligible?(order) && super | ||
| end | ||
|
|
||
| # Calculates credit for the coupon. | ||
| # | ||
| # If coupon amount exceeds the order item_total, credit is adjusted. | ||
| # | ||
| # Always returns negative non positive. | ||
| def calculate_coupon_credit | ||
| return 0 if order.line_items.empty? | ||
| amount = adjustment_source.calculator.compute(order.line_items).abs | ||
| amount = order.item_total if amount > order.item_total | ||
| -1 * amount | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,24 +1,19 @@ | ||
| class Credit < Adjustment | ||
| before_save :ensure_negative_amount | ||
|
|
||
| private | ||
| # Ensures Charge always has negative amount. | ||
| # | ||
| # Amount shold be modified ONLY when it's going to be saved to the database | ||
| # (read_attribute returns value) | ||
| # | ||
| # WARNING! It does not protect from Credits getting positive amounts while | ||
| # amount is autocalculated! Descending classes should ensure amount is always | ||
| # negative in their calculate_adjustment methods | ||
| # This method should be threated as a last resort for keeping integrity of adjustments | ||
| def ensure_negative_amount | ||
| if (db_amount = read_attribute(:amount)) && db_amount > 0 | ||
| self.amount *= -1 | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,146 +1,74 @@ | ||
| class Creditcard < ActiveRecord::Base | ||
| has_many :payments, :as => :source | ||
|
|
||
| before_save :set_last_digits | ||
|
|
||
| validates_numericality_of :month, :integer => true | ||
| validates_numericality_of :year, :integer => true | ||
| validates_presence_of :number, :unless => :has_payment_profile?, :on => :create | ||
| validates_presence_of :verification_value, :unless => :has_payment_profile?, :on => :create | ||
|
|
||
|
|
||
| def process!(payment) | ||
| begin | ||
| if Spree::Config[:auto_capture] | ||
| purchase(payment.amount.to_f, payment) | ||
| payment.finalize! | ||
| else | ||
| authorize(payment.amount.to_f, payment) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def set_last_digits | ||
| self.last_digits ||= number.to_s.length <= 4 ? number : number.to_s.slice(-4..-1) | ||
| end | ||
| def name? | ||
| first_name? && last_name? | ||
| end | ||
|
|
||
| def first_name? | ||
| !self.first_name.blank? | ||
| end | ||
|
|
||
| def last_name? | ||
| !self.last_name.blank? | ||
| end | ||
|
|
||
| def name | ||
| "#{self.first_name} #{self.last_name}" | ||
| end | ||
|
|
||
| def verification_value? | ||
| !verification_value.blank? | ||
| end | ||
|
|
||
| # Show the card number, with all but last 4 numbers replace with "X". (XXXX-XXXX-XXXX-4338) | ||
| def display_number | ||
| "XXXX-XXXX-XXXX-#{last_digits}" | ||
| end | ||
|
|
||
| alias :attributes_with_quotes_default :attributes_with_quotes | ||
|
|
||
|
|
||
|
|
||
|
|
||
| private | ||
|
|
||
|
|
||
| # Override default behavior of Rails attr_readonly so that its never written to the database (not even on create) | ||
| def attributes_with_quotes(include_primary_key = true, include_readonly_attributes = true, attribute_names = @attributes.keys) | ||
| attributes_with_quotes_default(include_primary_key, false, attribute_names) | ||
| end | ||
|
|
||
| def remove_readonly_attributes(attributes) | ||
| if self.class.readonly_attributes.present? | ||
| attributes.delete_if { |key, value| self.class.readonly_attributes.include?(key.gsub(/\(.+/,"")) } | ||
| end | ||
| # extra logic for sanitizing the number and verification value based on preferences | ||
| attributes.delete_if { |key, value| key == "number" and !Spree::Config[:store_cc] } | ||
| attributes.delete_if { |key, value| key == "verification_value" and !Spree::Config[:store_cvv] } | ||
| end | ||
| end | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| class CreditcardTxn < Transaction | ||
|
|
||
| enumerable_constant :txn_type, :constants => [:authorize, :capture, :purchase, :void, :credit] | ||
|
|
||
| def txn_type_name | ||
| TxnType.from_value(txn_type) | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,71 +1,88 @@ | ||
| class InventoryUnit < ActiveRecord::Base | ||
| belongs_to :variant | ||
| belongs_to :order | ||
| belongs_to :shipment | ||
| belongs_to :return_authorization | ||
|
|
||
| named_scope :retrieve_on_hand, lambda {|variant, quantity| {:conditions => ["state = 'on_hand' AND variant_id = ?", variant], :limit => quantity}} | ||
|
|
||
| # state machine (see http://github.com/pluginaweek/state_machine/tree/master for details) | ||
| state_machine :initial => 'on_hand' do | ||
| event :fill_backorder do | ||
| transition :to => 'sold', :from => 'backordered' | ||
| end | ||
| event :ship do | ||
| transition :to => 'shipped', :if => :allow_ship? #, :from => 'sold' | ||
| end | ||
| # TODO: add backorder state and relevant transitions | ||
| end | ||
|
|
||
| # destroy the specified number of on hand inventory units | ||
| def self.destroy_on_hand(variant, quantity) | ||
| inventory = self.retrieve_on_hand(variant, quantity) | ||
| inventory.each do |unit| | ||
| unit.destroy | ||
| end | ||
| end | ||
|
|
||
| # create the specified number of on hand inventory units | ||
| def self.create_on_hand(variant, quantity) | ||
| quantity.times do | ||
| self.create(:variant => variant, :state => 'on_hand') | ||
| end | ||
| end | ||
|
|
||
| # grab the appropriate units from inventory, mark as sold and associate with the order | ||
| def self.sell_units(order) | ||
| out_of_stock_items = [] | ||
| order.line_items.each do |line_item| | ||
| variant = line_item.variant | ||
| quantity = line_item.quantity | ||
|
|
||
| # mark all of these units as sold and associate them with this order | ||
| remaining_quantity = variant.count_on_hand - quantity | ||
| if (remaining_quantity >= 0) | ||
| quantity.times do | ||
| order.inventory_units.create(:variant => variant, :state => "sold") | ||
| end | ||
| variant.update_attribute(:count_on_hand, remaining_quantity) | ||
| else | ||
| (quantity + remaining_quantity).times do | ||
| order.inventory_units.create(:variant => variant, :state => "sold") | ||
| end | ||
| if Spree::Config[:allow_backorders] | ||
| (-remaining_quantity).times do | ||
| order.inventory_units.create(:variant => variant, :state => "backordered") | ||
| end | ||
| else | ||
| line_item.update_attribute(:quantity, quantity + remaining_quantity) | ||
| out_of_stock_items << {:line_item => line_item, :count => -remaining_quantity} | ||
| end | ||
| variant.update_attribute(:count_on_hand, 0) | ||
| end | ||
| end | ||
| out_of_stock_items | ||
| end | ||
|
|
||
| def can_restock? | ||
| %w(sold shipped).include?(state) | ||
| end | ||
|
|
||
| def restock! | ||
| variant.update_attribute(:count_on_hand, variant.count_on_hand + 1) | ||
| delete | ||
| end | ||
|
|
||
| # find the specified quantity of units with the specified status | ||
| def self.find_by_status(variant, quantity, status) | ||
| variant.inventory_units.find(:all, | ||
| :conditions => ['status = ? ', status], | ||
| :limit => quantity) | ||
| end | ||
|
|
||
| private | ||
| def allow_ship? | ||
| Spree::Config[:allow_backorder_shipping] || (state == 'ready_to_ship') | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| class OptionType < ActiveRecord::Base | ||
| has_many :option_values, :order => :position, :dependent => :destroy, :attributes => true | ||
| has_many :product_option_types, :dependent => :destroy | ||
| has_and_belongs_to_many :prototypes | ||
| validates_presence_of [:name, :presentation] | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,102 @@ | ||
| class Payment < ActiveRecord::Base | ||
| belongs_to :payable, :polymorphic => true | ||
| belongs_to :source, :polymorphic => true | ||
| belongs_to :payment_method | ||
|
|
||
| has_many :transactions | ||
| alias :txns :transactions | ||
|
|
||
| after_save :create_payment_profile, :if => :payment_profiles_supported? | ||
| after_save :check_payments, :if => :order_payment? | ||
| after_destroy :check_payments, :if => :order_payment? | ||
|
|
||
| accepts_nested_attributes_for :source | ||
|
|
||
| validate :amount_is_valid_for_outstanding_balance_or_credit, :if => :order_payment? | ||
| validates_presence_of :payment_method, :if => Proc.new { |payable| payable.is_a? Checkout } | ||
|
|
||
| named_scope :from_creditcard, :conditions => {:source_type => 'Creditcard'} | ||
|
|
||
| def order | ||
| payable.is_a?(Order) ? payable : payable.order | ||
| end | ||
|
|
||
| # With nested attributes, Rails calls build_[association_name] for the nested model which won't work for a polymorphic association | ||
| def build_source(params) | ||
| if payment_method and payment_method.payment_source_class | ||
| self.source = payment_method.payment_source_class.new(params) | ||
| end | ||
| end | ||
|
|
||
| def process! | ||
| source.process!(self) if source and source.respond_to?(:process!) | ||
| end | ||
|
|
||
| def can_finalize? | ||
| !finalized? | ||
| end | ||
|
|
||
| def finalize! | ||
| return unless can_finalize? | ||
| source.finalize!(self) if source and source.respond_to?(:finalize!) | ||
| self.payable = payable.order | ||
| save! | ||
| payable.save! | ||
| end | ||
|
|
||
| def finalized? | ||
| payable.is_a?(Order) | ||
| end | ||
|
|
||
| def actions | ||
| return [] unless source and source.respond_to? :actions | ||
| source.actions.select { |action| !source.respond_to?("can_#{action}?") or source.send("can_#{action}?", self) } | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def check_payments | ||
| return unless order.checkout_complete | ||
| #sorting by created_at.to_f to ensure millisecond percsision, plus ID - just in case | ||
| events = order.state_events.sort_by { |e| [e.created_at.to_f, e.id] }.reverse | ||
|
|
||
|
|
||
| if order.returnable_units.nil? && order.return_authorizations.size >0 | ||
| order.return! | ||
| elsif events.present? and %w(over_paid under_paid).include?(events.first.name) | ||
| events.each do |event| | ||
| if %w(shipped paid new).include?(event.previous_state) | ||
| order.update_attribute("state", event.previous_state) | ||
| return | ||
| end | ||
| end | ||
| elsif order.payment_total >= order.total | ||
| order.pay! | ||
| end | ||
| end | ||
|
|
||
| def amount_is_valid_for_outstanding_balance_or_credit | ||
| if amount < 0 | ||
| if amount.abs > order.outstanding_credit | ||
| errors.add(:amount, "Is greater than the credit owed (#{order.outstanding_credit})") | ||
| end | ||
| else | ||
| if amount > order.outstanding_balance | ||
| errors.add(:amount, "Is greater than the outstanding balance (#{order.outstanding_balance})") | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def order_payment? | ||
| payable_type == "Order" | ||
| end | ||
|
|
||
| def payment_profiles_supported? | ||
| source && source.payment_gateway && source.payment_gateway.payment_profiles_supported? | ||
| end | ||
|
|
||
| def create_payment_profile | ||
| source.create_payment_profile | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| class PaymentMethod < ActiveRecord::Base | ||
| default_scope :conditions => {:deleted_at => nil} | ||
|
|
||
| @provider = nil | ||
| @@providers = Set.new | ||
| def self.register | ||
| @@providers.add(self) | ||
| end | ||
|
|
||
| def self.providers | ||
| @@providers.to_a | ||
| end | ||
|
|
||
| def provider_class | ||
| raise "You must implement provider_class method for this gateway." | ||
| end | ||
|
|
||
| # The class that will process payments for this payment type, used for @payment.source | ||
| # e.g. Creditcard in the case of a the Gateway payment type | ||
| # nil means the payment method doesn't require a source e.g. check | ||
| def payment_source_class | ||
| raise "You must implement payment_source_class method for this gateway." | ||
| end | ||
|
|
||
| def self.available | ||
| PaymentMethod.all.select { |p| p.active and (p.environment == ENV['RAILS_ENV'] or p.environment.blank?) } | ||
| end | ||
|
|
||
| def self.active? | ||
| self.count(:conditions => {:type => self.to_s, :environment => RAILS_ENV, :active => true}) > 0 | ||
| end | ||
|
|
||
| def method_type | ||
| type.demodulize.downcase | ||
| end | ||
|
|
||
| def destroy | ||
| self.update_attribute(:deleted_at, Time.now.utc) | ||
| end | ||
|
|
||
| def self.find_with_destroyed *args | ||
| self.with_exclusive_scope { find(*args) } | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| class PaymentMethod::Check < PaymentMethod | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| # *ProductGroups* are used for creating and managing sets of products. | ||
| # Product group can be either anonymous(adhoc) or named. | ||
| # | ||
| # Anonymous Product groups are created by combining product scopes generated from url | ||
| # in 2 formats: | ||
| # | ||
| # /t/*taxons/s/name_of_scope/comma_separated_arguments/name_of_scope_that_doesn_take_any//order | ||
| # */s/name_of_scope/comma_separated_arguments/name_of_scope_that_doesn_take_any//order | ||
| # | ||
| # Named product groups can be created from anonymous ones, lub from another named scope | ||
| # (using ProductGroup.from_url method). | ||
| # Named product groups have pernament urls, that don't change even after changes | ||
| # to scopes are made, and come in two types. | ||
| # | ||
| # /t/*taxons/pg/named_product_group | ||
| # */pg/named_product_group | ||
| # | ||
| # first one is used for combining named scope with taxons, named product group can | ||
| # have #in_taxon or #taxons_name_eq scope defined, result should combine both | ||
| # and return products that exist in both taxons. | ||
| # | ||
| # ProductGroup#dynamic_products returns chain of named scopes generated from order and | ||
| # product scopes. So you can do counting, calculations etc, on resulted set of products, | ||
| # without retriving all records. | ||
| # | ||
| # ProductGroup operates on named scopes defined for product in Scopes::Product, | ||
| # or generated automatically by Searchlogic | ||
| # | ||
| class ProductGroup < ActiveRecord::Base | ||
| validates_presence_of :name | ||
| validates_associated :product_scopes | ||
|
|
||
| before_save :set_permalink | ||
| after_save :update_memberships | ||
|
|
||
| has_and_belongs_to_many :cached_products, :class_name => "Product" | ||
| # name | ||
| has_many :product_scopes | ||
| accepts_nested_attributes_for :product_scopes | ||
|
|
||
| # Testing utility: creates new *ProductGroup* from search permalink url. | ||
| # Follows conventions for accessing PGs from URLs, as decoded in routes | ||
| def self.from_url(url) | ||
| pg = nil; | ||
| case url | ||
| when /\/t\/(.+?)\/s\/(.+)/ then taxons = $1; attrs = $2; | ||
| when /\/t\/(.+?)\/pg\/(.+)/ then taxons = $1; pg_name = $2; | ||
| when /(.*?)\/s\/(.+)/ then attrs = $2; | ||
| when /(.*?)\/pg\/(.+)/ then pg_name = $2; | ||
| else return(nil) | ||
| end | ||
|
|
||
| if pg_name && opg = ProductGroup.find_by_permalink(pg_name) | ||
| pg = new.from_product_group(opg) | ||
| elsif attrs | ||
| attrs = url.split("/") | ||
| pg = new.from_route(attrs) | ||
| end | ||
| taxon = taxons && taxons.split("/").last | ||
| pg.add_scope("in_taxon", taxon) if taxon | ||
|
|
||
| pg | ||
| end | ||
|
|
||
| def from_product_group(opg) | ||
| self.product_scopes = opg.product_scopes.map{|ps| | ||
| ps = ps.clone; | ||
| ps.product_group_id = nil; | ||
| ps.product_group = self; | ||
| ps | ||
| } | ||
| self | ||
| end | ||
|
|
||
| def from_route(attrs) | ||
| self.order_scope = attrs.pop if attrs.length % 2 == 1 | ||
| attrs.each_slice(2) do |scope| | ||
| next unless Product.condition?(scope.first) | ||
| add_scope(scope.first, scope.last.split(",")) | ||
| end | ||
| self | ||
| end | ||
|
|
||
| def from_search(search_hash) | ||
| search_hash.each_pair do |scope_name, scope_attribute| | ||
| add_scope(scope_name, scope_attribute) | ||
| end | ||
|
|
||
| self | ||
| end | ||
|
|
||
| def add_scope(scope_name, arguments=[]) | ||
| self.product_scopes << ProductScope.new({ | ||
| :name => scope_name.to_s, | ||
| :arguments => [*arguments] | ||
| }) | ||
| self | ||
| end | ||
|
|
||
| def apply_on(scopish, use_order = true) | ||
| # There's bug in AR, it doesn't merge :order, instead it takes order | ||
| # from first nested_scope so we have to apply ordering FIRST. | ||
| # see #2253 on rails LH | ||
| base_product_scope = scopish | ||
| if use_order && !self.order_scope.blank? && Product.condition?(self.order_scope) | ||
| base_product_scope = base_product_scope.send(self.order_scope) | ||
| end | ||
|
|
||
| return self.product_scopes.reject {|s| | ||
| s.is_ordering? | ||
| }.inject(base_product_scope){|result, scope| | ||
| scope.apply_on(result) | ||
| } | ||
| end | ||
|
|
||
| # returns chain of named scopes generated from order scope and product scopes. | ||
| def dynamic_products(use_order = true) | ||
| apply_on(Product.scoped(nil), use_order) | ||
| end | ||
|
|
||
| # Does the final ordering if requested | ||
| # TODO: move the order stuff out of the above - is superfluous now | ||
| def products(use_order = true) | ||
| cached_group = Product.in_cached_group(self) | ||
| if cached_group.limit(1).blank? | ||
| dynamic_products(use_order) | ||
| elsif !use_order | ||
| cached_group | ||
| else | ||
| product_scopes.select {|s| | ||
| s.is_ordering? | ||
| }.inject(cached_group) {|res,order| | ||
| order.apply_on(res) | ||
| } | ||
| end | ||
| end | ||
|
|
||
| def include?(product) | ||
| res = apply_on(Product.id_equals(product.id), false) | ||
| res.count > 0 | ||
| end | ||
|
|
||
| def scopes_to_hash | ||
| result = {} | ||
| self.product_scopes.each do |scope| | ||
| result[scope.name] = scope.arguments | ||
| end | ||
| result | ||
| end | ||
|
|
||
| # generates ProductGroup url | ||
| def to_url | ||
| if (new_record? || name.blank?) | ||
| result = "" | ||
| result+= self.product_scopes.map{|ps| | ||
| [ps.name, ps.arguments.join(",")] | ||
| }.flatten.join('/') | ||
| result+= self.order_scope if self.order_scope | ||
|
|
||
| result | ||
| else | ||
| name.to_url | ||
| end | ||
| end | ||
|
|
||
| def set_permalink | ||
| self.permalink = self.name.to_url | ||
| end | ||
|
|
||
| def update_memberships | ||
| # wipe everything directly to avoid expensive in-rails sorting | ||
| ActiveRecord::Base.connection.execute "DELETE FROM product_groups_products WHERE product_group_id = #{self.id}" | ||
|
|
||
| # and generate the new group entirely in SQL | ||
| ActiveRecord::Base.connection.execute "INSERT INTO product_groups_products #{dynamic_products(false).scoped(:select => "products.id, #{self.id}").to_sql}" | ||
| end | ||
|
|
||
| def generate_preview(size = Spree::Config[:admin_pgroup_preview_size]) | ||
| count = self.class.count_by_sql ["SELECT COUNT(*) FROM product_groups_products WHERE product_groups_products.product_group_id = ?", self] | ||
|
|
||
| return count, products.limit(size) | ||
| end | ||
|
|
||
| def to_s | ||
| "<ProductGroup" + (id && "[#{id}]").to_s + ":'#{to_url}'>" | ||
| end | ||
|
|
||
| def order_scope | ||
| if scope = product_scopes.detect {|s| s.is_ordering?} | ||
| scope.name | ||
| end | ||
| end | ||
| def order_scope=(scope_name) | ||
| if scope = product_scopes.detect {|s| s.is_ordering?} | ||
| scope.update_attribute(:name, scope_name) | ||
| else | ||
| self.product_scopes.build(:name => scope_name, :arguments => []) | ||
| end | ||
| end | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # *ProductScope* is model for storing named scopes with their arguments, | ||
| # to be used with ProductGroups. | ||
| # | ||
| # Each product Scope can be applied to Product (or product scope) with #apply_on method | ||
| # which returns new combined named scope | ||
| # | ||
| class ProductScope < ActiveRecord::Base | ||
| # name | ||
| # arguments | ||
| belongs_to :product_group | ||
| serialize :arguments | ||
|
|
||
| extend ::Scopes::Dynamic | ||
|
|
||
| # Get all products with this scope | ||
| def products | ||
| if Product.condition?(self.name) | ||
| Product.send(self.name, *self.arguments) | ||
| end | ||
| end | ||
|
|
||
| # Applies product scope on Product model or another named scope | ||
| def apply_on(another_scope) | ||
| another_scope.send(self.name, *self.arguments) | ||
| end | ||
|
|
||
| def before_validation_on_create | ||
| # Add default empty arguments so scope validates and errors aren't caused when previewing it | ||
| if args = Scopes::Product.arguments_for_scope_name(name) | ||
| self.arguments ||= ['']*args.length | ||
| end | ||
| end | ||
|
|
||
| # checks validity of the named scope (if its safe and can be applied on Product) | ||
| def validate | ||
| errors.add(:name, "is not a valid scope name") unless Product.condition?(self.name) | ||
| apply_on(Product).limit(0) != nil | ||
| rescue Exception | ||
| errors.add(:arguments, "are incorrect") | ||
| end | ||
|
|
||
| # test ordering scope by looking for name pattern or :order clause | ||
| def is_ordering? | ||
| name =~ /^(ascend_by|descend_by)/ || apply_on(Product).scope(:find)[:order].present? | ||
| end | ||
|
|
||
| def to_sentence | ||
| result = I18n.t(:sentence, :scope => [:product_scopes, :scopes, self.name], :default => "") | ||
| result = I18n.t(:name, :scope => [:product_scopes, :scopes, self.name]) if result.blank? | ||
| result % [*self.arguments] | ||
| end | ||
|
|
||
| def to_s | ||
| to_sentence | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| class ReturnAuthorization < ActiveRecord::Base | ||
| belongs_to :order | ||
| has_many :inventory_units | ||
| before_save :generate_number | ||
|
|
||
| validates_presence_of :order | ||
| validates_numericality_of :amount | ||
| validate :must_have_shipped_units | ||
|
|
||
| state_machine :initial => 'authorized' do | ||
| after_transition :to => 'received', :do => :add_credit | ||
|
|
||
| event :receive do | ||
| transition :to => 'received', :from => 'authorized', :if => :allow_receive? | ||
| end | ||
| event :cancel do | ||
| transition :to => 'cancelled', :from => 'authorized' | ||
| end | ||
| end | ||
|
|
||
| def add_variant(variant_id, quantity) | ||
| order_units = self.order.inventory_units.group_by(&:variant_id) | ||
| returned_units = self.inventory_units.group_by(&:variant_id) | ||
|
|
||
| count = 0 | ||
|
|
||
| if returned_units[variant_id].nil? || returned_units[variant_id].size < quantity | ||
| count = returned_units[variant_id].nil? ? 0 : returned_units[variant_id].size | ||
|
|
||
| order_units[variant_id].each do |inventory_unit| | ||
| next unless inventory_unit.return_authorization.nil? && count < quantity | ||
|
|
||
| inventory_unit.return_authorization = self | ||
| inventory_unit.save! | ||
|
|
||
| count += 1 | ||
| end | ||
| elsif returned_units[variant_id].size > quantity | ||
| (returned_units[variant_id].size - quantity).times do |i| | ||
| returned_units[variant_id][i].return_authorization_id = nil | ||
| returned_units[variant_id][i].save! | ||
| end | ||
| end | ||
|
|
||
| self.order.return_authorized! if self.inventory_units.reload.size > 0 && !self.order.awaiting_return? | ||
| end | ||
|
|
||
| private | ||
| def must_have_shipped_units | ||
| errors.add(:order, I18n.t("has_no_shipped_units")) if order.nil? || order.shipped_units.nil? | ||
| end | ||
|
|
||
| def generate_number | ||
| record = true | ||
| while record | ||
| random = "RMA#{Array.new(9){rand(9)}.join}" | ||
| record = ReturnAuthorization.find(:first, :conditions => ["number = ?", random]) | ||
| end | ||
| self.number = random | ||
| end | ||
|
|
||
| def add_credit | ||
| credit = ReturnAuthorizationCredit.create(:adjustment_source => self, :order_id => self.order.id, :amount => self.amount, :description => "RMA Credit") | ||
| self.order.update_totals! | ||
| end | ||
|
|
||
| def allow_receive? | ||
| !inventory_units.empty? | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| class ReturnAuthorizationCredit < Credit | ||
|
|
||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,53 +1,112 @@ | ||
| require 'ostruct' | ||
| class Shipment < ActiveRecord::Base | ||
| belongs_to :order | ||
| belongs_to :shipping_method | ||
| belongs_to :address | ||
| has_one :shipping_charge, :as => :adjustment_source | ||
| alias charge shipping_charge | ||
| has_many :state_events, :as => :stateful | ||
| has_many :inventory_units | ||
| before_create :generate_shipment_number | ||
| after_save :create_shipping_charge | ||
|
|
||
| attr_accessor :special_instructions | ||
| accepts_nested_attributes_for :address | ||
| accepts_nested_attributes_for :inventory_units | ||
|
|
||
| validates_presence_of :inventory_units, :if => Proc.new { |unit| !unit.order.in_progress? } | ||
|
|
||
| def shipped=(value) | ||
| return unless value == "1" && shipped_at.nil? | ||
| self.shipped_at = Time.now | ||
| end | ||
|
|
||
| def create_shipping_charge | ||
| if shipping_method | ||
| self.shipping_charge ||= ShippingCharge.create({ | ||
| :order => order, | ||
| :description => description_for_shipping_charge, | ||
| :adjustment_source => self, | ||
| }) | ||
|
|
||
| self.shipping_charge.update_attribute(:description, description_for_shipping_charge) unless self.shipping_charge.description == description_for_shipping_charge | ||
| end | ||
| end | ||
|
|
||
| def cost | ||
| shipping_charge.amount if shipping_charge | ||
| end | ||
|
|
||
| # shipment state machine (see http://github.com/pluginaweek/state_machine/tree/master for details) | ||
| state_machine :initial => 'pending' do | ||
| event :ready do | ||
| transition :from => 'pending', :to => 'ready_to_ship' | ||
| end | ||
| event :pend do | ||
| transition :from => 'ready_to_ship', :to => 'pending' | ||
| end | ||
| event :ship do | ||
| transition :from => 'ready_to_ship', :to => 'shipped' | ||
| end | ||
|
|
||
| after_transition :to => 'shipped', :do => :transition_order | ||
| end | ||
|
|
||
| def editable_by?(user) | ||
| !shipped? | ||
| end | ||
|
|
||
| def manifest | ||
| inventory_units.group_by(&:variant).map do |i| | ||
| OpenStruct.new(:variant => i.first, :quantity => i.last.length) | ||
| end | ||
| end | ||
|
|
||
| def line_items | ||
| if order.checkout_complete | ||
| order.line_items.select {|li| inventory_units.map(&:variant_id).include?(li.variant_id)} | ||
| else | ||
| order.line_items | ||
| end | ||
| end | ||
|
|
||
| def recalculate_needed? | ||
| changed? or !address.same_as?(Address.find(address.id)) | ||
| end | ||
|
|
||
| def recalculate_order | ||
| shipping_charge.update_attribute(:description, description_for_shipping_charge) | ||
| order.update_adjustments | ||
| order.update_totals! | ||
| order.save | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def generate_shipment_number | ||
| return self.number unless self.number.blank? | ||
| record = true | ||
| while record | ||
| random = Array.new(11){rand(9)}.join | ||
| record = Shipment.find(:first, :conditions => ["number = ?", random]) | ||
| end | ||
| self.number = random | ||
| end | ||
|
|
||
| def description_for_shipping_charge | ||
| "#{I18n.t(:shipping)} (#{shipping_method.name})" | ||
| end | ||
|
|
||
| def transition_order | ||
| update_attribute(:shipped_at, Time.now) | ||
| # transition order to shipped if all shipments have been shipped | ||
| order.ship! if order.shipments.all?(&:shipped?) | ||
| end | ||
|
|
||
| def validate | ||
| unless shipping_method.nil? | ||
| errors.add :shipping_method, I18n.t("is_not_available_to_shipment_address") unless shipping_method.zone.include?(address) | ||
| end | ||
| end | ||
|
|
||
| end |