tienda 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/Rakefile +35 -0
- data/app/assets/images/tienda/chosen-sprite.png +0 -0
- data/app/assets/images/tienda/chosen-sprite@2x.png +0 -0
- data/app/assets/images/tienda/document.svg +1 -0
- data/app/assets/images/tienda/icons/bag.svg +1 -0
- data/app/assets/images/tienda/icons/balance.svg +1 -0
- data/app/assets/images/tienda/icons/box.svg +1 -0
- data/app/assets/images/tienda/icons/building.svg +1 -0
- data/app/assets/images/tienda/icons/chart.svg +1 -0
- data/app/assets/images/tienda/icons/chat.svg +1 -0
- data/app/assets/images/tienda/icons/checkbox.svg +1 -0
- data/app/assets/images/tienda/icons/checkbox2.svg +1 -0
- data/app/assets/images/tienda/icons/cloud.svg +1 -0
- data/app/assets/images/tienda/icons/cone.svg +1 -0
- data/app/assets/images/tienda/icons/credit_card.svg +1 -0
- data/app/assets/images/tienda/icons/currency.svg +1 -0
- data/app/assets/images/tienda/icons/edit.svg +14 -0
- data/app/assets/images/tienda/icons/flowchart.svg +1 -0
- data/app/assets/images/tienda/icons/gift.svg +1 -0
- data/app/assets/images/tienda/icons/globe.svg +1 -0
- data/app/assets/images/tienda/icons/id.svg +1 -0
- data/app/assets/images/tienda/icons/id2.svg +1 -0
- data/app/assets/images/tienda/icons/locked.svg +1 -0
- data/app/assets/images/tienda/icons/report.svg +1 -0
- data/app/assets/images/tienda/icons/search.svg +1 -0
- data/app/assets/images/tienda/icons/support.svg +1 -0
- data/app/assets/images/tienda/icons/tags.svg +1 -0
- data/app/assets/images/tienda/icons/toolbox.svg +1 -0
- data/app/assets/images/tienda/icons/unlocked.svg +1 -0
- data/app/assets/images/tienda/icons/wallet.svg +1 -0
- data/app/assets/images/tienda/logo.svg +47 -0
- data/app/assets/images/tienda/move.svg +1 -0
- data/app/assets/images/tienda/shoppe.svg +25 -0
- data/app/assets/images/tienda/square.svg +9 -0
- data/app/assets/images/tienda/statuses/accepted.svg +14 -0
- data/app/assets/images/tienda/statuses/paid.svg +16 -0
- data/app/assets/images/tienda/statuses/received.svg +15 -0
- data/app/assets/images/tienda/statuses/rejected.svg +14 -0
- data/app/assets/images/tienda/statuses/shipped.svg +14 -0
- data/app/assets/images/tienda/table-tear-off.png +0 -0
- data/app/assets/javascripts/tienda/application.coffee +119 -0
- data/app/assets/javascripts/tienda/chosen.jquery.js +1166 -0
- data/app/assets/javascripts/tienda/jquery_ui.js +6 -0
- data/app/assets/javascripts/tienda/mousetrap.js +9 -0
- data/app/assets/javascripts/tienda/order_form.coffee +47 -0
- data/app/assets/stylesheets/tienda/application.scss +585 -0
- data/app/assets/stylesheets/tienda/chosen.scss +424 -0
- data/app/assets/stylesheets/tienda/dialog.scss +25 -0
- data/app/assets/stylesheets/tienda/elements.scss +79 -0
- data/app/assets/stylesheets/tienda/printable.scss +67 -0
- data/app/assets/stylesheets/tienda/reset.scss +93 -0
- data/app/assets/stylesheets/tienda/sub.scss +106 -0
- data/app/assets/stylesheets/tienda/variables.scss +1 -0
- data/app/controllers/tienda/application_controller.rb +46 -0
- data/app/controllers/tienda/attachments_controller.rb +14 -0
- data/app/controllers/tienda/countries_controller.rb +47 -0
- data/app/controllers/tienda/dashboard_controller.rb +9 -0
- data/app/controllers/tienda/delivery_service_prices_controller.rb +45 -0
- data/app/controllers/tienda/delivery_services_controller.rb +47 -0
- data/app/controllers/tienda/orders_controller.rb +90 -0
- data/app/controllers/tienda/payments_controller.rb +33 -0
- data/app/controllers/tienda/product_categories_controller.rb +47 -0
- data/app/controllers/tienda/products_controller.rb +58 -0
- data/app/controllers/tienda/sessions_controller.rb +34 -0
- data/app/controllers/tienda/settings_controller.rb +16 -0
- data/app/controllers/tienda/stock_level_adjustments_controller.rb +40 -0
- data/app/controllers/tienda/tax_rates_controller.rb +49 -0
- data/app/controllers/tienda/users_controller.rb +53 -0
- data/app/controllers/tienda/variants_controller.rb +50 -0
- data/app/helpers/tienda/application_helper.rb +60 -0
- data/app/mailers/tienda/order_mailer.rb +25 -0
- data/app/mailers/tienda/user_mailer.rb +10 -0
- data/app/models/tienda/country.rb +27 -0
- data/app/models/tienda/delivery_service.rb +33 -0
- data/app/models/tienda/delivery_service_price.rb +31 -0
- data/app/models/tienda/order/actions.rb +98 -0
- data/app/models/tienda/order/billing.rb +105 -0
- data/app/models/tienda/order/delivery.rb +229 -0
- data/app/models/tienda/order/states.rb +69 -0
- data/app/models/tienda/order.rb +93 -0
- data/app/models/tienda/order_item.rb +239 -0
- data/app/models/tienda/payment.rb +80 -0
- data/app/models/tienda/product/product_attributes.rb +20 -0
- data/app/models/tienda/product/variants.rb +51 -0
- data/app/models/tienda/product.rb +169 -0
- data/app/models/tienda/product_attribute.rb +66 -0
- data/app/models/tienda/product_category.rb +23 -0
- data/app/models/tienda/setting.rb +68 -0
- data/app/models/tienda/stock_level_adjustment.rb +19 -0
- data/app/models/tienda/tax_rate.rb +46 -0
- data/app/models/tienda/user.rb +49 -0
- data/app/validators/permalink_validator.rb +7 -0
- data/app/views/layouts/tienda/application.html.haml +34 -0
- data/app/views/layouts/tienda/printable.html.haml +11 -0
- data/app/views/layouts/tienda/sub.html.haml +10 -0
- data/app/views/tienda/countries/_form.html.haml +35 -0
- data/app/views/tienda/countries/edit.html.haml +6 -0
- data/app/views/tienda/countries/index.html.haml +25 -0
- data/app/views/tienda/countries/new.html.haml +7 -0
- data/app/views/tienda/delivery_service_prices/_form.html.haml +44 -0
- data/app/views/tienda/delivery_service_prices/edit.html.haml +6 -0
- data/app/views/tienda/delivery_service_prices/index.html.haml +23 -0
- data/app/views/tienda/delivery_service_prices/new.html.haml +6 -0
- data/app/views/tienda/delivery_services/_form.html.haml +38 -0
- data/app/views/tienda/delivery_services/edit.html.haml +9 -0
- data/app/views/tienda/delivery_services/index.html.haml +27 -0
- data/app/views/tienda/delivery_services/new.html.haml +6 -0
- data/app/views/tienda/order_mailer/accepted.text.erb +12 -0
- data/app/views/tienda/order_mailer/received.text.erb +12 -0
- data/app/views/tienda/order_mailer/rejected.text.erb +10 -0
- data/app/views/tienda/order_mailer/shipped.text.erb +16 -0
- data/app/views/tienda/orders/_form.html.haml +55 -0
- data/app/views/tienda/orders/_order_details.html.haml +57 -0
- data/app/views/tienda/orders/_order_items.html.haml +38 -0
- data/app/views/tienda/orders/_order_items_form.html.haml +61 -0
- data/app/views/tienda/orders/_payments_form.html.haml +15 -0
- data/app/views/tienda/orders/_payments_table.html.haml +37 -0
- data/app/views/tienda/orders/_search_form.html.haml +24 -0
- data/app/views/tienda/orders/_status_bar.html.haml +35 -0
- data/app/views/tienda/orders/despatch_note.html.haml +45 -0
- data/app/views/tienda/orders/edit.html.haml +21 -0
- data/app/views/tienda/orders/index.html.haml +39 -0
- data/app/views/tienda/orders/new.html.haml +14 -0
- data/app/views/tienda/orders/show.html.haml +25 -0
- data/app/views/tienda/payments/refund.html.haml +13 -0
- data/app/views/tienda/product_categories/_form.html.haml +26 -0
- data/app/views/tienda/product_categories/edit.html.haml +6 -0
- data/app/views/tienda/product_categories/index.html.haml +19 -0
- data/app/views/tienda/product_categories/new.html.haml +6 -0
- data/app/views/tienda/products/_form.html.haml +118 -0
- data/app/views/tienda/products/_table.html.haml +42 -0
- data/app/views/tienda/products/edit.html.haml +8 -0
- data/app/views/tienda/products/import.html.haml +63 -0
- data/app/views/tienda/products/index.html.haml +9 -0
- data/app/views/tienda/products/new.html.haml +7 -0
- data/app/views/tienda/sessions/new.html.haml +12 -0
- data/app/views/tienda/sessions/reset.html.haml +12 -0
- data/app/views/tienda/settings/edit.html.haml +19 -0
- data/app/views/tienda/shared/error.html.haml +6 -0
- data/app/views/tienda/stock_level_adjustments/index.html.haml +40 -0
- data/app/views/tienda/tax_rates/form.html.haml +28 -0
- data/app/views/tienda/tax_rates/index.html.haml +17 -0
- data/app/views/tienda/user_mailer/new_password.text.erb +9 -0
- data/app/views/tienda/users/_form.html.haml +27 -0
- data/app/views/tienda/users/edit.html.haml +5 -0
- data/app/views/tienda/users/index.html.haml +17 -0
- data/app/views/tienda/users/new.html.haml +7 -0
- data/app/views/tienda/variants/form.html.haml +66 -0
- data/app/views/tienda/variants/index.html.haml +33 -0
- data/config/locales/en.yml +650 -0
- data/config/locales/pl.yml +650 -0
- data/config/locales/pt-BR.yml +643 -0
- data/config/routes.rb +42 -0
- data/db/countries.txt +252 -0
- data/db/migrate/20150124094549_create_tienda_initial_schema.rb +184 -0
- data/db/seeds.rb +128 -0
- data/db/seeds_data/poe400.jpg +0 -0
- data/db/seeds_data/snom-870-blk.jpg +0 -0
- data/db/seeds_data/snom-870-grey.jpg +0 -0
- data/db/seeds_data/snom-mm2.jpg +0 -0
- data/db/seeds_data/spa303.jpg +0 -0
- data/db/seeds_data/t18p.jpg +0 -0
- data/db/seeds_data/t20p.jpg +0 -0
- data/db/seeds_data/t22p.jpg +0 -0
- data/db/seeds_data/t26p.jpg +0 -0
- data/db/seeds_data/t41pn.jpg +0 -0
- data/db/seeds_data/t46gn.jpg +0 -0
- data/db/seeds_data/w52p.jpg +0 -0
- data/db/seeds_data/yhs32.jpg +0 -0
- data/lib/tasks/tienda.rake +29 -0
- data/lib/tienda/associated_countries.rb +20 -0
- data/lib/tienda/country_importer.rb +14 -0
- data/lib/tienda/default_navigation.rb +20 -0
- data/lib/tienda/engine.rb +48 -0
- data/lib/tienda/error.rb +21 -0
- data/lib/tienda/errors/inappropriate_delivery_service.rb +6 -0
- data/lib/tienda/errors/insufficient_stock_to_fulfil.rb +15 -0
- data/lib/tienda/errors/invalid_configuration.rb +6 -0
- data/lib/tienda/errors/not_enough_stock.rb +15 -0
- data/lib/tienda/errors/payment_declined.rb +6 -0
- data/lib/tienda/errors/refund_failed.rb +6 -0
- data/lib/tienda/errors/unorderable_item.rb +6 -0
- data/lib/tienda/navigation_manager.rb +81 -0
- data/lib/tienda/orderable_item.rb +39 -0
- data/lib/tienda/settings.rb +26 -0
- data/lib/tienda/settings_loader.rb +16 -0
- data/lib/tienda/setup_generator.rb +10 -0
- data/lib/tienda/version.rb +3 -0
- data/lib/tienda/view_helpers.rb +16 -0
- data/lib/tienda.rb +59 -0
- metadata +604 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
module Tienda
|
|
2
|
+
class Order < ActiveRecord::Base
|
|
3
|
+
|
|
4
|
+
# The associated delivery service
|
|
5
|
+
#
|
|
6
|
+
# @return [Tienda::DeliveryService]
|
|
7
|
+
belongs_to :delivery_service, :class_name => 'Tienda::DeliveryService'
|
|
8
|
+
|
|
9
|
+
# The country where this order is being delivered to (if one has been provided)
|
|
10
|
+
#
|
|
11
|
+
# @return [Tienda::Country]
|
|
12
|
+
belongs_to :delivery_country, :class_name => 'Tienda::Country', :foreign_key => 'delivery_country_id'
|
|
13
|
+
|
|
14
|
+
# The user who marked the order has shipped
|
|
15
|
+
#
|
|
16
|
+
# @return [Tienda::User]
|
|
17
|
+
belongs_to :shipper, :class_name => 'Tienda::User', :foreign_key => 'shipped_by'
|
|
18
|
+
|
|
19
|
+
# Set up a callback for use when an order is shipped
|
|
20
|
+
define_model_callbacks :ship
|
|
21
|
+
|
|
22
|
+
# Validations
|
|
23
|
+
with_options :if => :separate_delivery_address? do |order|
|
|
24
|
+
order.validates :delivery_name, :presence => true
|
|
25
|
+
order.validates :delivery_address1, :presence => true
|
|
26
|
+
order.validates :delivery_address3, :presence => true
|
|
27
|
+
order.validates :delivery_address4, :presence => true
|
|
28
|
+
order.validates :delivery_postcode, :presence => true
|
|
29
|
+
order.validates :delivery_country, :presence => true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
validate do
|
|
33
|
+
if self.delivery_required?
|
|
34
|
+
if self.delivery_service.nil?
|
|
35
|
+
errors.add :delivery_service_id, :must_be_specified
|
|
36
|
+
elsif !self.valid_delivery_service?
|
|
37
|
+
errors.add :delivery_service_id, :not_suitable
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
before_confirmation do
|
|
43
|
+
# Ensure that before we confirm the order that the delivery service which has been selected
|
|
44
|
+
# is appropritae for the contents of the order.
|
|
45
|
+
if self.delivery_required? && !self.valid_delivery_service?
|
|
46
|
+
raise Tienda::Errors::InappropriateDeliveryService, :order => self
|
|
47
|
+
end
|
|
48
|
+
cache_delivery_pricing
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# If an order has been received and something changes the delivery service or the delivery price
|
|
52
|
+
# is cleared, we will re-cache all the delivery pricing so that we have the latest.
|
|
53
|
+
before_save do
|
|
54
|
+
if received? && (delivery_service_id_changed? || (self.delivery_price_changed? && read_attribute(:delivery_price).blank?))
|
|
55
|
+
self.delivery_price = nil
|
|
56
|
+
self.delivery_cost_price = nil
|
|
57
|
+
self.delivery_tax_rate = nil
|
|
58
|
+
self.delivery_tax_amount = nil
|
|
59
|
+
cache_delivery_pricing
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# If there isn't a seperate address needed, clear all the fields back to nil
|
|
64
|
+
before_validation do
|
|
65
|
+
unless separate_delivery_address?
|
|
66
|
+
self.delivery_name = nil
|
|
67
|
+
self.delivery_address1 = nil
|
|
68
|
+
self.delivery_address2 = nil
|
|
69
|
+
self.delivery_address3 = nil
|
|
70
|
+
self.delivery_address4 = nil
|
|
71
|
+
self.delivery_postcode = nil
|
|
72
|
+
self.delivery_country = nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Create some delivery_ methods which will mimic the billing methods if the order does
|
|
77
|
+
# not need a seperate address.
|
|
78
|
+
[:delivery_name, :delivery_address1, :delivery_address2, :delivery_address3, :delivery_address4, :delivery_postcode, :delivery_country].each do |f|
|
|
79
|
+
define_method(f) do
|
|
80
|
+
separate_delivery_address? ? super() : send(f.to_s.gsub('delivery_', 'billing_'))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Cache delivery prices for the order
|
|
85
|
+
def cache_delivery_pricing
|
|
86
|
+
if self.delivery_service
|
|
87
|
+
write_attribute :delivery_service_id, self.delivery_service.id
|
|
88
|
+
write_attribute :delivery_price, self.delivery_price
|
|
89
|
+
write_attribute :delivery_cost_price, self.delivery_cost_price
|
|
90
|
+
write_attribute :delivery_tax_rate, self.delivery_tax_rate
|
|
91
|
+
else
|
|
92
|
+
write_attribute :delivery_service_id, nil
|
|
93
|
+
write_attribute :delivery_price, nil
|
|
94
|
+
write_attribute :delivery_cost_price, nil
|
|
95
|
+
write_attribute :delivery_tax_rate, nil
|
|
96
|
+
write_attribute :delivery_tax_amount, nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Cache prices and save the order
|
|
101
|
+
def cache_delivery_pricing!
|
|
102
|
+
cache_delivery_pricing
|
|
103
|
+
save!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Has this order been shipped?
|
|
107
|
+
#
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def shipped?
|
|
110
|
+
!!self.shipped_at?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The total weight of the order
|
|
114
|
+
#
|
|
115
|
+
# @return [BigDecimal]
|
|
116
|
+
def total_weight
|
|
117
|
+
order_items.inject(BigDecimal(0)) { |t,i| t + i.total_weight}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Is delivery required for this order?
|
|
121
|
+
#
|
|
122
|
+
# @return [Boolean]
|
|
123
|
+
def delivery_required?
|
|
124
|
+
total_weight > BigDecimal(0)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# An array of all the delivery services which are suitable for this order in it's
|
|
128
|
+
# current state (based on its current weight)
|
|
129
|
+
#
|
|
130
|
+
# @return [Array] an array of Tienda::DeliveryService objects
|
|
131
|
+
def available_delivery_services
|
|
132
|
+
delivery_service_prices.map(&:delivery_service).uniq
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# An array of all the delivery service prices which can be applied to this order.
|
|
136
|
+
#
|
|
137
|
+
# @return [Array] an array of Tienda:DeliveryServicePrice objects
|
|
138
|
+
def delivery_service_prices
|
|
139
|
+
if delivery_required?
|
|
140
|
+
prices = Tienda::DeliveryServicePrice.joins(:delivery_service).where(:tienda_delivery_services => {:active => true}).order(:price).for_weight(total_weight)
|
|
141
|
+
prices = prices.select { |p| p.countries.empty? || p.country?(self.delivery_country) }
|
|
142
|
+
prices.sort{ |x,y| (y.delivery_service.default? ? 1 : 0) <=> (x.delivery_service.default? ? 1 : 0) } # Order by truthiness
|
|
143
|
+
else
|
|
144
|
+
[]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The recommended delivery service for this order
|
|
149
|
+
#
|
|
150
|
+
# @return [Tienda::DeliveryService]
|
|
151
|
+
def delivery_service
|
|
152
|
+
super || available_delivery_services.first
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Return the delivery price for this order in its current state
|
|
156
|
+
#
|
|
157
|
+
# @return [BigDecimal]
|
|
158
|
+
def delivery_service_price
|
|
159
|
+
self.delivery_service && self.delivery_service.delivery_service_prices.for_weight(self.total_weight).first
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The price for delivering this order in its current state
|
|
163
|
+
#
|
|
164
|
+
# @return [BigDecimal]
|
|
165
|
+
def delivery_price
|
|
166
|
+
read_attribute(:delivery_price) || delivery_service_price.try(:price) || BigDecimal(0)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The cost of delivering this order in its current state
|
|
170
|
+
#
|
|
171
|
+
# @return [BigDecimal]
|
|
172
|
+
def delivery_cost_price
|
|
173
|
+
read_attribute(:delivery_cost_price) || delivery_service_price.try(:cost_price) || BigDecimal(0)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The tax amount due for the delivery of this order in its current state
|
|
177
|
+
#
|
|
178
|
+
# @return [BigDecimal]
|
|
179
|
+
def delivery_tax_amount
|
|
180
|
+
read_attribute(:delivery_tax_amount) ||
|
|
181
|
+
delivery_price / BigDecimal(100) * delivery_tax_rate
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The tax rate for the delivery of this order in its current state
|
|
185
|
+
#
|
|
186
|
+
# @return [BigDecimal]
|
|
187
|
+
def delivery_tax_rate
|
|
188
|
+
read_attribute(:delivery_tax_rate) ||
|
|
189
|
+
delivery_service_price.try(:tax_rate).try(:rate_for, self) ||
|
|
190
|
+
BigDecimal(0)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Is the currently assigned delivery service appropriate for this order?
|
|
194
|
+
#
|
|
195
|
+
# @return [Boolean]
|
|
196
|
+
def valid_delivery_service?
|
|
197
|
+
self.delivery_service ? self.available_delivery_services.include?(self.delivery_service) : !self.delivery_required?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Remove the associated delivery service if it's invalid
|
|
201
|
+
def remove_delivery_service_if_invalid
|
|
202
|
+
unless self.valid_delivery_service?
|
|
203
|
+
self.delivery_service = nil
|
|
204
|
+
self.save
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The URL which can be used to track the delivery of this order
|
|
209
|
+
#
|
|
210
|
+
# @return [String]
|
|
211
|
+
def courier_tracking_url
|
|
212
|
+
return nil if self.shipped_at.blank? || self.consignment_number.blank?
|
|
213
|
+
@courier_tracking_url ||= self.delivery_service.tracking_url_for(self)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Mark this order as shipped
|
|
217
|
+
def ship!(consignment_number, user = nil)
|
|
218
|
+
run_callbacks :ship do
|
|
219
|
+
self.shipped_at = Time.now
|
|
220
|
+
self.shipper = user if user
|
|
221
|
+
self.status = 'shipped'
|
|
222
|
+
self.consignment_number = consignment_number
|
|
223
|
+
self.save!
|
|
224
|
+
Tienda::OrderMailer.shipped(self).deliver
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Tienda
|
|
2
|
+
class Order < ActiveRecord::Base
|
|
3
|
+
|
|
4
|
+
# An array of all the available statuses for an order
|
|
5
|
+
STATUSES = ['building', 'confirming', 'received', 'accepted', 'rejected', 'shipped']
|
|
6
|
+
|
|
7
|
+
# The Tienda::User who accepted the order
|
|
8
|
+
#
|
|
9
|
+
# @return [Tienda::User]
|
|
10
|
+
belongs_to :accepter, :class_name => 'Tienda::User', :foreign_key => 'accepted_by'
|
|
11
|
+
|
|
12
|
+
# The Tienda::User who rejected the order
|
|
13
|
+
#
|
|
14
|
+
# @return [Tienda::User]
|
|
15
|
+
belongs_to :rejecter, :class_name => 'Tienda::User', :foreign_key => 'rejected_by'
|
|
16
|
+
|
|
17
|
+
# Validations
|
|
18
|
+
validates :status, :inclusion => {:in => STATUSES}
|
|
19
|
+
|
|
20
|
+
# Set the status to building if we don't have a status
|
|
21
|
+
after_initialize { self.status = STATUSES.first if self.status.blank? }
|
|
22
|
+
|
|
23
|
+
# All orders which have been received
|
|
24
|
+
scope :received, -> {where("received_at is not null")}
|
|
25
|
+
|
|
26
|
+
# All orders which are currently pending acceptance/rejection
|
|
27
|
+
scope :pending, -> { where(:status => 'received') }
|
|
28
|
+
|
|
29
|
+
# All ordered ordered by their ID desending
|
|
30
|
+
scope :ordered, -> { order(:id => :desc)}
|
|
31
|
+
|
|
32
|
+
# Is this order still being built by the user?
|
|
33
|
+
#
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
def building?
|
|
36
|
+
self.status == 'building'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Is this order in the user confirmation step?
|
|
40
|
+
#
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def confirming?
|
|
43
|
+
self.status == 'confirming'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Has this order been rejected?
|
|
47
|
+
#
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def rejected?
|
|
50
|
+
!!self.rejected_at
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Has this order been accepted?
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def accepted?
|
|
57
|
+
!!self.accepted_at
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Has the order been received?
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def received?
|
|
64
|
+
!!self.received_at?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module Tienda
|
|
2
|
+
class Order < ActiveRecord::Base
|
|
3
|
+
|
|
4
|
+
self.table_name = 'tienda_orders'
|
|
5
|
+
|
|
6
|
+
# Orders can have properties
|
|
7
|
+
key_value_store :properties
|
|
8
|
+
|
|
9
|
+
# Require dependencies
|
|
10
|
+
require_dependency 'tienda/order/states'
|
|
11
|
+
require_dependency 'tienda/order/actions'
|
|
12
|
+
require_dependency 'tienda/order/billing'
|
|
13
|
+
require_dependency 'tienda/order/delivery'
|
|
14
|
+
|
|
15
|
+
# All items which make up this order
|
|
16
|
+
has_many :order_items, :dependent => :destroy, :class_name => 'Tienda::OrderItem'
|
|
17
|
+
accepts_nested_attributes_for :order_items, :allow_destroy => true, :reject_if => Proc.new { |a| a['ordered_item_id'].blank? }
|
|
18
|
+
|
|
19
|
+
# All products which are part of this order (accessed through the items)
|
|
20
|
+
has_many :products, :through => :order_items, :class_name => 'Tienda::Product', :source => :ordered_item, :source_type => 'Tienda::Product'
|
|
21
|
+
|
|
22
|
+
# Validations
|
|
23
|
+
validates :token, :presence => true
|
|
24
|
+
with_options :if => Proc.new { |o| !o.building? } do |order|
|
|
25
|
+
order.validates :email_address, :format => {:with => /\A\b[A-Z0-9\.\_\%\-\+]+@(?:[A-Z0-9\-]+\.)+[A-Z]{2,6}\b\z/i}
|
|
26
|
+
order.validates :phone_number, :format => {:with => /\A[\d\ \-x\(\)]{7,}\z/}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Set some defaults
|
|
30
|
+
before_validation { self.token = SecureRandom.uuid if self.token.blank? }
|
|
31
|
+
|
|
32
|
+
# The order number
|
|
33
|
+
#
|
|
34
|
+
# @return [String] - the order number padded with at least 5 zeros
|
|
35
|
+
def number
|
|
36
|
+
id ? id.to_s.rjust(6, '0') : nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The length of time the customer spent building the order before submitting it to us.
|
|
40
|
+
# The time from first item in basket to received.
|
|
41
|
+
#
|
|
42
|
+
# @return [Float] - the length of time
|
|
43
|
+
def build_time
|
|
44
|
+
return nil if self.received_at.blank?
|
|
45
|
+
self.created_at - self.received_at
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The name of the customer in the format of "Company (First Last)" or if they don't have
|
|
49
|
+
# company specified, just "First Last".
|
|
50
|
+
#
|
|
51
|
+
# @return [String]
|
|
52
|
+
def customer_name
|
|
53
|
+
company.blank? ? full_name : "#{company} (#{full_name})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The full name of the customer created by concatinting the first & last name
|
|
57
|
+
#
|
|
58
|
+
# @return [String]
|
|
59
|
+
def full_name
|
|
60
|
+
"#{first_name} #{last_name}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Is this order empty? (i.e. doesn't have any items associated with it)
|
|
64
|
+
#
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def empty?
|
|
67
|
+
order_items.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Does this order have items?
|
|
71
|
+
#
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def has_items?
|
|
74
|
+
total_items > 0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Return the number of items in the order?
|
|
78
|
+
#
|
|
79
|
+
# @return [Integer]
|
|
80
|
+
def total_items
|
|
81
|
+
order_items.inject(0) { |t,i| t + i.quantity }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
85
|
+
["id", "billing_postcode", "billing_address1", "billing_address2", "billing_address3", "billing_address4", "first_name", "last_name", "company", "email_address", "phone_number", "consignment_number", "status", "received_at"] + _ransackers.keys
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.ransackable_associations(auth_object = nil)
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
module Tienda
|
|
2
|
+
class OrderItem < ActiveRecord::Base
|
|
3
|
+
|
|
4
|
+
self.table_name = 'tienda_order_items'
|
|
5
|
+
|
|
6
|
+
# The associated order
|
|
7
|
+
#
|
|
8
|
+
# @return [Tienda::Order]
|
|
9
|
+
belongs_to :order, :class_name => 'Tienda::Order', :touch => true
|
|
10
|
+
|
|
11
|
+
# The item which has been ordered
|
|
12
|
+
belongs_to :ordered_item, :polymorphic => true
|
|
13
|
+
|
|
14
|
+
# Any stock level adjustments which have been made for this order item
|
|
15
|
+
has_many :stock_level_adjustments, :as => :parent, :dependent => :nullify, :class_name => 'Tienda::StockLevelAdjustment'
|
|
16
|
+
|
|
17
|
+
# Validations
|
|
18
|
+
validates :quantity, :numericality => true
|
|
19
|
+
validates :ordered_item, :presence => true
|
|
20
|
+
|
|
21
|
+
validate do
|
|
22
|
+
unless in_stock?
|
|
23
|
+
errors.add :quantity, :too_hight_quantity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Before saving an order item which belongs to a received order, cache the pricing again if appropriate.
|
|
28
|
+
before_save do
|
|
29
|
+
if order.received? && (unit_price_changed? || unit_cost_price_changed? || tax_rate_changed? || tax_amount_changed?)
|
|
30
|
+
cache_pricing
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# After saving, if the order has been shipped, reallocate stock appropriate
|
|
35
|
+
after_save do
|
|
36
|
+
if order.shipped?
|
|
37
|
+
allocate_unallocated_stock!
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# This allows you to add a product to the scoped order. For example Order.first.order_items.add_product(...).
|
|
42
|
+
# This will either increase the quantity of the value in the order or create a new item if one does not
|
|
43
|
+
# exist already.
|
|
44
|
+
#
|
|
45
|
+
# @param ordered_item [Object] an object which implements the Tienda::OrderableItem protocol
|
|
46
|
+
# @param quantity [Fixnum] the number of items to order
|
|
47
|
+
# @return [Tienda::OrderItem]
|
|
48
|
+
def self.add_item(ordered_item, quantity = 1)
|
|
49
|
+
raise Errors::UnorderableItem, :ordered_item => ordered_item unless ordered_item.orderable?
|
|
50
|
+
transaction do
|
|
51
|
+
if existing = self.where(:ordered_item_id => ordered_item.id, :ordered_item_type => ordered_item.class.to_s).first
|
|
52
|
+
existing.increase!(quantity)
|
|
53
|
+
existing
|
|
54
|
+
else
|
|
55
|
+
new_item = self.create(:ordered_item => ordered_item, :quantity => 0)
|
|
56
|
+
new_item.increase!(quantity)
|
|
57
|
+
new_item
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Remove a product from an order. It will also ensure that the order's custom delivery
|
|
63
|
+
# service is updated if appropriate.
|
|
64
|
+
#
|
|
65
|
+
# @return [Tienda::OrderItem]
|
|
66
|
+
def remove
|
|
67
|
+
transaction do
|
|
68
|
+
self.destroy!
|
|
69
|
+
self.order.remove_delivery_service_if_invalid
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Increases the quantity of items in the order by the number provided. Will raise an error if we don't have
|
|
75
|
+
# the stock to do this.
|
|
76
|
+
#
|
|
77
|
+
# @param quantity [Fixnum]
|
|
78
|
+
def increase!(amount = 1)
|
|
79
|
+
transaction do
|
|
80
|
+
self.quantity += amount
|
|
81
|
+
unless self.in_stock?
|
|
82
|
+
raise Tienda::Errors::NotEnoughStock, :ordered_item => self.ordered_item, :requested_stock => self.quantity
|
|
83
|
+
end
|
|
84
|
+
self.save!
|
|
85
|
+
self.order.remove_delivery_service_if_invalid
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Decreases the quantity of items in the order by the number provided.
|
|
90
|
+
#
|
|
91
|
+
# @param amount [Fixnum]
|
|
92
|
+
def decrease!(amount = 1)
|
|
93
|
+
transaction do
|
|
94
|
+
self.quantity -= amount
|
|
95
|
+
self.quantity == 0 ? self.destroy : self.save!
|
|
96
|
+
self.order.remove_delivery_service_if_invalid
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The total weight of the item
|
|
101
|
+
#
|
|
102
|
+
# @return [BigDecimal]
|
|
103
|
+
def weight
|
|
104
|
+
read_attribute(:weight) || ordered_item.try(:weight) || BigDecimal(0)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Return the total weight of the item
|
|
108
|
+
#
|
|
109
|
+
# @return [BigDecimal]
|
|
110
|
+
def total_weight
|
|
111
|
+
quantity * weight
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The unit price for the item
|
|
115
|
+
#
|
|
116
|
+
# @return [BigDecimal]
|
|
117
|
+
def unit_price
|
|
118
|
+
read_attribute(:unit_price) || ordered_item.try(:price) || BigDecimal(0)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The cost price for the item
|
|
122
|
+
#
|
|
123
|
+
# @return [BigDecimal]
|
|
124
|
+
def unit_cost_price
|
|
125
|
+
read_attribute(:unit_cost_price) || ordered_item.try(:cost_price) || BigDecimal(0)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The tax rate for the item
|
|
129
|
+
#
|
|
130
|
+
# @return [BigDecimal]
|
|
131
|
+
def tax_rate
|
|
132
|
+
read_attribute(:tax_rate) || ordered_item.try(:tax_rate).try(:rate_for, self.order) || BigDecimal(0)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The total tax for the item
|
|
136
|
+
#
|
|
137
|
+
# @return [BigDecimal]
|
|
138
|
+
def tax_amount
|
|
139
|
+
read_attribute(:tax_amount) || (self.sub_total / BigDecimal(100)) * self.tax_rate
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The total cost for the product
|
|
143
|
+
#
|
|
144
|
+
# @return [BigDecimal]
|
|
145
|
+
def total_cost
|
|
146
|
+
quantity * unit_cost_price
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The sub total for the product
|
|
150
|
+
#
|
|
151
|
+
# @return [BigDecimal]
|
|
152
|
+
def sub_total
|
|
153
|
+
quantity * unit_price
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The total price including tax for the order line
|
|
157
|
+
#
|
|
158
|
+
# @return [BigDecimal]
|
|
159
|
+
def total
|
|
160
|
+
tax_amount + sub_total
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Cache the pricing for this order item
|
|
164
|
+
def cache_pricing
|
|
165
|
+
write_attribute :weight, self.weight
|
|
166
|
+
write_attribute :unit_price, self.unit_price
|
|
167
|
+
write_attribute :unit_cost_price, self.unit_cost_price
|
|
168
|
+
write_attribute :tax_rate, self.tax_rate
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Cache the pricing for this order item and save
|
|
172
|
+
def cache_pricing!
|
|
173
|
+
cache_pricing
|
|
174
|
+
save!
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Trigger when the associated order is confirmed. It handles caching the values
|
|
178
|
+
# of the monetary items and allocating stock as appropriate.
|
|
179
|
+
def confirm!
|
|
180
|
+
cache_pricing!
|
|
181
|
+
allocate_unallocated_stock!
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Trigger when the associated order is accepted
|
|
185
|
+
def accept!
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Trigged when the associated order is rejected..
|
|
189
|
+
def reject!
|
|
190
|
+
self.stock_level_adjustments.destroy_all
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Do we have the stock needed to fulfil this order?
|
|
194
|
+
#
|
|
195
|
+
# @return [Boolean]
|
|
196
|
+
def in_stock?
|
|
197
|
+
if self.ordered_item && self.ordered_item.stock_control?
|
|
198
|
+
self.ordered_item.stock >= unallocated_stock
|
|
199
|
+
else
|
|
200
|
+
true
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# How much stock remains to be allocated for this order?
|
|
205
|
+
#
|
|
206
|
+
# @return [Fixnum]
|
|
207
|
+
def unallocated_stock
|
|
208
|
+
self.quantity - allocated_stock
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# How much stock has been allocated to this item?
|
|
212
|
+
#
|
|
213
|
+
# @return [Fixnum]
|
|
214
|
+
def allocated_stock
|
|
215
|
+
0 - self.stock_level_adjustments.sum(:adjustment)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Validate the stock level against the product and update as appropriate. This method will be executed
|
|
219
|
+
# before an order is completed. If we have run out of this product, we will update the quantity to an
|
|
220
|
+
# appropriate level (or remove the order item) and return the object.
|
|
221
|
+
def validate_stock_levels
|
|
222
|
+
if in_stock?
|
|
223
|
+
false
|
|
224
|
+
else
|
|
225
|
+
self.quantity = self.ordered_item.stock
|
|
226
|
+
self.quantity == 0 ? self.destroy : self.save!
|
|
227
|
+
self
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Allocate any unallocated stock for this order item. There is no return value.
|
|
232
|
+
def allocate_unallocated_stock!
|
|
233
|
+
if self.ordered_item.stock_control? && self.unallocated_stock != 0
|
|
234
|
+
self.ordered_item.stock_level_adjustments.create!(:parent => self, :adjustment => 0 - self.unallocated_stock, :description => "Order ##{self.order.number}")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module Tienda
|
|
2
|
+
class Payment < ActiveRecord::Base
|
|
3
|
+
|
|
4
|
+
# The associated order
|
|
5
|
+
#
|
|
6
|
+
# @return [Tienda::Order]
|
|
7
|
+
belongs_to :order, :class_name => 'Tienda::Order'
|
|
8
|
+
|
|
9
|
+
# An associated payment (only applies to refunds)
|
|
10
|
+
#
|
|
11
|
+
# @return [Tienda::Payment]
|
|
12
|
+
belongs_to :parent, :class_name => "Tienda::Payment", :foreign_key => "parent_payment_id"
|
|
13
|
+
|
|
14
|
+
# Validatiosn
|
|
15
|
+
validates :amount, :numericality => true
|
|
16
|
+
validates :reference, :presence => true
|
|
17
|
+
validates :method, :presence => true
|
|
18
|
+
|
|
19
|
+
# Payments can have associated properties
|
|
20
|
+
key_value_store :properties
|
|
21
|
+
|
|
22
|
+
# Callbacks
|
|
23
|
+
after_create :cache_amount_paid
|
|
24
|
+
after_destroy :cache_amount_paid
|
|
25
|
+
before_destroy { self.parent.update_attribute(:amount_refunded, self.parent.amount_refunded + amount) if self.parent }
|
|
26
|
+
|
|
27
|
+
# Is this payment a refund?
|
|
28
|
+
#
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def refund?
|
|
31
|
+
self.amount < BigDecimal(0)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Has this payment had any refunds taken from it?
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def refunded?
|
|
38
|
+
self.amount_refunded > BigDecimal(0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# How much of the payment can be refunded
|
|
42
|
+
#
|
|
43
|
+
# @return [BigDecimal]
|
|
44
|
+
def refundable_amount
|
|
45
|
+
refundable? ? (self.amount - self.amount_refunded) : BigDecimal(0)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Process a refund from this payment.
|
|
49
|
+
#
|
|
50
|
+
# @param amount [String] the amount which should be refunded
|
|
51
|
+
# @return [Boolean]
|
|
52
|
+
def refund!(amount)
|
|
53
|
+
amount = BigDecimal(amount)
|
|
54
|
+
if refundable_amount >= amount
|
|
55
|
+
transaction do
|
|
56
|
+
self.class.create(:parent => self, :order_id => self.order_id, :amount => 0-amount, :method => self.method, :reference => reference)
|
|
57
|
+
self.update_attribute(:amount_refunded, self.amount_refunded + amount)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
raise Tienda::Errors::RefundFailed, :message => I18n.t('.refund_failed', refundable_amount: refundable_amount)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Return a transaction URL for viewing further information about this
|
|
66
|
+
# payment.
|
|
67
|
+
#
|
|
68
|
+
# @return [String]
|
|
69
|
+
def transaction_url
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def cache_amount_paid
|
|
76
|
+
self.order.update_attribute(:amount_paid, self.order.payments.sum(:amount))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
end
|
|
80
|
+
end
|