162 changes: 57 additions & 105 deletions app/helpers/admin/base_helper.rb
@@ -1,110 +1,23 @@
module Admin::BaseHelper
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_delete(resource, options = {})
options.assert_valid_keys(:url, :caption, :title)

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')

#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}),
success: function(r){ jQuery('##{dom_id resource}').fadeOut('hide'); }
});
}
});"
end

def link_to_with_icon(icon_name, text, url, options = {})
link_to(icon(icon_name) + ' ' + text, url, options.update(:class => 'iconlink'))
end

def icon(icon_name)
image_tag("/images/admin/icons/#{icon_name}.png")
end

def button(text, icon = nil, button_type = 'submit')
content_tag('button', content_tag('span', text), :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 text_for_button_link(text, html_options)
s = ''
if html_options[:icon]
s << icon(html_options.delete(:icon)) + ' &nbsp; '
end
s << text
content_tag('span', s)
end

def html_options_for_button_link(html_options)
options = {:class => 'button'}.update(html_options)
end



# 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]}")
# receives a :controller, :action, and :params. Finds the given controller and runs user_authorized_for? on it.
# This can be called in your views, and is for advanced users only. If you are using :if / :unless eval expressions,
# then this may or may not work (eval strings use the current binding to execute, not the binding of the target
# controller)
def url_options_authenticate?(params = {})
params = params.symbolize_keys
if params[:controller]
# find the controller class
klass = eval("#{params[:controller]}_controller".classify)
else
args.include?(controller.controller_name.to_sym)
klass = self.class
end
css_classes << 'selected' if selected

if options[:css_class]
css_classes << options[:css_class]
end
content_tag('li', link, :class => css_classes.join(' '))
klass.user_authorized_for?(current_user, params, binding)
end


def field_container(model, method, options = {}, &block)
unless error_message_on(model, method).blank?
css_class = 'withError'
css_class = 'withError'
end
html = content_tag('p', capture(&block), :class => css_class)
concat(html)
Expand All @@ -115,11 +28,11 @@ def class_for_error(model, method)
end
end

def get_additional_field_value(controller, field)
attribute = field[:name].gsub(" ", "_").downcase
def get_additional_field_value(controller, field)
attribute = attribute_name_for(field[:name])

value = eval("@" + controller.controller_name.singularize + "." + attribute)

value = eval("@" + controller.controller_name.singularize + "." + attribute)

if value.nil? && controller.controller_name == "variants"
value = @variant.product.has_attribute?(attribute) ? @variant.product[attribute] : nil
end
Expand Down Expand Up @@ -148,7 +61,7 @@ def get_additional_field_value(controller, field)
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
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 })
Expand Down Expand Up @@ -188,6 +101,14 @@ def preference_field(form, field, options)
:disabled => options[:disabled]
}
)
when :password
form.password_field(field, {
:size => 10,
:class => 'password_string',
:readonly => options[:readonly],
:disabled => options[:disabled]
}
)
when :text
form.text_area(field,
{:rows => 15, :cols => 85, :readonly => options[:readonly],
Expand All @@ -207,12 +128,43 @@ def preference_field(form, field, options)
def preference_fields(object, form)
return unless object.respond_to?(:preferences)
object.preferences.keys.map{ |key|
next unless object.class.preference_definitions.has_key? key

definition = object.class.preference_definitions[key]
type = definition.instance_eval{@type}.to_sym

form.label("preferred_#{key}", t(key)+": ") +
preference_field(form, "preferred_#{key}", :type => type)
}.join("<br />")
end

def additional_field_for(controller, field)
field[:use] ||= 'text_field'
options = field[:options] || {}

object_name, method = controller.controller_name.singularize, attribute_name_for(field[:name])

case field[:use]
when 'check_box'
check_box(object_name, method, options, field[:checked_value] || 1, field[:unchecked_value] || 0)
when 'radio_button'
html = ''
field[:value].call(controller, field).each do |value|
html << radio_button(object_name, method, value, options)
html << " #{value.to_s} "
end
html
when 'select'
select(object_name, method, field[:value].call(controller, field), options, field[:html_options] || {})
else
value = field[:value] ? field[:value].call(controller, field) : get_additional_field_value(controller, field)
__send__(field[:use], object_name, method, options.merge(:value => value))
end # case
end

private
def attribute_name_for(field_name)
field_name.gsub(' ', '_').downcase
end

end
2 changes: 0 additions & 2 deletions app/helpers/admin/calculators_helper.rb

This file was deleted.

2 changes: 2 additions & 0 deletions app/helpers/admin/checkouts_helper.rb
@@ -0,0 +1,2 @@
module Admin::CheckoutsHelper
end
2 changes: 0 additions & 2 deletions app/helpers/admin/configurations_helper.rb

This file was deleted.

2 changes: 0 additions & 2 deletions app/helpers/admin/extensions_helper.rb

This file was deleted.

2 changes: 2 additions & 0 deletions app/helpers/admin/line_items_helper.rb
@@ -0,0 +1,2 @@
module Admin::LineItemsHelper
end
2 changes: 0 additions & 2 deletions app/helpers/admin/mail_settings_helper.rb

This file was deleted.

127 changes: 127 additions & 0 deletions app/helpers/admin/navigation_helper.rb
@@ -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)) + ' &nbsp; '
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
2 changes: 0 additions & 2 deletions app/helpers/admin/option_values_helper.rb

This file was deleted.

29 changes: 22 additions & 7 deletions app/helpers/admin/orders_helper.rb
@@ -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|
links << (button_link_to t(event), fire_admin_order_url(@order, :e => event), {:method => :put, :confirm => "Are you sure you want to #{event}?"}) if @order.send("can_#{event}?")
@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
return "" if links.empty?
links.join(' &nbsp;')
links.join('&nbsp;')
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
7 changes: 7 additions & 0 deletions app/helpers/admin/payments_helper.rb
@@ -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
2 changes: 2 additions & 0 deletions app/helpers/admin/return_authorizations_helper.rb
@@ -0,0 +1,2 @@
module Admin::ReturnAuthorizationsHelper
end
2 changes: 0 additions & 2 deletions app/helpers/admin/shipping_categories_helper.rb

This file was deleted.

2 changes: 0 additions & 2 deletions app/helpers/admin/shipping_methods_helper.rb

This file was deleted.

2 changes: 0 additions & 2 deletions app/helpers/admin/tax_rates_helper.rb

