├── .rspec ├── config ├── routes.rb └── locales │ └── en.yml ├── .gitignore ├── lib ├── solidus_shipstation.rb ├── spree │ ├── basic_ssl_authentication.rb │ └── shipment_notice.rb └── solidus_shipstation │ └── engine.rb ├── spec ├── lib │ ├── solidus_shipstation_spec.rb │ ├── solidus_shipstation │ │ └── engine_spec.rb │ └── spree │ │ └── shipment_notice_spec.rb ├── support │ └── shipment_helper.rb ├── spec_helper.rb ├── models │ └── spree │ │ └── shipment_spec.rb ├── controllers │ └── spree │ │ └── shipstation_controller_spec.rb └── fixtures │ └── shipstation_xml_schema.xsd ├── app ├── helpers │ └── spree │ │ ├── date_param_helper.rb │ │ └── export_helper.rb ├── models │ └── spree │ │ └── shipment_decorator.rb ├── controllers │ └── spree │ │ └── shipstation_controller.rb └── views │ └── spree │ └── shipstation │ └── export.xml.builder ├── script └── rails ├── Versionfile ├── Rakefile ├── Gemfile ├── Guardfile ├── .travis.yml ├── .rubocop.yml ├── solidus_shipstation.gemspec ├── LICENSE └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format documentation 3 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Spree::Core::Engine.routes.draw do 2 | get '/shipstation' => 'shipstation#export' 3 | post '/shipstation' => 'shipstation#shipnotify' 4 | end 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | \#* 2 | *~ 3 | .#* 4 | .DS_Store 5 | .idea 6 | .project 7 | .sass-cache 8 | coverage 9 | Gemfile.lock 10 | tmp 11 | nbproject 12 | pkg 13 | *.swp 14 | spec/dummy 15 | .bundle 16 | .ruby-version 17 | .ruby-gemset 18 | .rvmrc 19 | -------------------------------------------------------------------------------- /lib/solidus_shipstation.rb: -------------------------------------------------------------------------------- 1 | require 'solidus_core' 2 | require 'solidus_support' 3 | require 'solidus_shipstation/engine' 4 | require 'spree/shipment_notice' 5 | 6 | module SolidusShipstation 7 | 8 | VERSION = '1.0.0'.freeze 9 | 10 | end 11 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | shipment_not_found: Shipment %{number} was not found 4 | import_tracking_error: "Tracking number cannot be imported. Error: %{error}" 5 | capture_payment_error: "Error in capture of payment for order %{number}. Error: %{error}" 6 | -------------------------------------------------------------------------------- /spec/lib/solidus_shipstation_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SolidusShipstation do 4 | 5 | describe 'VERSION' do 6 | it 'is defined' do 7 | expect(SolidusShipstation::VERSION).to be_present 8 | end 9 | end 10 | 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/shipment_helper.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | 3 | module ShipmentHelper 4 | 5 | def create_shipment(options = {}) 6 | FactoryBot.create(:shipment, options).tap do |shipment| 7 | shipment.update_column(:state, options[:state]) if options[:state] 8 | end 9 | end 10 | 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /app/helpers/spree/date_param_helper.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | 3 | module DateParamHelper 4 | 5 | DATE_FORMAT = '%m/%d/%Y %H:%M %Z'.freeze 6 | 7 | private 8 | 9 | def date_param(name) 10 | return if params[name].nil? 11 | Time.strptime(params[name] + ' UTC', DATE_FORMAT) 12 | end 13 | 14 | end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /script/rails: -------------------------------------------------------------------------------- 1 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. 2 | 3 | ENGINE_ROOT = File.expand_path('../..', __FILE__) 4 | ENGINE_PATH = File.expand_path('../../lib/solidus_ship_station/engine', __FILE__) 5 | 6 | require 'rails/all' 7 | require 'rails/engine/commands' 8 | -------------------------------------------------------------------------------- /Versionfile: -------------------------------------------------------------------------------- 1 | # This file is used to designate compatibilty with different versions of Spree 2 | # Please see http://spreecommerce.com/documentation/extensions.html#versionfile for details 3 | 4 | # Examples 5 | # 6 | # '1.2.x' => { :branch => 'master' } 7 | # '1.1.x' => { :branch => '1-1-stable' } 8 | # '1.0.x' => { :branch => '1-0-stable' } 9 | # '0.70.x' => { :branch => '0-70-stable' } 10 | # '0.40.x' => { :tag => 'v1.0.0', :version => '1.0.0' } 11 | 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rspec/core/rake_task' 5 | require 'spree/testing_support/extension_rake' 6 | 7 | RSpec::Core::RakeTask.new 8 | 9 | task :default do 10 | if Dir["spec/dummy"].empty? 11 | Rake::Task[:test_app].invoke 12 | Dir.chdir("../../") 13 | end 14 | Rake::Task[:spec].invoke 15 | end 16 | 17 | desc 'Generates a dummy app for testing' 18 | task :test_app do 19 | ENV['LIB_NAME'] = 'solidus_shipstation' 20 | Rake::Task['extension:test_app'].invoke 21 | end 22 | -------------------------------------------------------------------------------- /app/models/spree/shipment_decorator.rb: -------------------------------------------------------------------------------- 1 | Spree::Shipment.class_eval do 2 | def self.exportable 3 | query = order(:updated_at).joins(:order).merge(Spree::Order.complete).where.not(spree_shipments: { state: 'canceled' }) 4 | query = query.ready unless Spree::Config.shipstation_capture_at_notification 5 | query 6 | end 7 | 8 | def self.between(from, to) 9 | joins(:order).where( 10 | '(spree_shipments.updated_at > ? AND spree_shipments.updated_at < ?) OR 11 | (spree_orders.updated_at > ? AND spree_orders.updated_at < ?)', 12 | from, to, from, to 13 | ) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/lib/solidus_shipstation/engine_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SolidusShipstation::Engine do 4 | 5 | describe 'configuration methods' do 6 | it 'creates Spree::Config methods', :aggregate_failures do 7 | expect(Spree::Config).to respond_to(:shipstation_username) 8 | expect(Spree::Config).to respond_to(:shipstation_password) 9 | expect(Spree::Config).to respond_to(:shipstation_weight_units) 10 | expect(Spree::Config).to respond_to(:shipstation_ssl_encrypted) 11 | expect(Spree::Config).to respond_to(:shipstation_capture_at_notification) 12 | end 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/spree/basic_ssl_authentication.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | 3 | module BasicSslAuthentication 4 | 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | force_ssl if: :ssl_configured? 9 | before_action :authenticate 10 | end 11 | 12 | protected 13 | 14 | def authenticate 15 | authenticate_or_request_with_http_basic do |username, password| 16 | username == Spree::Config.shipstation_username && password == Spree::Config.shipstation_password 17 | end 18 | end 19 | 20 | private 21 | 22 | def ssl_configured? 23 | Spree::Config.shipstation_ssl_encrypted 24 | end 25 | 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | branch = ENV.fetch('SOLIDUS_BRANCH', 'master') 4 | gem "solidus", github: "solidusio/solidus", branch: branch 5 | gem 'guard', require: false 6 | gem 'guard-rspec', require: false 7 | gem 'pry-rails', require: false 8 | gem 'codeclimate-test-reporter', group: :test, require: nil 9 | 10 | gem 'pg', '~> 0.21' 11 | gem 'mysql2', '~> 0.4.10' 12 | 13 | group :development, :test do 14 | if branch == 'master' || branch >= "v2.0" 15 | gem "rails-controller-testing" 16 | else 17 | gem "rails_test_params_backport" 18 | end 19 | 20 | if branch < "v2.5" 21 | gem 'factory_bot', '4.10.0' 22 | else 23 | gem 'factory_bot', '> 4.10.0' 24 | end 25 | end 26 | 27 | 28 | gemspec 29 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec', cli: '--color' do 2 | watch('spec/spec_helper.rb') { 'spec' } 3 | watch('config/routes.rb') { 'spec/controllers' } 4 | watch('app/controllers/application_controller.rb') { 'spec/controllers' } 5 | watch(%r{^spec/(.+)_spec\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 6 | watch(%r{^app/(.+)_decorator\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 7 | watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 8 | watch(%r{^app/(.*)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" } 9 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 10 | watch(%r{^app/controllers/(.+)_(controller)\.rb$}) do |m| 11 | ["spec/routing/#{m[1]}_routing_spec.rb", 12 | "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", 13 | "spec/acceptance/#{m[1]}_spec.rb"] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/helpers/spree/export_helper.rb: -------------------------------------------------------------------------------- 1 | require 'builder' 2 | 3 | module Spree 4 | 5 | module ExportHelper 6 | 7 | DATE_FORMAT = '%m/%d/%Y %H:%M'.freeze 8 | 9 | # rubocop:disable all 10 | def self.address(xml, order, type) 11 | name = "#{type.to_s.titleize}To" 12 | address = order.send("#{type}_address") 13 | 14 | xml.__send__(name) { 15 | xml.Name address.full_name 16 | xml.Company address.company 17 | 18 | if type == :ship 19 | xml.Address1 address.address1 20 | xml.Address2 address.address2 21 | xml.City address.city 22 | xml.State address.state ? address.state.abbr : address.state_name 23 | xml.PostalCode address.zipcode 24 | xml.Country address.country.iso 25 | end 26 | 27 | xml.Phone address.phone 28 | } 29 | end 30 | # rubocop:enable all 31 | 32 | end 33 | 34 | end 35 | -------------------------------------------------------------------------------- /app/controllers/spree/shipstation_controller.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | 3 | class ShipstationController < Spree::BaseController 4 | 5 | include Spree::BasicSslAuthentication 6 | include Spree::DateParamHelper 7 | 8 | protect_from_forgery with: :null_session, only: [:shipnotify] 9 | 10 | def export 11 | @shipments = Spree::Shipment.exportable 12 | .between(date_param(:start_date), 13 | date_param(:end_date)) 14 | .page(params[:page]) 15 | .per(50) 16 | 17 | respond_to do |format| 18 | format.xml { render 'spree/shipstation/export', layout: false } 19 | end 20 | end 21 | 22 | # TODO: log when request are succeeding and failing 23 | def shipnotify 24 | notice = Spree::ShipmentNotice.new(params) 25 | 26 | if notice.apply 27 | head :ok 28 | else 29 | head :bad_request 30 | end 31 | end 32 | 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /lib/solidus_shipstation/engine.rb: -------------------------------------------------------------------------------- 1 | module SolidusShipstation 2 | 3 | class Engine < Rails::Engine 4 | 5 | engine_name 'solidus_shipstation' 6 | config.autoload_paths += %W(#{config.root}/lib) 7 | 8 | # use rspec for tests 9 | config.generators do |g| 10 | g.test_framework :rspec 11 | end 12 | 13 | initializer 'solidus.shipstation.preferences', before: :load_config_initializers do |_app| 14 | Spree::AppConfiguration.class_eval do 15 | preference :shipstation_username, :string 16 | preference :shipstation_password, :string 17 | preference :shipstation_weight_units, :string 18 | preference :shipstation_ssl_encrypted, :boolean, default: true 19 | preference :shipstation_capture_at_notification, :boolean, default: false 20 | end 21 | end 22 | 23 | def self.activate 24 | Dir.glob(File.join(File.dirname(__FILE__), '../../app/**/*_decorator*.rb')) do |c| 25 | Rails.configuration.cache_classes ? require(c) : load(c) 26 | end 27 | end 28 | 29 | config.to_prepare(&method(:activate).to_proc) 30 | 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.3.1 5 | before_script: 6 | - bundle exec rake test_app 7 | env: 8 | matrix: 9 | - SOLIDUS_BRANCH=v1.1 DB=postgres 10 | - SOLIDUS_BRANCH=v1.2 DB=postgres 11 | - SOLIDUS_BRANCH=v1.3 DB=postgres 12 | - SOLIDUS_BRANCH=v1.4 DB=postgres 13 | - SOLIDUS_BRANCH=v2.0 DB=postgres 14 | - SOLIDUS_BRANCH=v2.1 DB=postgres 15 | - SOLIDUS_BRANCH=v2.2 DB=postgres 16 | - SOLIDUS_BRANCH=v2.3 DB=postgres 17 | - SOLIDUS_BRANCH=v2.4 DB=postgres 18 | - SOLIDUS_BRANCH=v2.5 DB=postgres 19 | - SOLIDUS_BRANCH=v2.6 DB=postgres 20 | - SOLIDUS_BRANCH=v2.7 DB=postgres 21 | - SOLIDUS_BRANCH=master DB=postgres 22 | - SOLIDUS_BRANCH=v1.2 DB=mysql 23 | - SOLIDUS_BRANCH=v1.3 DB=mysql 24 | - SOLIDUS_BRANCH=v1.4 DB=mysql 25 | - SOLIDUS_BRANCH=v2.0 DB=mysql 26 | - SOLIDUS_BRANCH=v2.1 DB=mysql 27 | - SOLIDUS_BRANCH=v2.2 DB=mysql 28 | - SOLIDUS_BRANCH=v2.3 DB=mysql 29 | - SOLIDUS_BRANCH=v2.4 DB=mysql 30 | - SOLIDUS_BRANCH=v2.5 DB=mysql 31 | - SOLIDUS_BRANCH=v2.6 DB=mysql 32 | - SOLIDUS_BRANCH=v2.7 DB=mysql 33 | - SOLIDUS_BRANCH=master DB=mysql 34 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - ".bundle/**/*" # Auto-generated 4 | - "bin/**/*" # Auto-generated 5 | - "vendor/**/*" # We cannot solve the world's problems 6 | - "spec/dummy/**/*" # We cannot solve the world's problems 7 | - "node_modules/**/*" 8 | Rails: 9 | Enabled: true 10 | 11 | Lint/HandleExceptions: 12 | Exclude: 13 | - "config/unicorn/*" 14 | 15 | Metrics/AbcSize: 16 | Max: 25 17 | 18 | Metrics/LineLength: 19 | Max: 120 20 | 21 | Metrics/MethodLength: 22 | Max: 20 23 | 24 | Style/ClassAndModuleChildren: 25 | Exclude: 26 | - "app/controllers/**/*" # We generally use compact style here 27 | 28 | Style/EmptyLinesAroundBlockBody: 29 | Exclude: 30 | # These are naturally DSL-y, and so let's be lenient. 31 | - "spec/**/*" 32 | - "lib/tasks/*.rake" 33 | 34 | Style/EmptyLinesAroundClassBody: 35 | EnforcedStyle: empty_lines 36 | 37 | Style/EmptyLinesAroundModuleBody: 38 | EnforcedStyle: empty_lines 39 | 40 | Style/SignalException: 41 | EnforcedStyle: only_raise 42 | 43 | Style/StringLiterals: 44 | EnforcedStyle: single_quotes 45 | 46 | Style/TrailingCommaInLiteral: 47 | EnforcedStyleForMultiline: comma 48 | 49 | Style/TrivialAccessors: 50 | ExactNameMatch: true 51 | 52 | Style/Documentation: 53 | Enabled: false 54 | -------------------------------------------------------------------------------- /solidus_shipstation.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | Gem::Specification.new do |s| 3 | s.platform = Gem::Platform::RUBY 4 | s.name = 'solidus_shipstation' 5 | s.version = '2.0.1' 6 | s.summary = 'Solidus/ShipStation Integration' 7 | s.description = 'Integrates ShipStation API with Solidus. Supports exporting shipments and importing tracking numbers' 8 | s.required_ruby_version = '>= 2.1.0' 9 | 10 | s.author = 'Stephen Puiszis' 11 | s.email = 'steve@tablexi.com' 12 | s.homepage = 'https://github.com/boomerdigital/solidus_shipstation' 13 | 14 | # s.files = `git ls-files`.split("\n") 15 | # s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | s.require_path = 'lib' 17 | s.requirements << 'none' 18 | 19 | s.add_dependency 'solidus_core', ' >= 1.1', '< 3' 20 | s.add_dependency 'solidus_support' 21 | 22 | s.add_development_dependency 'capybara', '~> 2.2' 23 | s.add_development_dependency 'coffee-rails', '>= 4.1' 24 | s.add_development_dependency 'factory_bot' 25 | s.add_development_dependency 'database_cleaner' 26 | s.add_development_dependency 'timecop' 27 | s.add_development_dependency 'ffaker' 28 | s.add_development_dependency 'rubocop' 29 | s.add_development_dependency 'rspec-rails', '~> 3' 30 | s.add_development_dependency 'sass-rails' 31 | s.add_development_dependency 'sqlite3' 32 | s.add_development_dependency 'rspec-xsd' 33 | s.add_development_dependency 'simplecov' 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 [name of plugin creator] 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name Spree nor the names of its contributors may be used to 13 | endorse or promote products derived from this software without specific 14 | prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 20 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 22 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 23 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 25 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /app/views/spree/shipstation/export.xml.builder: -------------------------------------------------------------------------------- 1 | xml = Builder::XmlMarkup.new 2 | xml.instruct! 3 | xml.Orders(pages: (@shipments.total_count/50.0).ceil) { 4 | @shipments.each do |shipment| 5 | order = shipment.order 6 | 7 | xml.Order { 8 | xml.OrderID shipment.id 9 | xml.OrderNumber shipment.number # do not use shipment.order.number as this presents lookup issues 10 | xml.OrderDate order.completed_at.strftime(Spree::ExportHelper::DATE_FORMAT) 11 | xml.OrderStatus shipment.state 12 | xml.LastModified [order.completed_at, shipment.updated_at].max.strftime(Spree::ExportHelper::DATE_FORMAT) 13 | xml.ShippingMethod shipment.shipping_method.try(:name) 14 | xml.OrderTotal order.total 15 | xml.TaxAmount order.tax_total 16 | xml.ShippingAmount order.ship_total 17 | xml.CustomField1 order.number 18 | 19 | =begin 20 | if order.gift? 21 | xml.Gift 22 | xml.GiftMessage 23 | end 24 | =end 25 | 26 | xml.Customer { 27 | xml.CustomerCode order.email.slice(0, 50) 28 | Spree::ExportHelper.address(xml, order, :bill) 29 | Spree::ExportHelper.address(xml, order, :ship) 30 | } 31 | xml.Items { 32 | shipment.line_items.each do |line| 33 | variant = line.variant 34 | xml.Item { 35 | xml.SKU variant.sku 36 | xml.Name [variant.product.name, variant.options_text].join(' ') 37 | xml.ImageUrl variant.images.first.try(:attachment).try(:url) 38 | xml.Weight variant.weight.to_f 39 | xml.WeightUnits Spree::Config.shipstation_weight_units 40 | xml.Quantity line.quantity 41 | xml.UnitPrice line.price 42 | 43 | if variant.option_values.present? 44 | xml.Options { 45 | variant.option_values.each do |value| 46 | xml.Option { 47 | xml.Name value.option_type.presentation 48 | xml.Value value.name 49 | } 50 | end 51 | } 52 | end 53 | } 54 | end 55 | } 56 | } 57 | end 58 | } 59 | -------------------------------------------------------------------------------- /lib/spree/shipment_notice.rb: -------------------------------------------------------------------------------- 1 | module Spree 2 | 3 | class ShipmentNotice 4 | 5 | attr_reader :error, :number, :tracking, :shipment 6 | 7 | def initialize(params) 8 | @number = params[:order_number] 9 | @tracking = params[:tracking_number] 10 | end 11 | 12 | def apply 13 | find_shipment 14 | 15 | unless shipment 16 | log_not_found 17 | return false 18 | end 19 | 20 | unless capture_payments! 21 | log_not_paid 22 | return false 23 | end 24 | 25 | ship_it! 26 | rescue => e 27 | handle_error(e) 28 | end 29 | 30 | private 31 | 32 | def capture_payments! 33 | order = shipment.order 34 | return true if order.paid? 35 | 36 | # We try to capture payments if flag is set 37 | if Spree::Config.shipstation_capture_at_notification 38 | process_payments!(order) 39 | else 40 | order.errors.add(:base, 'Capture is not enabled and order is not paid') 41 | false 42 | end 43 | end 44 | 45 | def process_payments!(order) 46 | order.payments.pending.each(&:capture!) 47 | rescue Core::GatewayError => e 48 | order.errors.add(:base, e.message) and return false 49 | end 50 | 51 | # TODO: add documentation 52 | # => 53 | def find_shipment 54 | @shipment = Spree::Shipment.find_by(number: number) 55 | end 56 | 57 | # TODO: add documentation 58 | # => true 59 | def ship_it! 60 | shipment.update_attribute(:tracking, tracking) 61 | 62 | unless shipment.shipped? 63 | shipment.reload.ship! 64 | shipment.touch :shipped_at 65 | shipment.order.update! 66 | end 67 | 68 | true 69 | end 70 | 71 | def log_not_found 72 | @error = I18n.t(:shipment_not_found, number: number) 73 | Rails.logger.error(@error) 74 | end 75 | 76 | def log_not_paid 77 | @error = I18n.t(:capture_payment_error, 78 | number: number, 79 | error: shipment.order.errors.full_messages.join(' ')) 80 | Rails.logger.error(@error) 81 | end 82 | 83 | def handle_error(error) 84 | @error = I18n.t(:import_tracking_error, error: error.to_s) 85 | Rails.logger.error(@error) 86 | 87 | false 88 | end 89 | 90 | end 91 | 92 | end 93 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # Run Coverage report 2 | require 'simplecov' 3 | SimpleCov.start do 4 | add_filter 'spec/dummy' 5 | add_group 'Controllers', 'app/controllers' 6 | add_group 'Helpers', 'app/helpers' 7 | add_group 'Mailers', 'app/mailers' 8 | add_group 'Models', 'app/models' 9 | add_group 'Views', 'app/views' 10 | add_group 'Libraries', 'lib' 11 | end 12 | 13 | # Configure Rails Environment 14 | ENV['RAILS_ENV'] = 'test' 15 | 16 | require File.expand_path('../dummy/config/environment.rb', __FILE__) 17 | 18 | require 'rspec/rails' 19 | require 'factory_bot' 20 | require 'ffaker' 21 | require 'database_cleaner' 22 | require 'rspec/xsd' 23 | FactoryBot.find_definitions 24 | 25 | # Requires supporting ruby files with custom matchers and macros, etc, 26 | # in spec/support/ and its subdirectories. 27 | Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each { |f| require f } 28 | 29 | # Requires factories defined in spree_core 30 | require 'spree/testing_support/factories' 31 | require 'spree/testing_support/controller_requests' 32 | require 'spree/testing_support/authorization_helpers' 33 | 34 | RSpec.configure do |config| 35 | config.include FactoryBot::Syntax::Methods 36 | config.include RSpec::XSD 37 | config.include Spree::ShipmentHelper 38 | config.filter_run :focus 39 | config.run_all_when_everything_filtered = true 40 | 41 | # == URL Helpers 42 | # 43 | # Allows access to Spree's routes in specs: 44 | # 45 | # visit spree.admin_path 46 | # current_path.should eql(spree.products_path) 47 | # config.include Spree::Core::UrlHelpers 48 | 49 | # == Mock Framework 50 | # 51 | # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line: 52 | # 53 | # config.mock_with :mocha 54 | # config.mock_with :flexmock 55 | # config.mock_with :rr 56 | config.mock_with :rspec 57 | 58 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 59 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 60 | 61 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 62 | # examples within a transaction, remove the following line or assign false 63 | # instead of true. 64 | config.use_transactional_fixtures = false 65 | 66 | config.before(:suite) do 67 | DatabaseCleaner.clean_with :truncation 68 | end 69 | 70 | config.before(:each) do 71 | DatabaseCleaner.strategy = :transaction 72 | end 73 | 74 | config.before(:each) do 75 | DatabaseCleaner.start 76 | end 77 | 78 | config.after(:each) do 79 | DatabaseCleaner.clean 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/models/spree/shipment_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'timecop' 3 | 4 | describe Spree::Shipment do 5 | context 'shipment_decorator methods' do 6 | describe '.between' do 7 | let(:now) { Time.now.utc } 8 | 9 | let!(:order_1) { create(:order) } 10 | let!(:order_2) { create(:order) } 11 | let!(:order_3) { create(:order) } 12 | let!(:yesterday) { create_shipment(order: order_2) } 13 | let!(:tomorrow) { create_shipment(order: order_2) } 14 | let!(:old_shipment_recent_order_update) { create_shipment(created_at: now - 1.week, order: order_3) } 15 | let!(:active_1) { create_shipment } 16 | let!(:active_2) { create_shipment } 17 | let(:query) { Spree::Shipment.between(now - 1.hour, now + 1.hour) } 18 | 19 | # Use Timecop set #updated_at at specific times rather than manually settting them 20 | # as ActiveRecord will automatically set #updated_at timestamps even when attempting to 21 | # override them for Spree::Order instances 22 | before do 23 | Timecop.freeze(now - 1.day) do 24 | order_1.touch 25 | yesterday.touch 26 | end 27 | 28 | Timecop.freeze(now + 1.day) do 29 | order_2.touch 30 | tomorrow.touch 31 | end 32 | 33 | Timecop.freeze(now - 1.week) do 34 | order_3.touch 35 | end 36 | end 37 | 38 | it 'returns shipments based on shipments/orders updated_at within the given time range', :aggregate_failures do 39 | expect(query.count).to eq(3) 40 | expect(query).to match_array([old_shipment_recent_order_update, active_1, active_2]) 41 | end 42 | end 43 | 44 | describe '.exportable' do 45 | def create_complete_order 46 | FactoryBot.create(:order, state: 'complete', completed_at: Time.now) 47 | end 48 | 49 | let!(:incomplete_order) { create(:order, state: 'confirm') } 50 | let!(:incomplete) { create_shipment(state: 'pending', 51 | order: incomplete_order) } 52 | let!(:pending) { create_shipment(state: 'pending', 53 | order: create_complete_order) } 54 | let!(:ready) { create_shipment(state: 'ready', 55 | order: create_complete_order) } 56 | let!(:shipped) { create_shipment(state: 'shipped', 57 | order: create_complete_order) } 58 | let!(:canceled) { create_shipment(state: 'canceled', 59 | order: create_complete_order) } 60 | 61 | let(:query) { Spree::Shipment.exportable } 62 | 63 | context 'given capture at notification is false' do 64 | before { Spree::Config.shipstation_capture_at_notification = false } 65 | it 'should have the expected shipment instances', :aggregate_failures do 66 | expect(query.count).to eq(1) 67 | expect(query).to eq([ready]) 68 | expect(query).to_not include(pending) 69 | expect(query).to_not include(incomplete) 70 | end 71 | end 72 | 73 | context 'given capture at notification is true' do 74 | before { Spree::Config.shipstation_capture_at_notification = true } 75 | it 'should have the expected shipment instances', :aggregate_failures do 76 | expect(query.count).to eq(3) 77 | expect(query).to match_array([pending, ready, shipped]) 78 | expect(query).to_not include(incomplete) 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/controllers/spree/shipstation_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Spree::ShipstationController, type: :controller do 4 | render_views 5 | routes { Spree::Core::Engine.routes } 6 | 7 | before do 8 | Spree::Config.shipstation_ssl_encrypted = false # disable SSL for testing 9 | allow(described_class).to receive(:check_authorization).and_return(false) 10 | allow(described_class).to receive(:spree_current_user).and_return(FactoryBot.create(:user)) 11 | @request.env['HTTP_ACCEPT'] = 'application/xml' 12 | end 13 | 14 | context 'logged in' do 15 | 16 | before { login } 17 | 18 | describe '#export' do 19 | let(:schema) { 'spec/fixtures/shipstation_xml_schema.xsd' } 20 | let(:order) { create(:order, state: 'complete', completed_at: Time.now.utc) } 21 | let!(:shipments) { create(:shipment, state: 'ready', order: order) } 22 | let(:params) do 23 | { 24 | start_date: 1.day.ago.strftime('%m/%d/%Y %H:%M'), 25 | end_date: 1.day.from_now.strftime('%m/%d/%Y %H:%M'), 26 | format: 'xml' 27 | } 28 | end 29 | 30 | before { get :export, params: params } 31 | 32 | it 'renders successfully', :aggregate_failures do 33 | expect(response).to be_success 34 | expect(response).to render_template(:export) 35 | expect(assigns(:shipments)).to match_array([shipments]) 36 | end 37 | 38 | it 'generates valid ShipStation formatted xml' do 39 | expect(response.body).to pass_validation(schema) 40 | end 41 | end 42 | 43 | describe '#shipnotify' do 44 | # NOTE: Spree::Shipment factory creates new instances with tracking numbers, 45 | # which might not reflect reality in practice 46 | let(:order_number) { 'ABC123' } 47 | let(:tracking_number) { '123456' } 48 | let(:order) { create(:order, payment_state: 'paid') } 49 | let!(:shipment) do 50 | shipment = create(:shipment, tracking: nil, number: order_number, order: order) 51 | if shipment.has_attribute?(:address_id) 52 | shipment.address_id = order.ship_address.id 53 | end 54 | shipment.save 55 | shipment 56 | end 57 | let!(:inventory_unit) { create(:inventory_unit, order: order, shipment: shipment) } 58 | 59 | context 'shipment found' do 60 | let(:params) do 61 | { order_number: order_number, tracking_number: tracking_number } 62 | end 63 | 64 | before do 65 | allow(order).to receive(:can_ship?) { true } 66 | allow(order).to receive(:paid?) { true } 67 | shipment.ready! 68 | 69 | post :shipnotify, params: params 70 | end 71 | 72 | it 'updates the shipment', :aggregate_failures do 73 | expect(shipment.reload.tracking).to eq(tracking_number) 74 | expect(shipment.state).to eq('shipped') 75 | expect(shipment.shipped_at).to be_present 76 | end 77 | 78 | it 'responds with success' do 79 | expect(response).to be_success 80 | end 81 | end 82 | 83 | context 'shipment not found' do 84 | let(:invalid_params) do 85 | { order_number: 'JJ123456' } 86 | end 87 | before { post :shipnotify, params: invalid_params } 88 | 89 | it 'responds with failure' do 90 | expect(response.code).to eq('400') 91 | end 92 | end 93 | end 94 | end 95 | 96 | context 'not logged in' do 97 | it 'returns error' do 98 | get :export, params: { format: 'xml' } 99 | 100 | expect(response.code).to eq('401') 101 | end 102 | end 103 | 104 | def login 105 | config(username: 'mario', password: 'lemieux') 106 | 107 | user = 'mario' 108 | pw = 'lemieux' 109 | @request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, pw) 110 | end 111 | 112 | def config(options = {}) 113 | options.each do |k, v| 114 | Spree::Config.send("shipstation_#{k}=", v) 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/lib/spree/shipment_notice_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | include Spree 4 | 5 | describe Spree::ShipmentNotice do 6 | 7 | def define_shipment_notice(order, tracking_number = '1Z1231234') 8 | ShipmentNotice.new(order_number: order.shipments.first.number, 9 | tracking_number: tracking_number) 10 | end 11 | 12 | context 'capture at notification is true' do 13 | before do 14 | Spree::Config.shipstation_capture_at_notification = true 15 | end 16 | 17 | context 'successful capture' do 18 | it 'payments are completed' do 19 | order = create(:completed_order_with_pending_payment) 20 | notice = define_shipment_notice(order) 21 | expect(notice.apply).to eq(true) 22 | 23 | order.reload.shipments.each do |shipment| 24 | expect(shipment).to be_shipped 25 | end 26 | order.payments.each do |payment| 27 | expect(payment.reload).to be_completed 28 | end 29 | expect(order).to be_paid 30 | end 31 | end 32 | 33 | context 'capture fails' do 34 | it "doesn't ship the shipment" do 35 | order = create(:completed_order_with_pending_payment) 36 | notice = define_shipment_notice(order) 37 | 38 | expect_any_instance_of(Payment).to receive(:capture!).and_raise(Spree::Core::GatewayError) 39 | expect(notice.apply).to eq(false) 40 | 41 | order.reload.shipments.each do |shipment| 42 | expect(shipment).to_not be_shipped 43 | end 44 | order.payments.each do |payment| 45 | expect(payment.reload).to_not be_completed 46 | end 47 | expect(order).to_not be_paid 48 | end 49 | end 50 | end 51 | 52 | context 'capture at notification is false' do 53 | before do 54 | Spree::Config.shipstation_capture_at_notification = false 55 | end 56 | 57 | context 'order is not paid' do 58 | it "doesn't ship the shipment" do 59 | order = create(:completed_order_with_pending_payment) 60 | notice = define_shipment_notice(order) 61 | 62 | expect(notice.apply).to eq(false) 63 | 64 | order.reload.shipments.each do |shipment| 65 | expect(shipment).to_not be_shipped 66 | end 67 | order.payments.each do |payment| 68 | expect(payment.reload).to_not be_completed 69 | end 70 | expect(order).to_not be_paid 71 | expect(notice.error).to be_present 72 | end 73 | end 74 | end 75 | 76 | context '#apply' do 77 | let(:order_number) { 'S12345' } 78 | let(:tracking_number) { '1Z1231234' } 79 | let(:order) { instance_double(Order, paid?: true) } 80 | let(:shipment) { instance_double(Shipment, order: order, shipped?: false, pending?: false) } 81 | let(:notice) do 82 | ShipmentNotice.new(order_number: order_number, 83 | tracking_number: tracking_number) 84 | end 85 | 86 | context 'shipment found' do 87 | before do 88 | expect(Shipment).to receive(:find_by).with(number: order_number).and_return(shipment) 89 | end 90 | 91 | context 'transition succeeds' do 92 | before do 93 | expect(shipment).to receive(:update_attribute).with(:tracking, tracking_number) 94 | expect(shipment).to receive_message_chain(:reload, :ship!) 95 | expect(shipment).to receive(:touch).with(:shipped_at) 96 | expect(order).to receive(:update!) 97 | end 98 | 99 | it 'returns true' do 100 | expect(notice.apply).to eq(true) 101 | end 102 | 103 | end 104 | 105 | context 'transition fails' do 106 | before do 107 | expect(shipment).to receive(:update_attribute).with(:tracking, tracking_number) 108 | expect(shipment).to receive_message_chain(:reload, :ship!).and_raise('oopsie') 109 | expect(Rails.logger).to receive(:error) 110 | @result = notice.apply 111 | end 112 | 113 | it 'returns false and sets @error', :aggregate_failures do 114 | expect(@result).to eq(false) 115 | expect(notice.error).to be_present 116 | end 117 | end 118 | end 119 | 120 | context 'shipment not found' do 121 | before do 122 | expect(Shipment).to receive(:find_by).with(number: order_number).and_return(nil) 123 | expect(Rails.logger).to receive(:error) 124 | end 125 | 126 | it '#apply returns false and sets @error', :aggregate_failures do 127 | expect(notice.apply).to eq(false) 128 | expect(notice.error).to be_present 129 | end 130 | end 131 | end 132 | 133 | context 'shipment already shipped' do 134 | it 'updates #tracking and returns true' do 135 | tracking_number = 'ZN10110' 136 | order = create(:shipped_order) 137 | notice = define_shipment_notice(order, tracking_number) 138 | 139 | expect(notice.apply).to eq(true) 140 | expect(order.reload.shipments.first.tracking).to eq(tracking_number) 141 | end 142 | 143 | it 'does not update #state' do 144 | order = create(:shipped_order) 145 | notice = define_shipment_notice(order) 146 | expect { notice.apply }.to_not change { order.shipments.first.state } 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Solidus/ShipStation Integration 2 | ============================== 3 | [![TravisCI](https://travis-ci.org/boomerdigital/solidus_shipstation.svg?branch=master)](https://travis-ci.org/boomerdigital/solidus_shipstation) [![Code Climate](https://codeclimate.com/github/boomerdigital/solidus_shipstation/badges/gpa.svg)](https://codeclimate.com/github/boomerdigital/solidus_shipstation) 4 | 5 | This gem integrates [ShipStation](http://www.shipstation.com) with [Solidus](http://solidus.io), a fork of [Spree](http://spreecommerce.com). It enables ShipStation to pull shipments from the system and update tracking numbers. This integration is a fork of http://github.com/DynamoMTL/spree_shipstation to make compatible with Solidus and Rails 4.2+. 6 | 7 | See below for more documentation on the ShipStation API or how shipments and orders work in Solidus: 8 | 9 | - [ShipStation Custom Store Overview](https://help.shipstation.com/hc/en-us/articles/205928478#1c) 10 | - [ShipStation Custom Store Dev Guide](https://app.shipstation.com/content/integration/ShipStationCustomStoreDevGuide.pdf) 11 | - [Spree::Order State Machine](https://guides.spreecommerce.com/developer/orders.html#the-order-state-machine) 12 | - [Spree::Shipment States](https://guides.spreecommerce.com/developer/shipments.html#overview) 13 | 14 | 15 | ## Integration Overview 16 | 17 | `solidus_shipstation` exposes two API endpoints for ShipStation's Custom Store API to **pull** data from: 18 | 19 | **GET /shipstation** 20 | Will return an XML formatted, paginated list of order/shipment details for the requested time frame and conforms to [ShipStation's specifed XML schema](https://help.shipstation.com/hc/en-us/articles/205928478-ShipStation-Custom-Store-Development-Guide#4b). However, in practice, ShipStation will use a narrow time frame (1 day) to request order updates. You can configure how often data is pulled in the ShipStation UI. 21 | 22 | ```ruby 23 | # GET Example 24 | localhost:3000/shipstation?action=export&end_date=12%2F31%2F2016+00%3A00&format=xml&start_date=01%2F01%2F2016+00%3A00 25 | ``` 26 | 27 | **POST /shipstation** 28 | This endpoint allows ShipStation to send updates on a shipment. Below are the parameters you can expect to receive from ShipStation: 29 | 30 | ```ruby 31 | { 32 | "SS-UserName"=>"this-is-my-username", 33 | "SS-Password"=>"this-is-my-password", 34 | "action"=>"shipnotify", 35 | "order_number"=>"R1334232", 36 | "carrier"=>"UPS", 37 | "service"=>"USPS Priority", 38 | "tracking_number"=>"12312312001303", 39 | "format"=>"xml" 40 | } 41 | ``` 42 | 43 | ```ruby 44 | # POST Example 45 | localhost:3000/shipstation?action=shipnotify&order_number=ABC123&carrier=USPS&service=USPS+Priority&tracking_number=123456&format=xml 46 | ``` 47 | 48 | ## Setup 49 | 50 | Add `solidus_shipstation` to your Gemfile: 51 | 52 | ```ruby 53 | gem "solidus_shipstation", github: 'boomerdigital/solidus_shipstation' 54 | ``` 55 | 56 | Then, bundle install 57 | 58 | $ bundle 59 | 60 | Configure your ShipStation integration: 61 | 62 | ```ruby 63 | # config/initializers/spree.rb 64 | Spree.config do |config| 65 | 66 | # ShipStation Configuration 67 | # 68 | # choose between Grams, Ounces or Pounds 69 | config.shipstation_weight_units = "Grams" 70 | 71 | # ShipStation expects the endpoint to be protected by HTTP Basic Auth. Set the 72 | # username and password you desire for ShipStation to use. You should also place these 73 | # values in to your `secrets.yml` file to make they configurable between stage/production 74 | # environments for testing purposes. 75 | config.shipstation_username = "smoking_jay_cutler" 76 | config.shipstation_password = "my-awesome-password" 77 | 78 | # Turn on/off SSL requirepments for testing and development purposes 79 | config.shipstation_ssl_encrypted = !Rails.env.development? 80 | 81 | # Captures payment when ShipStation notifies a shipping label creation, defaults to false 82 | config.shipstation_capture_at_notification = false 83 | 84 | # Spree::Core related configuration 85 | # Both of these Spree::Core configuration options will affect which shipment records 86 | # are pulled by ShipStation 87 | config.require_payment_to_ship = true # false if not using auto_capture for payment gateways, defaults to true 88 | config.track_inventory_levels = true # false if not using inventory tracking features, defaults to true 89 | end 90 | ``` 91 | 92 | ### Configuring ShipStation 93 | 94 | To configure or create a ShipStation store, go to **Settings** and select **Stores**. Then click **Add Store**, scroll down and choose the **Custom Store** option. 95 | 96 | - For **Username**, enter the username defined in your config 97 | - For **Password**, enter the password defined in your config 98 | - For **URL to custom page**, enter your URL: `https://mydomain.com/shipstation.xml` 99 | 100 | There are five primary shipment states for an order/shipment in ShipStation. Order is ShipStation's terminology, Solidus uses Shipments. These states do not necessarily align with Solidus, but in the store configuration you can create a mapping for your specific needs. 101 | 102 | ShipStation mapping depends on your store's configuration. Please see the notes above regarding `config/initializers/spree.rb` and adjust your states accordingly. 103 | 104 | ShipStation Status Title | ShipStation Status | Spree::Shipment#state 105 | -------------------------|--------------------|----------------- 106 | Awaiting Payment | unpaid | pending (won't appear in API response) 107 | Awaiting Shipment | paid | ready 108 | Shipped | shipped | shipped 109 | Cancelled | cancelled | cancelled 110 | On-Hold | on-hold | pending (won't appear in API response) 111 | 112 | ### Payment Capture 113 | 114 | By default the shipments exported are only the ones that have the state of `ready`, for Spree that means 115 | that the shipment has backordered inventory units and the order is paid for. By setting 116 | `require_payment_to_ship` to `false` and `shipstation_capture_at_notification` to `true` 117 | this extension will export shipments that are in the state of `pending` and will 118 | try to capture payments when a shipnotify notification is received. 119 | 120 | ## Caveats 121 | 122 | 1. Removed [#send_shipped_email](https://github.com/DynamoMTL/spree_shipstation/blob/master/app/models/spree/shipment_decorator.rb#L9), which was previously available in `spree_shipstation` 123 | 2. If you change the shipping method of an order in ShipStation, the change will not be reflected in Spree and the tracking link might not work properly. 124 | 3. Removed the ability to use `Spree::Order.number` as the ShipStation order number. We now use `Spree::Shipment.number`. This was previously available in `spree_shipstation` 125 | 4. When capture of payments is enabled any error will prevent the update of the tracking number. 126 | 127 | ## Testing 128 | 129 | Be sure to bundle your dependencies and then create a dummy test app for the specs to run against. 130 | 131 | $ bundle 132 | $ bundle exec rake test_app 133 | 134 | To run tests: 135 | 136 | $ bundle exec rspec spec 137 | 138 | To run tests with guard: 139 | 140 | $ bundle exec guard 141 | 142 | 143 | ## Contributing 144 | 145 | 1. Fork it 146 | 2. Create your feature branch (`git checkout -b my-new-feature`) 147 | 3. Commit your changes (`git commit -m 'Add some feature'`) 148 | 4. Push to the branch (`git push origin my-new-feature`) 149 | 5. Create new Pull Request 150 | 151 | ## Future Work 152 | 153 | - Improve documentation 154 | - Update legacy development patterns (ex: `class_eval`) 155 | - Update XML generation and parsing 156 | -------------------------------------------------------------------------------- /spec/fixtures/shipstation_xml_schema.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | --------------------------------------------------------------------------------