This file was deleted.

3 changes: 0 additions & 3 deletions app/helpers/admin/taxonomies_helper.rb

This file was deleted.

2 changes: 0 additions & 2 deletions app/helpers/admin/variants_helper.rb

This file was deleted.

10 changes: 2 additions & 8 deletions app/helpers/application_helper.rb
Expand Up @@ -6,15 +6,9 @@ def store_menu?
return true unless %w{thank_you}.include? @current_action
false
end

# Renders all the extension partials that may have been specified in the extensions
def render_extra_partials(f)
@extension_partials.inject("") do |extras, partial|
extras += render :partial => partial, :locals => {:f => f}
end
end

def flag_image(code)
"#{code.to_s.split("-").last.downcase}.png"
end
end

end
36 changes: 32 additions & 4 deletions app/helpers/checkouts_helper.rb
@@ -1,7 +1,35 @@
module CheckoutsHelper
def checkout_steps
checkout_steps = %w{registration billing shipping shipping_method payment confirmation}
checkout_steps.delete "registration" if current_user
checkout_steps

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
21 changes: 21 additions & 0 deletions app/helpers/hook_helper.rb
@@ -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
38 changes: 28 additions & 10 deletions app/helpers/products_helper.rb
@@ -1,5 +1,5 @@
module ProductsHelper
# returns the formatted change in price (from the master price) for the specified variant (or simply return
# returns the formatted change in price (from the master price) for the specified variant (or simply return
# the variant price if no master price was supplied)
def variant_price_diff(variant)
return product_price(variant) unless variant.product.master.price
Expand All @@ -11,7 +11,7 @@ def variant_price_diff(variant)
"(#{t("subtract")}: #{format_price diff.abs})"
end
end

# returns the price of the product to show for display purposes
def product_price(product_or_variant, options={})
options.assert_valid_keys(:format_as_currency, :show_vat_text)
Expand All @@ -21,23 +21,41 @@ def product_price(product_or_variant, options={})
amount += Calculator::Vat.calculate_tax_on(product_or_variant) if Spree::Config[:show_price_inc_vat]
options.delete(:format_as_currency) ? format_price(amount, options) : ("%0.2f" % amount).to_f
end

def format_price(price, options={})
options.assert_valid_keys(:show_vat_text)
options.reverse_merge! :show_vat_text => Spree::Config[:show_price_inc_vat]
options[:show_vat_text] ? number_to_currency(price) + ' (inc. VAT)' : number_to_currency(price)
formatted_price = number_to_currency(price)
if options[:show_vat_text]
I18n.t(:price_with_vat_included, :price => formatted_price)
else
formatted_price
end
end

# converts line breaks in product description into <p> tags (for html display purposes)
def product_description(product)
product.description.gsub(/^(.*)$/, '<p>\1</p>')
end
end

# generates nested url to product based on supplied taxon
def seo_url(taxon, product = nil)
return '/t/' + taxon.permalink if product.nil?

'/t/' + taxon.permalink + "p/" + product.permalink
warn "DEPRECATION: the /t/taxon-permalink/p/product-permalink urls are "+
"not used anymore. Use product_url instead. (called from #{caller[0]})"
return product_url(product)
end

# Generate taxon breadcrumbs for searching related products
def taxon_crumbs(taxon, separator="&nbsp;&raquo;&nbsp;")
crumbs = []

crumbs << taxon.ancestors.collect { |ancestor|
content_tag(:li, link_to(ancestor.name , seo_url(ancestor)) + separator)
} unless taxon.ancestors.empty?

crumbs << content_tag(:li, link_to(taxon.name , seo_url(taxon)))
crumb_list = content_tag(:ul, crumbs)
content_tag(:div, crumb_list + content_tag(:br, nil, :class => 'clear'), :class => 'breadcrumbs')
end

end
86 changes: 43 additions & 43 deletions app/helpers/spree/base_helper.rb
Expand Up @@ -7,16 +7,16 @@ def cart_link
return new_order_url if session[:order_id].blank?
return edit_order_url(Order.find_or_create_by_id(session[:order_id]))
end

def cart_path
cart_link
end


def link_to_cart(text=t('cart'))
path = cart_path
order = Order.find_or_create_by_id(session[:order_id]) unless session[:order_id].blank?
css_class = ''
css_class = nil
unless order.nil?
item_count = order.line_items.inject(0) { |kount, line_item| kount + line_item.quantity }
return "" if current_page?(path)
Expand All @@ -25,78 +25,78 @@ def link_to_cart(text=t('cart'))
end
link_to text, path, :class => css_class
end

def order_price(order, options={})
options.assert_valid_keys(:format_as_currency, :show_vat_text, :show_price_inc_vat)
options.reverse_merge! :format_as_currency => true, :show_vat_text => true

# overwrite show_vat_text if show_price_inc_vat is false
options[:show_vat_text] = Spree::Config[:show_price_inc_vat]

amount = order.item_total
amount = order.item_total
amount += Calculator::Vat.calculate_tax(order) if Spree::Config[:show_price_inc_vat]

options.delete(:format_as_currency) ? number_to_currency(amount) : amount
end

def add_product_link(text, product)
link_to_remote text, {:url => {:controller => "cart",
:action => "add", :id => product}},
{:title => "Add to Cart",
:href => url_for( :controller => "cart",
:action => "add", :id => product)}
end
def remove_product_link(text, product)
link_to_remote text, {:url => {:controller => "cart",
:action => "remove",
:id => product}},
{:title => "Remove item",
:href => url_for( :controller => "cart",
:action => "remove", :id => product)}
end


def add_product_link(text, product)
link_to_remote text, {:url => {:controller => "cart",
:action => "add", :id => product}},
{:title => "Add to Cart",
:href => url_for( :controller => "cart",
:action => "add", :id => product)}
end

def remove_product_link(text, product)
link_to_remote text, {:url => {:controller => "cart",
:action => "remove",
:id => product}},
{:title => "Remove item",
:href => url_for( :controller => "cart",
:action => "remove", :id => product)}
end

def todays_short_date
utc_to_local(Time.now.utc).to_ordinalized_s(:stub)
end

def yesterdays_short_date
utc_to_local(Time.now.utc.yesterday).to_ordinalized_s(:stub)
end
end


# human readable list of variant options
def variant_options(v, allow_back_orders = Spree::Config[:allow_backorders], include_style = true)
list = v.options_text
list = include_style ? "<span class =\"out-of-stock\">(" + t("out_of_stock") + ") #{list}</span>" : "#{t("out_of_stock")} #{list}" unless (v.in_stock? or allow_back_orders)
list = include_style ? "<span class =\"out-of-stock\">(" + t("out_of_stock") + ") #{list}</span>" : "#{t("out_of_stock")} #{list}" unless (allow_back_orders || v.in_stock?)
list
end
def mini_image(product)
end

def mini_image(product, options={})
if product.images.empty?
image_tag "noimage/mini.jpg"
image_tag "noimage/mini.jpg", options
else
image_tag product.images.first.attachment.url(:mini)
image_tag product.images.first.attachment.url(:mini), options
end
end

def small_image(product)
def small_image(product, options={})
if product.images.empty?
image_tag "noimage/small.jpg"
image_tag "noimage/small.jpg", options
else
image_tag product.images.first.attachment.url(:small)
image_tag product.images.first.attachment.url(:small), options
end
end

def product_image(product)
def product_image(product, options={})
if product.images.empty?
image_tag "noimage/product.jpg"
image_tag "noimage/product.jpg", options
else
image_tag product.images.first.attachment.url(:product)
image_tag product.images.first.attachment.url(:product), options
end
end

def meta_data_tags
return unless self.respond_to?(:object) && object
"".tap do |tags|
Expand All @@ -118,7 +118,7 @@ def stylesheet_tags(paths=stylesheet_paths)
end
return output
end

def stylesheet_paths
paths = Spree::Config[:stylesheets]
if (paths.blank?)
Expand Down
6 changes: 3 additions & 3 deletions app/helpers/taxons_helper.rb
Expand Up @@ -4,13 +4,13 @@ def breadcrumbs(taxon, separator="&nbsp;&raquo;&nbsp;")
crumbs = [content_tag(:li, link_to(t(:home) , root_path) + separator)]
if taxon
crumbs << content_tag(:li, link_to(t('products') , products_path) + separator)
crumbs << taxon.ancestors.reverse.collect { |ancestor| content_tag(:li, link_to(ancestor.name , seo_url(ancestor)) + separator) } unless taxon.ancestors.empty?
crumbs << taxon.ancestors.collect { |ancestor| content_tag(:li, link_to(ancestor.name , seo_url(ancestor)) + separator) } unless taxon.ancestors.empty?
crumbs << content_tag(:li, content_tag(:span, taxon.name))
else
crumbs << content_tag(:li, content_tag(:span, t('products')))
end
crumb_list = content_tag(:ul, crumbs)
content_tag(:div, crumb_list + content_tag(:br, nil, :class => 'clear'), :class => 'breadcrumbs')
crumb_list = content_tag(:ul, crumbs.flatten.map{|li| li.mb_chars}.join)
content_tag(:div, crumb_list + tag(:br, {:class => 'clear'}, false, true), :class => 'breadcrumbs')
end


Expand Down
2 changes: 2 additions & 0 deletions app/helpers/trackers_helper.rb
@@ -0,0 +1,2 @@
module TrackersHelper
end
13 changes: 13 additions & 0 deletions app/helpers/users_helper.rb
@@ -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
22 changes: 11 additions & 11 deletions app/metal/create_admin_user.rb
@@ -1,18 +1,18 @@
# Allow the metal piece to run in isolation
require(File.dirname(__FILE__) + "/../../config/environment") unless defined?(Rails)

class CreateAdminUser
# note: we're not returning 404 in the usuaul sense - it actually just tells rails to continue metal chain
class CreateAdminUser

# note: this is not really a true 404 - it actually just tells rails to continue metal chain
CONTINUE_CHAIN = [404, {"Content-Type" => "text/html"}, ["Not Found"]]

def self.call(env)
session = env["rack.session"]
return CONTINUE_CHAIN if env["PATH_INFO"] =~ /^\/users/ or session['admin-user'] or not User.table_exists?
session['admin-user'] = User.first(:include => :roles, :conditions => ["roles.name = 'admin'"])
return CONTINUE_CHAIN if session['admin-user']
# redirect to user creation
[302, {'Location'=> '/users/new' }, []]
def self.call(env)
if env["PATH_INFO"] =~ /^\/users|stylesheets/ or @admin_defined or not User.table_exists?
@status = [404, {"Content-Type" => "text/html"}, "Not Found"]
else
@admin_defined = User.first(:include => :roles, :conditions => ["roles.name = 'admin'"])
@status = [404, {"Content-Type" => "text/html"}, "Not Found"] if @admin_defined
end
# redirect to user creation screen
return @status || [302, {'Location'=> '/users/new' }, []]
ensure
# Release the connections back to the pool.
ActiveRecord::Base.clear_active_connections!
Expand Down
10 changes: 10 additions & 0 deletions app/metal/redirect_legacy_product_url.rb
@@ -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
38 changes: 38 additions & 0 deletions app/metal/seo_assist.rb
@@ -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
30 changes: 28 additions & 2 deletions app/models/address.rb
@@ -1,7 +1,7 @@
class Address < ActiveRecord::Base
belongs_to :country
belongs_to :state

has_many :checkouts, :foreign_key => "bill_address_id"
has_many :shipments

Expand All @@ -14,14 +14,22 @@ class Address < ActiveRecord::Base
validates_presence_of :zipcode
validates_presence_of :country
validates_presence_of :phone
validate :state_name_validate, :if => Proc.new { |address| address.state.blank? && Spree::Config[:address_requires_state] }

# disconnected since there's no code to display error messages yet OR matching client-side validation
def phone_validate
return if phone.blank?
n_digits = phone.scan(/[0-9]/).size
valid_chars = (phone =~ /^[-+()\/\s\d]+$/)
if !(n_digits > 5 && valid_chars)
errors.add(:phone, "is invalid")
errors.add(:phone, :invalid)
end
end

def state_name_validate
return if country.blank? || country.states.empty?
if state_name.blank? || country.states.name_or_abbr_equals(state_name).empty?
errors.add(:state, :invalid)
end
end

Expand Down Expand Up @@ -52,11 +60,29 @@ def zones
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
Address.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? {|k,v| v.nil?}
end
end
75 changes: 57 additions & 18 deletions app/models/adjustment.rb
@@ -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 :amount
validates_presence_of :description
validates_numericality_of :amount

before_save do |record|
new_amount = record.calculate_adjustment
record.amount = new_amount if new_amount
record.secondary_type ||= record.type
end
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.calculator || adjustment_source.default_calculator
raise(RuntimeError, "#{self.class.name}##{id} doesn't have a calculator") unless calc
calc.compute(adjustment_source)
elsif read_attribute(:amount)
read_attribute(:amount)
else
nil
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
read_attribute(:amount) || self.calculate_adjustment
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
11 changes: 8 additions & 3 deletions app/models/app_configuration.rb
Expand Up @@ -28,22 +28,27 @@ class AppConfiguration < Configuration
preference :show_zero_stock_products, :boolean, :default => true
preference :orders_per_page, :integer, :default => 15
preference :admin_products_per_page, :integer, :default => 10
preference :admin_pgroup_preview_size, :integer, :default => 10
preference :products_per_page, :integer, :default => 10
preference :default_tax_category, :string, :default => nil # Use the name (exact case) of the tax category if you wish to specify
preference :logo, :string, :default => '/images/admin/bg/spree_50.png'
preference :stylesheets, :string, :default => 'compiled/screen' # Comma separate multiple stylesheets, e.g. 'compiled/screen,compiled/site'
preference :stylesheets, :string, :default => 'screen' # Comma separate multiple stylesheets, e.g. 'screen,mystyle'
preference :admin_interface_logo, :string, :default => "spree/spree.jpg"
preference :allow_ssl_in_production, :boolean, :default => true
preference :allow_ssl_in_development_and_test, :boolean, :default => false
preference :google_analytics_id, :string, :default => '12312312' # Replace with real Google Analytics Id
preference :allow_guest_checkout, :boolean, :default => true
preference :allow_anonymous_checkout, :boolean, :default => false
preference :alternative_billing_phone, :boolean, :default => false # Request extra phone for bill addr
preference :alternative_shipping_phone, :boolean, :default => false # Request extra phone for ship addr
preference :shipping_instructions, :boolean, :default => false # Request instructions/info for shipping
preference :show_price_inc_vat, :boolean, :default => false
preference :auto_capture, :boolean, :default => false # automatically capture the creditcard (as opposed to just authorize and capture later)
preference :address_requires_state, :boolean, :default => true # should state/state_name be required
preference :use_mail_queue, :boolean, :default => false #send mail immediately or use a mail queue.
preference :use_mail_queue, :boolean, :default => false #send mail immediately or use a mail queue.
preference :allow_openid, :boolean, :default => true # allow use OpenID for registrations
preference :checkout_zone, :string, :default => nil # replace with the name of a zone if you would like to limit the countries
preference :always_put_site_name_in_title, :boolean, :default => true

validates_presence_of :name
validates_uniqueness_of :name

Expand Down
23 changes: 23 additions & 0 deletions app/models/billing_integration.rb
@@ -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
11 changes: 8 additions & 3 deletions app/models/calculator.rb
@@ -1,4 +1,4 @@
class Calculator < ActiveRecord::Base
class Calculator < ActiveRecord::Base
belongs_to :calculable, :polymorphic => true

# This method must be overriden in concrete calculator.
Expand All @@ -17,8 +17,13 @@ def self.description

@@calculators = Set.new
# Registers calculator to be used with selected kinds of operations
def self.register
def self.register(*klasses)
@@calculators.add(self)
klasses.each do |klass|
klass = klass.constantize if klass.is_a?(String)
klass.register_calculator(self)
end
self
end

# Returns all calculators applicable for kind of work
Expand All @@ -36,6 +41,6 @@ def description
end

def available?(object)
self.class.available?(object)
return true #should be overridden if needed
end
end
11 changes: 6 additions & 5 deletions app/models/calculator/flat_percent_item_total.rb
Expand Up @@ -11,9 +11,10 @@ def self.register
ShippingMethod.register_calculator(self)
ShippingRate.register_calculator(self)
end

def compute(order)
return if order.nil?
order.item_total * self.preferred_flat_percent
end

def compute(line_items)
return if line_items.nil?
item_total = line_items.inject(0) {|amount, li| amount + li.total }
item_total * self.preferred_flat_percent / 100.0
end
end
11 changes: 8 additions & 3 deletions app/models/calculator/flexi_rate.rb
Expand Up @@ -11,9 +11,14 @@ def self.available?(object)
true
end

def compute(object = nil)
object ||= self.calculable

def self.register
super
Coupon.register_calculator(self)
ShippingMethod.register_calculator(self)
ShippingRate.register_calculator(self)
end

def compute(object)
sum = 0
max = self.preferred_max_items
object.length.times do |i|
Expand Down
5 changes: 3 additions & 2 deletions app/models/calculator/per_item.rb
Expand Up @@ -6,12 +6,13 @@ def self.description
end

def self.register
super
super
Coupon.register_calculator(self)
ShippingMethod.register_calculator(self)
ShippingRate.register_calculator(self)
end

def compute(object=nil)
self.preferred_amount * object.length
end
end
end
31 changes: 31 additions & 0 deletions app/models/calculator/price_bucket.rb
@@ -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
14 changes: 7 additions & 7 deletions app/models/calculator/vat.rb
Expand Up @@ -10,10 +10,10 @@ def self.register
end

# list the vat rates for the default country
def self.default_rates
def self.default_rates
origin = Country.find(Spree::Config[:default_country_id])
calcs = Calculator::Vat.find(:all, :include => {:calculable => :zone}).select {
|vat| vat.calculable.zone.country_list.include?(origin)
calcs = Calculator::Vat.find(:all, :include => {:calculable => :zone}).select {
|vat| vat.calculable.zone.country_list.include?(origin)
}
calcs.collect { |calc| calc.calculable }
end
Expand All @@ -23,7 +23,7 @@ def self.calculate_tax(order, rates=default_rates)
# note: there is a bug with associations in rails 2.1 model caching so we're using this hack
# (see http://rails.lighthouseapp.com/projects/8994/tickets/785-caching-models-fails-in-development)
cache_hack = rates.first.respond_to?(:tax_category_id)

taxable_totals = {}
order.line_items.each do |line_item|
next unless tax_category = line_item.variant.product.tax_category
Expand All @@ -32,7 +32,7 @@ def self.calculate_tax(order, rates=default_rates)
taxable_totals[tax_category] ||= 0
taxable_totals[tax_category] += line_item.price * rate.amount * line_item.quantity
end

return 0 if taxable_totals.empty?
tax = 0
taxable_totals.values.each do |total|
Expand All @@ -49,7 +49,7 @@ def self.calculate_tax_on(product_or_variant)
return 0 unless tax_category = product_or_variant.is_a?(Product) ? product_or_variant.tax_category : product_or_variant.product.tax_category
return 0 unless rate = vat_rates.find { | vat_rate | vat_rate.tax_category_id = tax_category.id }

(product_or_variant.is_a?(Product) ? product_or_variant.master_price : product_or_variant.price) * rate.amount
(product_or_variant.is_a?(Product) ? product_or_variant.price : product_or_variant.price) * rate.amount
end

# computes vat for line_items associated with order, and tax rate
Expand All @@ -60,4 +60,4 @@ def compute(order)
sum += (line_item.price * rate.amount * line_item.quantity)
}
end
end
end
46 changes: 14 additions & 32 deletions app/models/charge.rb
@@ -1,37 +1,19 @@
class Charge < Adjustment
validates_presence_of :secondary_type
before_save :ensure_positive_amount

def calculate_adjustment
if adjustment_source
case secondary_type
when "TaxCharge"
calculate_tax_charge
when "ShippingCharge"
calculate_shipping_charge
else
super
end
end
end

def calculate_tax_charge
return Calculator::Vat.calculate_tax(order) if order.shipment.address.blank? and Spree::Config[:show_price_inc_vat]
return unless order.shipment.address
zones = Zone.match(order.shipment.address)
tax_rates = zones.map{|zone| zone.tax_rates}.flatten.uniq
calculated_taxes = tax_rates.map{|tax_rate| tax_rate.calculate_tax(order)}
return(calculated_taxes.sum)
end

# Calculates shipping cost using calculators from shipping_rates and shipping_method
# shipping_method calculator is used when there's no corresponding shipping_rate calculator
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)
#
# shipping costs are calculated for each shipping_category - so if order have items
# from 3 shipping categories, shipping cost will triple.
# You can alter this behaviour by overwriting this method in your site extension
def calculate_shipping_charge
return unless shipping_method = adjustment_source.shipping_method
shipping_method.calculate_cost(adjustment_source)
# 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
142 changes: 118 additions & 24 deletions app/models/checkout.rb
@@ -1,46 +1,140 @@
class Checkout < ActiveRecord::Base
before_save :authorize_creditcard, :unless => "Spree::Config[:auto_capture]"
before_save :capture_creditcard, :if => "Spree::Config[:auto_capture]"
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"
has_one :shipment, :through => :order, :source => :shipments, :order => "shipments.created_at ASC"

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 :shipment
accepts_nested_attributes_for :ship_address
accepts_nested_attributes_for :payments

# for memory-only storage of creditcard details
attr_accessor :creditcard
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"]

validates_presence_of :order_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 authorize_creditcard
return unless process_creditcard?
cc = Creditcard.new(creditcard.merge(:address => self.bill_address, :checkout => self))
return unless cc.valid? and cc.authorize(order.total)
order.complete

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 capture_creditcard
return unless process_creditcard?
cc = Creditcard.new(creditcard.merge(:address => self.bill_address, :checkout => self))
return unless cc.valid? and cc.purchase(order.total)
order.complete
order.pay
def complete_order
order.complete!
order.pay! if Spree::Config[:auto_capture]
end

def process_creditcard?
order and creditcard and not creditcard[:number].blank?
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)
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
10 changes: 6 additions & 4 deletions app/models/coupon.rb
@@ -1,23 +1,25 @@
class Coupon < ActiveRecord::Base
has_many :credits, :as => :adjustment_source
has_many :coupon_credits, :as => :adjustment_source
has_calculator
alias credits coupon_credits

validates_presence_of :code

def eligible?(order)
return false if expires_at and Time.now > expires_at
return false if usage_limit and credits.count >= usage_limit
return false if usage_limit and coupon_credits.with_order.count >= usage_limit
return false if starts_at and Time.now < starts_at
# TODO - also check items in the order (once we support product groups for coupons)
true
end

def create_discount(order)
if eligible?(order) and amount = calculator.compute(order)
return if order.coupon_credits.reload.detect { |credit| credit.adjustment_source_id == self.id }
if eligible?(order) and amount = calculator.compute(order.line_items)
amount = order.item_total if amount > order.item_total
order.coupon_credits.reload.clear unless combine? and order.coupon_credits.all? { |credit| credit.adjustment_source.combine? }
order.save
credits.create({
coupon_credits.create({
:order => order,
:amount => amount,
:description => "#{I18n.t(:coupon)} (#{code})"
Expand Down
25 changes: 25 additions & 0 deletions app/models/coupon_credit.rb
@@ -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
33 changes: 14 additions & 19 deletions app/models/credit.rb
@@ -1,24 +1,19 @@
class Credit < Adjustment
before_save :inverse_amount
before_save :ensure_negative_amount

def calculate_adjustment
if adjustment_source
case adjustment_source_type
when "Coupon"
calculate_coupon_credit
else
super
end
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

def inverse_amount
x = self.amount > 0 ? -1 : 1
self.amount = self.amount * x
end

private
def calculate_coupon_credit
adjustment_source.calculator.compute(order)
end
end
148 changes: 38 additions & 110 deletions app/models/creditcard.rb
@@ -1,146 +1,74 @@
class Creditcard < ActiveRecord::Base
before_save :filter_sensitive
belongs_to :checkout
belongs_to :address
has_many :creditcard_payments
before_validation :prepare
class Creditcard < ActiveRecord::Base
has_many :payments, :as => :source

accepts_nested_attributes_for :address

include ActiveMerchant::Billing::CreditCardMethods

class ExpiryDate #:nodoc:
attr_reader :month, :year
def initialize(month, year)
@month = month
@year = year
end

def expired? #:nodoc:
Time.now > expiration rescue true
end

def expiration #:nodoc:
Time.parse("#{month}/#{month_days}/#{year} 23:59:59") rescue Time.at(0)
end

private
def month_days
mdays = [nil,31,28,31,30,31,30,31,31,30,31,30,31]
mdays[2] = 29 if Date.leap?(year)
mdays[month]
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 expiry_date
ExpiryDate.new(Time.now.month, Time.now.year)
end

def expired?
expiry_date.expired?
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?
!@first_name.blank?
!self.first_name.blank?
end

def last_name?
!@last_name.blank?
!self.last_name.blank?
end

def name
"#{@first_name} #{@last_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
# self.class.mask(number)
#end

def last_digits
self.class.last_digits(number)
end

# needed for some of the ActiveMerchant gateways (eg. Protx)
def brand
cc_type
end

def validate
validate_essential_attributes
validate_card_type
#validate_card_number
#validate_verification_value
#validate_switch_or_solo_attributes
def display_number
"XXXX-XXXX-XXXX-#{last_digits}"
end

def self.requires_verification_value?
true
#require_verification_value
end
alias :attributes_with_quotes_default :attributes_with_quotes

private
# Validation logic ripped from ActiveMerchant's Creditcard model
# http://github.com/Shopify/active_merchant/tree/master/lib/active_merchant/billing/credit_card.rb
def filter_sensitive
self.display_number = ActiveMerchant::Billing::CreditCard.mask(number) if self.display_number.blank?
self.number = nil unless Spree::Config[:store_cc]
self.verification_value = nil unless Spree::Config[:store_cvv]
end

def prepare #:nodoc:
self.month = month.to_i
self.year = year.to_i
self.number = number.to_s.gsub(/[^\d]/, "")
self.cc_type.downcase! if cc_type.respond_to?(:downcase)
self.cc_type = spree_cc_type if cc_type.blank?
self.first_name = address.firstname if address
self.last_name = address.lastname if address
end

def validate_card_number #:nodoc:
errors.add :number, "is not a valid credit card number" unless Creditcard.valid_number?(number)
unless errors.on(:number) || errors.on(:cc_type)
errors.add :cc_type, "is not the correct card type" unless Creditcard.matching_type?(number, cc_type)
end
end

def validate_card_type #:nodoc:
#errors.add :cc_type, "is required" if cc_type.blank?
errors.add :cc_type, "is invalid" unless Creditcard.card_companies.keys.include?(cc_type)
end
private

def validate_essential_attributes #:nodoc:
errors.add :first_name, "cannot be empty" if first_name.blank?
errors.add :last_name, "cannot be empty" if last_name.blank?
errors.add :month, "is not a valid month" unless valid_month?(month)
errors.add :year, "expired" if expired?
errors.add :year, "is not a valid year" unless valid_expiry_year?(year)
end

def validate_switch_or_solo_attributes #:nodoc:
if %w[switch solo].include?(cc_type)
unless valid_month?(@start_month) && valid_start_year?(@start_year) || valid_issue_number?(@issue_number)
errors.add :start_month, "is invalid" unless valid_month?(@start_month)
errors.add :start_year, "is invalid" unless valid_start_year?(@start_year)
errors.add :issue_number, "cannot be empty" unless valid_issue_number?(@issue_number)
end
end
# 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 validate_verification_value #:nodoc:
if Creditcard.requires_verification_value?
errors.add :verification_value, "is required" unless verification_value?

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

26 changes: 0 additions & 26 deletions app/models/creditcard_payment.rb

This file was deleted.

11 changes: 7 additions & 4 deletions app/models/creditcard_txn.rb
@@ -1,6 +1,9 @@
class CreditcardTxn < ActiveRecord::Base
belongs_to :creditcard_payment
validates_numericality_of :amount

class CreditcardTxn < Transaction

enumerable_constant :txn_type, :constants => [:authorize, :capture, :purchase, :void, :credit]

def txn_type_name
TxnType.from_value(txn_type)
end

end
83 changes: 50 additions & 33 deletions app/models/inventory_unit.rb
@@ -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 :sell do
transition :to => 'sold', :from => 'on_hand'
end
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
event :restock do
transition :to => 'on_hand', :from => %w(sold shipped)
end
# TODO: add backorder state and relevant transitions
end
# destroy the specified number of on hand inventory units

# 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
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

# 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
# retrieve the requested number of on hand units (or as many as possible) - note: optimistic locking used here
on_hand = self.retrieve_on_hand(variant, quantity)
# mark all of these units as sold and associate them with this order
on_hand.each do |unit|
unit.order = order
unit.sell!
end
# right now we always allow back ordering
backorder = quantity - on_hand.size
backorder.times do
order.inventory_units.create(:variant => variant, :state => "backordered")

# 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],
variant.inventory_units.find(:all,
:conditions => ['status = ? ', status],
:limit => quantity)
end
end

private
def allow_ship?
state == 'ready_to_ship' || Spree::Config[:allow_backorder_shipping]
Spree::Config[:allow_backorder_shipping] || (state == 'ready_to_ship')
end
end

end
21 changes: 15 additions & 6 deletions app/models/line_item.rb
Expand Up @@ -5,18 +5,27 @@ class LineItem < ActiveRecord::Base

has_one :product, :through => :variant

validates_presence_of :variant
validates_numericality_of :quantity, :only_integer => true, :message => "must be an integer"
before_validation :copy_price

validates_presence_of :variant, :order
validates_numericality_of :quantity, :only_integer => true, :message => I18n.t("validation.must_be_int")
validates_numericality_of :price

attr_accessible :quantity
attr_accessible :quantity, :variant_id, :order_id

def copy_price
self.price = variant.price if variant && self.price.nil?
end

def validate
unless quantity && quantity >= 0
errors.add(:quantity, "must be a non-negative value")
errors.add(:quantity, I18n.t("validation.must_be_non_negative"))
end
unless variant and quantity <= variant.on_hand || Spree::Config[:allow_backorders]
errors.add(:quantity, " is too large-- stock on hand cannot cover requested quantity!")
# avoid reload of order.inventory_units by using direct lookup
unless Spree::Config[:allow_backorders] ||
order && InventoryUnit.order_id_equals(order).first.present? ||
variant && quantity <= variant.on_hand
errors.add(:quantity, I18n.t("validation.is_too_large"))
end
end

Expand Down
3 changes: 2 additions & 1 deletion app/models/option_type.rb
@@ -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
end
264 changes: 199 additions & 65 deletions app/models/order.rb

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions app/models/order_mailer.rb
@@ -1,4 +1,4 @@
class OrderMailer < ActionMailer::Base
class OrderMailer < ActionMailer::QueueMailer
helper "spree/base"

def confirm(order, resend = false)
Expand All @@ -22,6 +22,10 @@ def cancel(order)

private
def order_bcc
[Spree::Config[:order_bcc], Spree::Config[:mail_bcc]].delete_if { |email| email.blank? }.uniq
bcc = [Spree::Config[:order_bcc] || "", Spree::Config[:mail_bcc] || ""]
bcc = bcc.inject([]){|array, config_string| array + config_string.split(",")}
bcc = bcc.collect{|email| email.strip}
bcc = bcc.uniq
bcc
end
end
15 changes: 0 additions & 15 deletions app/models/order_observer.rb

This file was deleted.

107 changes: 99 additions & 8 deletions app/models/payment.rb
@@ -1,11 +1,102 @@
class Payment < ActiveRecord::Base
belongs_to :order
after_save :check_payments
after_destroy :check_payments
belongs_to :payable, :polymorphic => true
belongs_to :source, :polymorphic => true
belongs_to :payment_method

has_many :transactions
alias :txns :transactions

private
def check_payments
return unless order.checkout_complete
order.pay! if order.payment_total >= order.total
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
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
45 changes: 45 additions & 0 deletions app/models/payment_method.rb
@@ -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
2 changes: 2 additions & 0 deletions app/models/payment_method/check.rb
@@ -0,0 +1,2 @@
class PaymentMethod::Check < PaymentMethod
end
221 changes: 111 additions & 110 deletions app/models/product.rb
@@ -1,10 +1,10 @@
# PRODUCTS
# Products represent an entity for sale in a store.
# Products can have variations, called variants
# Products properties include description, permalink, availability,
# Products represent an entity for sale in a store.
# Products can have variations, called variants
# Products properties include description, permalink, availability,
# shipping category, etc. that do not change by variant.
#
# MASTER VARIANT
# MASTER VARIANT
# Every product has one master variant, which stores master price and sku, size and weight, etc.
# The master variant does not have option values associated with it.
# Price, SKU, size, weight, etc. are all delegated to the master variant.
Expand All @@ -16,151 +16,104 @@
# The master variant can have inventory units, but not option values.
# All other variants have option values and may have inventory units.
# Sum of on_hand each variant's inventory level determine "on_hand" level for the product.
#
#
class Product < ActiveRecord::Base
has_many :product_option_types, :dependent => :destroy
has_many :option_types, :through => :product_option_types
has_many :variants, :dependent => :destroy
has_many :product_properties, :dependent => :destroy, :attributes => true
has_many :properties, :through => :product_properties
has_many :images, :as => :viewable, :order => :position, :dependent => :destroy
has_many :images, :as => :viewable, :order => :position, :dependent => :destroy
has_and_belongs_to_many :product_groups
belongs_to :tax_category
has_and_belongs_to_many :taxons
belongs_to :shipping_category
has_one :master,
:class_name => 'Variant',
:conditions => ["is_master = ?", true],
:dependent => :destroy

has_one :master,
:class_name => 'Variant',
:conditions => ["variants.is_master = ? AND variants.deleted_at IS NULL", true]

delegate_belongs_to :master, :sku, :price, :weight, :height, :width, :depth, :is_master
delegate_belongs_to :master, :cost_price if Variant.column_names.include? "cost_price"

after_create :set_master_variant_defaults
after_save :set_master_on_hand_to_zero_when_product_has_variants
after_create :add_properties_and_option_types_from_prototype
before_save :recalculate_count_on_hand
after_save :update_memberships if ProductGroup.table_exists?
after_save :set_master_on_hand_to_zero_when_product_has_variants
after_save :save_master

has_many :variants,
:conditions => ["is_master = ?", false],

has_many :variants,
:conditions => ["variants.is_master = ? AND variants.deleted_at IS NULL", false]


has_many :variants_including_master,
:class_name => 'Variant',
:conditions => ["variants.deleted_at IS NULL"],
:dependent => :destroy

validates_presence_of :name
validates_presence_of :price
validates_presence_of :name
validates_presence_of :price

accepts_nested_attributes_for :product_properties

make_permalink

alias :options :product_option_types

include ::Scopes::Product

# default product scope only lists available and non-deleted products
named_scope :active, lambda { |*args| Product.not_deleted.available(args.first).scope(:find) }

named_scope :not_deleted, { :conditions => "products.deleted_at is null" }
named_scope :on_hand, { :conditions => "products.count_on_hand > 0" }
named_scope :not_deleted, { :conditions => "products.deleted_at is null" }
named_scope :available, lambda { |*args| { :conditions => ["products.available_on <= ?", args.first || Time.zone.now] } }

if (ActiveRecord::Base.connection.adapter_name == 'PostgreSQL')
named_scope :group_by_products_id, { :group => "products." + Product.column_names.join(", products.") } if ActiveRecord::Base.connection.tables.include?("products")
else
named_scope :group_by_products_id, { :group => "products.id" }
end


named_scope :master_price_between, lambda {|low,high|
{ :conditions => ["master_price BETWEEN ? AND ?", low, high] }
}

named_scope :taxons_id_in_tree, lambda {|taxon|
Product.taxons_id_in_tree_any(taxon).scope :find
}

# TODO - speed test on nest vs join
named_scope :taxons_id_in_tree_any, lambda {|*taxons|
taxons = [taxons].flatten
{ :conditions => [ "products.id in (select product_id from products_taxons where taxon_id in (?))",
taxons.map {|i| i.is_a?(Taxon) ? i : Taxon.find(i)}.
reject {|t| t.nil?}.
map {|t| [t] + t.descendents}.flatten ]}
}

# a simple test for product with a certain property-value pairing
# it can't test for NULLs and can't be cascaded - see :with_property
named_scope :with_property_value, lambda { |property, value|
Product.product_properties_property_id_equals(property).
product_properties_value_equals(value).
scope :find
} # coded this way to demonstrate composition


# a scope which sets up later testing on the values of a given property
# it takes * a property (object or id), and
# * an optional distinguishing name to support multiple property tests
# this version includes results for which the property is not given (ie is NULL),
# eg an unspecified colour would come out as a NULL.
# it probably won't be used without taxon or other filters having narrowed the set
# to a point where results aren't swamped by nulls, hence no inner join version
named_scope :with_property,
lambda {|property,*args|
name = args.empty? ? "product_properties" : args.first
property_id = case property
when Property then property.id
when Fixnum then property
end
return {} if property_id.nil?
{ :joins => "left outer join product_properties #{name} on products.id = #{name}.product_id and #{name}.property_id = #{property_id}"}
}


# add in option_values_variants to the query
# this is the common info required for all options searches
named_scope :with_variant_options,
Product.
scoped(:joins => :variants).
scoped(:joins => "join option_values_variants on variants.id = option_values_variants.variant_id").
scope(:find)

# select products which have an option of the given type
# this sets up testing on specific option values, eg colour = red
# the optional argument supports filtering by multi options, eg colour = red and
# size = small, which need separate joins if done a property at a time
# this version discards products which don't have the given option (the outer join
# version is a bit more complex because we need to control the order of joins)
# TODO: speed test on nest vs join
named_scope :with_option,
lambda {|opt_type,*args|
name = args.empty? ? "option_types" : args.first
opt_id = case opt_type
when OptionType then opt_type.id
when Fixnum then opt_type
end
return {} if opt_id.nil?
Product.with_variant_options.
scoped(:joins => "join (select presentation, id from option_values where option_type_id = #{opt_id}) #{name} on #{name}.id = option_values_variants.option_value_id").
scope(:find)
}
# truncate a list of results (TODO: move this into a superclass)
named_scope :limit, lambda {|n| {:limit => n}}

# ----------------------------------------------------------------------------------------------------------
#
# The following methods are deprecated and will be removed in a future version of Spree
#
#
# ----------------------------------------------------------------------------------------------------------

def master_price
warn "[DEPRECATION] `Product.master_price` is deprecated. Please use `Product.price` instead." unless RAILS_ENV == 'test'
warn "[DEPRECATION] `Product.master_price` is deprecated. Please use `Product.price` instead. (called from #{caller[0]})"
self.price
end

def master_price=(value)
warn "[DEPRECATION] `Product.master_price=` is deprecated. Please use `Product.price=` instead."
warn "[DEPRECATION] `Product.master_price=` is deprecated. Please use `Product.price=` instead. (called from #{caller[0]})"
self.price = value
end

def variants?
warn "[DEPRECATION] `Product.variants?` is deprecated. Please use `Product.has_variants?` instead."
warn "[DEPRECATION] `Product.variants?` is deprecated. Please use `Product.has_variants?` instead. (called from #{caller[0]})"
self.has_variants?
end

def variant
warn "[DEPRECATION] `Product.variant` is deprecated. Please use `Product.master` instead."
warn "[DEPRECATION] `Product.variant` is deprecated. Please use `Product.master` instead. (called from #{caller[0]})"
self.master
end

def to_param
# ----------------------------------------------------------------------------------------------------------
# end deprecation region
# ----------------------------------------------------------------------------------------------------------

def to_param
return permalink unless permalink.blank?
name.to_url
end

# returns true if the product has any variants (the master variant is not a member of the variants array)
def has_variants?
!variants.empty?
Expand All @@ -187,8 +140,7 @@ def has_stock?
def prototype_id=(value)
@prototype_id = value.to_i
end
after_create :add_properties_and_option_types_from_prototype


def add_properties_and_option_types_from_prototype
if prototype_id and prototype = Prototype.find_by_id(prototype_id)
prototype.properties.each do |property|
Expand All @@ -197,9 +149,54 @@ def add_properties_and_option_types_from_prototype
self.option_types = prototype.option_types
end
end


# for adding products which are closely related to existing ones
# define "duplicate_extra" for site-specific actions, eg for additional fields
def duplicate
p = self.clone
p.name = 'COPY OF ' + self.name
p.deleted_at = nil
p.created_at = p.updated_at = nil
p.taxons = self.taxons

p.product_properties = self.product_properties.map {|q| r = q.clone; r.created_at = r.updated_at = nil; r}

image_clone = lambda {|i| j = i.clone; j.attachment = i.attachment.clone; j}
p.images = self.images.map {|i| image_clone.call i}

variant = self.master.clone
variant.sku = 'COPY OF ' + self.master.sku
variant.deleted_at = nil
variant.images = self.master.images.map {|i| image_clone.call i}
p.master = variant

if self.has_variants?
# don't clone the actual variants, just the characterising types
p.option_types = self.option_types
else
end
# allow site to do some customization
p.send(:duplicate_extra) if p.respond_to?(:duplicate_extra)
p.save!
p
end

# use deleted? rather than checking the attribute directly. this
# allows extensions to override deleted? if they want to provide
# their own definition.
def deleted?
deleted_at
end

private

def recalculate_count_on_hand
product_count_on_hand = has_variants? ?
variants.inject(0) {|acc, v| acc + v.count_on_hand} :
(master ? master.count_on_hand : 0)
self.count_on_hand = product_count_on_hand
end

# the master on_hand is meaningless once a product has variants as the inventory
# units are now "contained" within the product variants
def set_master_on_hand_to_zero_when_product_has_variants
Expand All @@ -209,11 +206,15 @@ def set_master_on_hand_to_zero_when_product_has_variants
# ensures the master variant is flagged as such
def set_master_variant_defaults
master.is_master = true
end
end

# there's a weird quirk with the delegate stuff that does not automatically save the delegate object
# when saving so we force a save using a hook.
def save_master
master.save if master
master.save if master && (master.changed? || master.new_record?)
end

def update_memberships
self.product_groups = ProductGroup.all.select{|pg| pg.include?(self)}
end
end
201 changes: 201 additions & 0 deletions app/models/product_group.rb
@@ -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
56 changes: 56 additions & 0 deletions app/models/product_scope.rb
@@ -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
70 changes: 70 additions & 0 deletions app/models/return_authorization.rb
@@ -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
3 changes: 3 additions & 0 deletions app/models/return_authorization_credit.rb
@@ -0,0 +1,3 @@
class ReturnAuthorizationCredit < Credit

end
99 changes: 79 additions & 20 deletions app/models/shipment.rb
@@ -1,53 +1,112 @@
class Shipment < ActiveRecord::Base
require 'ostruct'
class Shipment < ActiveRecord::Base
belongs_to :order
belongs_to :shipping_method
belongs_to :address
has_one :charge, :as => :adjustment_source

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 :transition_order
after_save :create_shipping_charge
attr_accessor :special_instructions

attr_accessor :special_instructions
accepts_nested_attributes_for :address

def shipped?
self.shipped_at
end

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.charge ||= Charge.create({
self.shipping_charge ||= ShippingCharge.create({
:order => order,
:secondary_type => "ShippingCharge",
:description => "#{I18n.t(:shipping)} (#{shipping_method.name})",
: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
return unless shipped_at_changed?
order.shipments.each do |shipment|
return unless shipment.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
order.ship!
end
end

end