spree-shipstation 5.0.0 → 6.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 +4 -4
- data/.gitignore +0 -1
- data/CHANGELOG.md +126 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/Gemfile +10 -4
- data/README.md +125 -28
- data/Rakefile +10 -9
- data/app/controllers/spree/shipstation_controller.rb +99 -16
- data/app/helpers/spree/shipstation/export_helper.rb +21 -10
- data/app/models/spree/integrations/shipstation.rb +64 -25
- data/app/presenters/spree/shipstation/export/item_presenter.rb +54 -10
- data/app/presenters/spree/shipstation/export/order_presenter.rb +87 -17
- data/app/presenters/spree/shipstation/export/weight.rb +20 -1
- data/app/views/spree/shipstation/export.xml.builder +9 -7
- data/config/initializers/spree.rb +5 -1
- data/config/locales/en.yml +4 -5
- data/config/routes.rb +12 -2
- data/lib/spree/shipstation/engine.rb +4 -13
- data/lib/spree/shipstation/errors.rb +52 -2
- data/lib/spree/shipstation/export_query.rb +136 -0
- data/lib/spree/shipstation/shipment_notice.rb +179 -28
- data/lib/spree/shipstation/testing_support/factories/shipstation_integration.rb +2 -0
- data/lib/spree/shipstation/version.rb +8 -2
- data/lib/spree/shipstation.rb +1 -1
- data/spree-shipstation.gemspec +8 -7
- metadata +11 -41
- data/.github/stale.yml +0 -17
- data/.github/workflows/lint.yml +0 -24
- data/.github/workflows/security.yml +0 -41
- data/.github/workflows/test.yml +0 -32
- data/Appraisals +0 -20
- data/CLAUDE.md +0 -95
- data/app/assets/config/spree_shipstation_manifest.js +0 -1
- data/app/models/spree/shipment_decorator.rb +0 -28
- data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +0 -6
- data/gemfiles/spree_5_2.gemfile +0 -18
- data/gemfiles/spree_5_3.gemfile +0 -18
- data/gemfiles/spree_5_4.gemfile +0 -18
|
@@ -1,31 +1,68 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Spree
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
##
|
|
5
|
+
# HTTP endpoints ShipStation polls for ready parcels and posts ship notices to.
|
|
6
|
+
#
|
|
7
|
+
# Every request is answered for one ShipStation integration, and so for one
|
|
8
|
+
# store. The bare +/shipstation+ path uses the default store's integration;
|
|
9
|
+
# +/shipstation/:integration_id+ uses the integration with that prefixed id.
|
|
10
|
+
# Requests are authenticated with HTTP Basic against the integration's own
|
|
11
|
+
# credentials, after the integration is found: with no active integration the
|
|
12
|
+
# answer is 404, whoever asks.
|
|
13
|
+
#
|
|
14
|
+
# This is an +ActionController::Base+ rather than an API controller because
|
|
15
|
+
# the export is a Builder template, which +ActionController::API+ renders as
|
|
16
|
+
# an empty body.
|
|
17
|
+
#
|
|
18
|
+
class ShipstationController < ActionController::Base
|
|
19
|
+
include Spree::Core::ControllerHelpers::Store
|
|
6
20
|
include Pagy::Method
|
|
7
21
|
|
|
8
|
-
|
|
22
|
+
PER_PAGE = 50
|
|
23
|
+
private_constant :PER_PAGE
|
|
9
24
|
|
|
10
|
-
|
|
25
|
+
# A ship notice body is a few kilobytes. A larger one is not parsed.
|
|
26
|
+
MAX_NOTICE_BYTES = 64.kilobytes
|
|
27
|
+
private_constant :MAX_NOTICE_BYTES
|
|
28
|
+
|
|
29
|
+
# Forgery protection is the default for this controller, so an action added
|
|
30
|
+
# later is protected until it says otherwise. ShipStation's two actions opt
|
|
31
|
+
# out: HTTP Basic is their authentication and there is no session to forge.
|
|
32
|
+
protect_from_forgery with: :exception
|
|
33
|
+
skip_forgery_protection only: %i[export shipnotify]
|
|
34
|
+
|
|
35
|
+
before_action :find_integration
|
|
11
36
|
before_action :authenticate_shipstation
|
|
12
37
|
|
|
38
|
+
##
|
|
39
|
+
# XML export of the store's ready parcels for ShipStation to pull.
|
|
40
|
+
#
|
|
41
|
+
# @return [void]
|
|
42
|
+
#
|
|
13
43
|
def export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
page: params[:page],
|
|
19
|
-
items: 50
|
|
44
|
+
query = Shipstation::ExportQuery.new(
|
|
45
|
+
store: @integration.store,
|
|
46
|
+
from: date_param(:start_date),
|
|
47
|
+
to: date_param(:end_date)
|
|
20
48
|
)
|
|
49
|
+
@pagy, @fulfillments = pagy(query.call, limit: PER_PAGE)
|
|
21
50
|
|
|
22
51
|
respond_to do |format|
|
|
23
52
|
format.xml { render layout: false }
|
|
24
53
|
end
|
|
25
54
|
end
|
|
26
55
|
|
|
56
|
+
##
|
|
57
|
+
# Applies a ship notice from ShipStation and fulfils the parcel. The notice
|
|
58
|
+
# travels in the query string. Of the XML body ShipStation also sends, only
|
|
59
|
+
# the +OrderID+ is read.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
#
|
|
27
63
|
def shipnotify
|
|
28
|
-
|
|
64
|
+
payload = notice_params.merge(order_id: notice_order_id)
|
|
65
|
+
Shipstation::ShipmentNotice.from_payload(payload, store: @integration.store).apply
|
|
29
66
|
head :ok
|
|
30
67
|
rescue Shipstation::Error => e
|
|
31
68
|
Rails.logger.error("ShipStation Notification Error: #{e.message}")
|
|
@@ -34,10 +71,52 @@ module Spree
|
|
|
34
71
|
|
|
35
72
|
private
|
|
36
73
|
|
|
37
|
-
|
|
38
|
-
|
|
74
|
+
# Only the notice's own fields. Sliced first, so the integration id, the
|
|
75
|
+
# format and ShipStation's other query params are never "unpermitted", which
|
|
76
|
+
# would raise in a host app that treats them so.
|
|
77
|
+
def notice_params
|
|
78
|
+
params.slice(:order_number, :tracking_number, :carrier)
|
|
79
|
+
.permit(:order_number, :tracking_number, :carrier).to_h
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The +OrderID+ from the notice's XML body: the fulfillment's id, as the
|
|
83
|
+
# export sent it. Parsed strictly and with Nokogiri's defaults otherwise, so
|
|
84
|
+
# no network access and no entity expansion. A body that is empty, too
|
|
85
|
+
# large or not well-formed, or an +OrderID+ that is missing or not a whole
|
|
86
|
+
# number, gives nil, and the notice is matched on its number alone.
|
|
87
|
+
def notice_order_id
|
|
88
|
+
body = request.raw_post
|
|
89
|
+
return if body.blank? || body.bytesize > MAX_NOTICE_BYTES
|
|
90
|
+
|
|
91
|
+
text = Nokogiri::XML(body, &:strict).at_xpath("/ShipNotice/OrderID")&.text.to_s.strip
|
|
92
|
+
text.to_i if text.match?(/\A\d+\z/)
|
|
93
|
+
rescue Nokogiri::XML::SyntaxError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Finds the active integration this request is for and makes its store the
|
|
98
|
+
# current one. Anything else is a 404, before the caller is asked who they are.
|
|
99
|
+
def find_integration
|
|
100
|
+
@integration = resolve_integration
|
|
101
|
+
return head :not_found unless @integration
|
|
102
|
+
|
|
103
|
+
Spree::Current.store = @integration.store
|
|
39
104
|
end
|
|
40
105
|
|
|
106
|
+
# Only the path names an integration. A query-string +integration_id+ on the
|
|
107
|
+
# bare URL is ignored, so that URL always belongs to the default store.
|
|
108
|
+
def resolve_integration
|
|
109
|
+
integration_id = request.path_parameters[:integration_id]
|
|
110
|
+
|
|
111
|
+
if integration_id.present?
|
|
112
|
+
Spree::Integrations::Shipstation.active.find_by_prefix_id(integration_id)
|
|
113
|
+
else
|
|
114
|
+
current_store.integrations.active.find_by(type: Spree::Integrations::Shipstation.sti_name)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# ShipStation sends dates as +MM/DD/YYYY HH:MM+ in UTC. One it cannot read
|
|
119
|
+
# is dropped, which widens the window rather than failing the pull.
|
|
41
120
|
def date_param(name)
|
|
42
121
|
return if params[name].blank?
|
|
43
122
|
|
|
@@ -48,9 +127,13 @@ module Spree
|
|
|
48
127
|
|
|
49
128
|
def authenticate_shipstation
|
|
50
129
|
authenticate_or_request_with_http_basic do |username, password|
|
|
51
|
-
integration
|
|
52
|
-
|
|
53
|
-
|
|
130
|
+
# Credentials the integration does not hold would match anything blank.
|
|
131
|
+
next false if @integration.preferred_username.blank? || @integration.preferred_password.blank?
|
|
132
|
+
|
|
133
|
+
# A non-short-circuiting & so both fields are always compared, and the
|
|
134
|
+
# time taken does not reveal whether the username matched.
|
|
135
|
+
ActiveSupport::SecurityUtils.secure_compare(username.to_s, @integration.preferred_username.to_s) &
|
|
136
|
+
ActiveSupport::SecurityUtils.secure_compare(password.to_s, @integration.preferred_password.to_s)
|
|
54
137
|
end
|
|
55
138
|
end
|
|
56
139
|
end
|
|
@@ -4,13 +4,30 @@ require "builder"
|
|
|
4
4
|
|
|
5
5
|
module Spree
|
|
6
6
|
module Shipstation
|
|
7
|
+
##
|
|
8
|
+
# Builds ShipStation address XML nodes for the export template.
|
|
9
|
+
#
|
|
7
10
|
module ExportHelper
|
|
8
11
|
DATE_FORMAT = "%m/%d/%Y %H:%M"
|
|
9
12
|
|
|
13
|
+
##
|
|
14
|
+
# Emits a +BillTo+ node without street-level fields.
|
|
15
|
+
#
|
|
16
|
+
# @param xml [Builder::XmlMarkup] the XML builder
|
|
17
|
+
# @param address [Spree::Address, NilClass] billing address
|
|
18
|
+
# @return [String, NilClass]
|
|
19
|
+
#
|
|
10
20
|
def self.bill_address(xml, address)
|
|
11
21
|
render_address(xml, address, "BillTo", include_street: false)
|
|
12
22
|
end
|
|
13
23
|
|
|
24
|
+
##
|
|
25
|
+
# Emits a +ShipTo+ node including street-level fields.
|
|
26
|
+
#
|
|
27
|
+
# @param xml [Builder::XmlMarkup] the XML builder
|
|
28
|
+
# @param address [Spree::Address, NilClass] shipping address
|
|
29
|
+
# @return [String, NilClass]
|
|
30
|
+
#
|
|
14
31
|
def self.ship_address(xml, address)
|
|
15
32
|
render_address(xml, address, "ShipTo", include_street: true)
|
|
16
33
|
end
|
|
@@ -22,7 +39,7 @@ module Spree
|
|
|
22
39
|
return unless address
|
|
23
40
|
|
|
24
41
|
xml.tag!(tag_name) do
|
|
25
|
-
xml.Name
|
|
42
|
+
xml.Name address.full_name
|
|
26
43
|
xml.Company address.company
|
|
27
44
|
|
|
28
45
|
if include_street
|
|
@@ -30,22 +47,16 @@ module Spree
|
|
|
30
47
|
xml.Address2 address.address2
|
|
31
48
|
xml.City address.city
|
|
32
49
|
xml.State state_from(address)
|
|
33
|
-
xml.PostalCode address.
|
|
34
|
-
xml.Country address.
|
|
50
|
+
xml.PostalCode address.postal_code
|
|
51
|
+
xml.Country address.country_code
|
|
35
52
|
end
|
|
36
53
|
|
|
37
54
|
xml.Phone address.phone
|
|
38
55
|
end
|
|
39
56
|
end
|
|
40
57
|
|
|
41
|
-
def name_from(address)
|
|
42
|
-
return address.name if address.respond_to?(:name)
|
|
43
|
-
|
|
44
|
-
address.try(:full_name)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
58
|
def state_from(address)
|
|
48
|
-
address.
|
|
59
|
+
address.state_code.presence || address.state_name
|
|
49
60
|
end
|
|
50
61
|
end
|
|
51
62
|
end
|
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module Spree
|
|
4
4
|
module Integrations
|
|
5
|
+
##
|
|
6
|
+
# Per-store ShipStation credentials registered with Spree's integration framework.
|
|
7
|
+
#
|
|
5
8
|
class Shipstation < Spree::Integration
|
|
9
|
+
I18N_SCOPE = "spree.admin.integrations.shipstation"
|
|
10
|
+
|
|
11
|
+
# Password complexity rules, checked in order. Each key names the
|
|
12
|
+
# translation shown when its pattern does not match.
|
|
13
|
+
PASSWORD_RULES = {
|
|
14
|
+
must_contain_at_least_one_special_character: /[!@#$%^&*(),.?":{}|<>]/,
|
|
15
|
+
must_contain_at_least_one_uppercase_letter: /[A-Z]/,
|
|
16
|
+
must_contain_at_least_one_number: /\d/
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
LOGO_PATH = Spree::Shipstation::Engine.root.join("app/assets/images/integration_icons/shipstation-logo.webp")
|
|
20
|
+
|
|
6
21
|
preference :username, :string
|
|
7
22
|
|
|
8
23
|
validates :preferred_username,
|
|
@@ -14,7 +29,7 @@ module Spree
|
|
|
14
29
|
validates :preferred_username,
|
|
15
30
|
format: {
|
|
16
31
|
with: /\A[a-zA-Z0-9._@+-]+\z/,
|
|
17
|
-
message:
|
|
32
|
+
message: ->(_record, _data) { I18n.t("username_chars_error", scope: I18N_SCOPE) }
|
|
18
33
|
},
|
|
19
34
|
allow_blank: true
|
|
20
35
|
|
|
@@ -24,37 +39,61 @@ module Spree
|
|
|
24
39
|
presence: true,
|
|
25
40
|
length: {minimum: 20, maximum: 60}
|
|
26
41
|
|
|
27
|
-
|
|
28
|
-
format: {
|
|
29
|
-
with: /[!@#$%^&*(),.?":{}|<>]/,
|
|
30
|
-
message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_special_character")
|
|
31
|
-
},
|
|
32
|
-
allow_blank: true
|
|
42
|
+
validate :password_complexity
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
##
|
|
45
|
+
# Dashboard group key. Matches the group Spree's own shipping integrations use.
|
|
46
|
+
#
|
|
47
|
+
# @return [String]
|
|
48
|
+
#
|
|
49
|
+
def self.integration_group
|
|
50
|
+
"shipping"
|
|
51
|
+
end
|
|
40
52
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
##
|
|
54
|
+
# Brand name shown in the dashboard's integrations list.
|
|
55
|
+
#
|
|
56
|
+
# @return [String]
|
|
57
|
+
#
|
|
58
|
+
def self.integration_name
|
|
59
|
+
I18n.t("brand_name", scope: I18N_SCOPE)
|
|
60
|
+
end
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
##
|
|
63
|
+
# The bundled logo as a +data:+ URI, so the dashboard can render it with
|
|
64
|
+
# no asset pipeline and no hosted file. Read once, then memoised.
|
|
65
|
+
#
|
|
66
|
+
# @return [String]
|
|
67
|
+
#
|
|
68
|
+
def self.logo_url
|
|
69
|
+
# Array#pack("m0") is strict Base64 without the base64 gem, which
|
|
70
|
+
# Ruby 3.4 and later no longer ship as a default gem.
|
|
71
|
+
@logo_url ||= "data:image/webp;base64,#{[LOGO_PATH.binread].pack("m0")}".freeze
|
|
50
72
|
end
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
##
|
|
75
|
+
# Link to this extension's setup guide, shown on the dashboard's gallery card.
|
|
76
|
+
#
|
|
77
|
+
# @return [String]
|
|
78
|
+
#
|
|
79
|
+
def self.docs_url
|
|
80
|
+
"https://github.com/aypex-io/spree-shipstation#readme"
|
|
54
81
|
end
|
|
55
82
|
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Adds an error for each complexity rule the password fails. Written as a
|
|
86
|
+
# method rather than three +format:+ validations because those patterns
|
|
87
|
+
# are deliberately unanchored ("contains"), which Brakeman flags.
|
|
88
|
+
def password_complexity
|
|
89
|
+
password = preferred_password
|
|
90
|
+
return if password.blank?
|
|
91
|
+
|
|
92
|
+
PASSWORD_RULES.each do |message_key, pattern|
|
|
93
|
+
next if pattern.match?(password)
|
|
94
|
+
|
|
95
|
+
errors.add(:preferred_password, I18n.t(message_key, scope: I18N_SCOPE))
|
|
96
|
+
end
|
|
58
97
|
end
|
|
59
98
|
end
|
|
60
99
|
end
|
|
@@ -3,48 +3,92 @@
|
|
|
3
3
|
module Spree
|
|
4
4
|
module Shipstation
|
|
5
5
|
module Export
|
|
6
|
-
|
|
7
|
-
#
|
|
6
|
+
##
|
|
7
|
+
# Shapes a parcel's line item (a line item plus its fulfillment items) into
|
|
8
|
+
# the values ShipStation's export XML expects for an +<Item>+.
|
|
9
|
+
#
|
|
8
10
|
class ItemPresenter
|
|
9
|
-
attr_reader :line_item, :
|
|
11
|
+
attr_reader :line_item, :fulfillment_items
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
##
|
|
14
|
+
# @param line_item [Spree::LineItem] the line item
|
|
15
|
+
# @param fulfillment_items [Array<Spree::FulfillmentItem>] the parcel's
|
|
16
|
+
# fulfillment items for that line
|
|
17
|
+
#
|
|
18
|
+
def initialize(line_item, fulfillment_items)
|
|
12
19
|
@line_item = line_item
|
|
13
|
-
@
|
|
20
|
+
@fulfillment_items = fulfillment_items
|
|
14
21
|
end
|
|
15
22
|
|
|
23
|
+
##
|
|
24
|
+
# @return [Spree::Variant]
|
|
25
|
+
#
|
|
16
26
|
def variant
|
|
17
27
|
@variant ||= line_item.variant
|
|
18
28
|
end
|
|
19
29
|
|
|
30
|
+
##
|
|
31
|
+
# @return [String]
|
|
32
|
+
#
|
|
20
33
|
def sku
|
|
21
34
|
variant.sku
|
|
22
35
|
end
|
|
23
36
|
|
|
37
|
+
##
|
|
38
|
+
# Product name plus variant options, dropping blanks.
|
|
39
|
+
#
|
|
40
|
+
# @return [String]
|
|
41
|
+
#
|
|
24
42
|
def name
|
|
25
43
|
[variant.product.name, variant.options_text].reject(&:blank?).join(" ")
|
|
26
44
|
end
|
|
27
45
|
|
|
28
|
-
|
|
29
|
-
#
|
|
46
|
+
##
|
|
47
|
+
# The +Spree::Media+ to advertise, or +nil+: the variant's primary media,
|
|
48
|
+
# else its product's. URL generation stays in the view because it depends
|
|
49
|
+
# on Rails route/URL helpers.
|
|
50
|
+
#
|
|
51
|
+
# @return [Spree::Media, NilClass]
|
|
52
|
+
#
|
|
30
53
|
def image
|
|
31
|
-
variant.
|
|
54
|
+
variant.thumbnail
|
|
32
55
|
end
|
|
33
56
|
|
|
57
|
+
##
|
|
58
|
+
# @return [Spree::Shipstation::Export::Weight]
|
|
59
|
+
#
|
|
34
60
|
def weight
|
|
35
61
|
Weight.from_variant(variant)
|
|
36
62
|
end
|
|
37
63
|
|
|
64
|
+
##
|
|
65
|
+
# The number of items to ship, summed across the line's fulfillment items.
|
|
66
|
+
#
|
|
67
|
+
# Spree stores a quantity on each +Spree::FulfillmentItem+ rather than one
|
|
68
|
+
# row per item, so counting rows under-reports any line ordered more
|
|
69
|
+
# than once.
|
|
70
|
+
#
|
|
71
|
+
# @return [Integer]
|
|
72
|
+
#
|
|
38
73
|
def quantity
|
|
39
|
-
|
|
74
|
+
fulfillment_items.sum(&:quantity)
|
|
40
75
|
end
|
|
41
76
|
|
|
77
|
+
##
|
|
78
|
+
# @return [BigDecimal]
|
|
79
|
+
#
|
|
42
80
|
def unit_price
|
|
43
81
|
line_item.price
|
|
44
82
|
end
|
|
45
83
|
|
|
84
|
+
##
|
|
85
|
+
# The variant's options as pairs of the option type's label and the
|
|
86
|
+
# option value's name, which is how ShipStation lists them.
|
|
87
|
+
#
|
|
88
|
+
# @return [Array<Array(String, String)>]
|
|
89
|
+
#
|
|
46
90
|
def option_values
|
|
47
|
-
variant.option_values
|
|
91
|
+
variant.option_values.map { |value| [value.option_type.label, value.name] }
|
|
48
92
|
end
|
|
49
93
|
end
|
|
50
94
|
end
|
|
@@ -3,80 +3,150 @@
|
|
|
3
3
|
module Spree
|
|
4
4
|
module Shipstation
|
|
5
5
|
module Export
|
|
6
|
-
|
|
7
|
-
#
|
|
6
|
+
##
|
|
7
|
+
# Shapes a single +Spree::Fulfillment+ (a parcel) into the values
|
|
8
|
+
# ShipStation's export XML expects, keeping data-massaging logic out of the
|
|
9
|
+
# Builder template.
|
|
10
|
+
#
|
|
11
|
+
# Note: ShipStation models one "Order" per parcel, so most order-level
|
|
12
|
+
# fields are derived from +fulfillment.order+ while identifiers come from
|
|
13
|
+
# the fulfillment itself (mirroring the +<OrderNumber>+ = fulfillment.number
|
|
14
|
+
# contract).
|
|
8
15
|
#
|
|
9
|
-
# Note: ShipStation models one "Order" per shipment, so most order-level
|
|
10
|
-
# fields are derived from `shipment.order` while identifiers come from the
|
|
11
|
-
# shipment itself (mirroring the <OrderNumber> = shipment.number contract).
|
|
12
16
|
class OrderPresenter
|
|
13
|
-
attr_reader :
|
|
17
|
+
attr_reader :fulfillment
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
##
|
|
20
|
+
# @param fulfillment [Spree::Fulfillment] the parcel to export
|
|
21
|
+
#
|
|
22
|
+
def initialize(fulfillment)
|
|
23
|
+
@fulfillment = fulfillment
|
|
17
24
|
end
|
|
18
25
|
|
|
26
|
+
##
|
|
27
|
+
# @return [Spree::Order]
|
|
28
|
+
#
|
|
19
29
|
def order
|
|
20
|
-
|
|
30
|
+
fulfillment.order
|
|
21
31
|
end
|
|
22
32
|
|
|
33
|
+
##
|
|
34
|
+
# @return [Integer]
|
|
35
|
+
#
|
|
23
36
|
def order_id
|
|
24
|
-
|
|
37
|
+
fulfillment.id
|
|
25
38
|
end
|
|
26
39
|
|
|
40
|
+
##
|
|
41
|
+
# The number ShipStation echoes back in the ship notice: the stored number
|
|
42
|
+
# of a parcel created before Spree 6.0, otherwise one derived from its
|
|
43
|
+
# order, such as +R1001-F1+.
|
|
44
|
+
#
|
|
45
|
+
# @return [String]
|
|
46
|
+
#
|
|
27
47
|
def order_number
|
|
28
|
-
|
|
48
|
+
fulfillment.number
|
|
29
49
|
end
|
|
30
50
|
|
|
51
|
+
##
|
|
52
|
+
# Always +ready+. The export only offers parcels that can ship now, and
|
|
53
|
+
# merchants' ShipStation status mappings key on this value.
|
|
54
|
+
#
|
|
55
|
+
# @return [String]
|
|
56
|
+
#
|
|
31
57
|
def order_status
|
|
32
|
-
|
|
58
|
+
"ready"
|
|
33
59
|
end
|
|
34
60
|
|
|
61
|
+
##
|
|
62
|
+
# @return [String, NilClass]
|
|
63
|
+
#
|
|
35
64
|
def order_date
|
|
36
65
|
format_date(order.completed_at)
|
|
37
66
|
end
|
|
38
67
|
|
|
68
|
+
##
|
|
69
|
+
# @return [String, NilClass]
|
|
70
|
+
#
|
|
39
71
|
def last_modified
|
|
40
|
-
format_date([order.completed_at,
|
|
72
|
+
format_date([order.completed_at, fulfillment.updated_at].compact.max)
|
|
41
73
|
end
|
|
42
74
|
|
|
75
|
+
##
|
|
76
|
+
# The name of the delivery method selected for the parcel.
|
|
77
|
+
#
|
|
78
|
+
# @return [String, NilClass]
|
|
79
|
+
#
|
|
43
80
|
def shipping_method_name
|
|
44
|
-
|
|
81
|
+
fulfillment.delivery_method&.name
|
|
45
82
|
end
|
|
46
83
|
|
|
84
|
+
##
|
|
85
|
+
# @return [BigDecimal]
|
|
86
|
+
#
|
|
47
87
|
def order_total
|
|
48
88
|
order.total
|
|
49
89
|
end
|
|
50
90
|
|
|
91
|
+
##
|
|
92
|
+
# @return [BigDecimal]
|
|
93
|
+
#
|
|
51
94
|
def tax_total
|
|
52
95
|
order.tax_total
|
|
53
96
|
end
|
|
54
97
|
|
|
98
|
+
##
|
|
99
|
+
# The order's delivery charge, exported as ShipStation's ShippingAmount.
|
|
100
|
+
#
|
|
101
|
+
# @return [BigDecimal]
|
|
102
|
+
#
|
|
55
103
|
def ship_total
|
|
56
|
-
order.
|
|
104
|
+
order.delivery_total
|
|
57
105
|
end
|
|
58
106
|
|
|
107
|
+
##
|
|
108
|
+
# Spree order number, exported as ShipStation CustomField1.
|
|
109
|
+
#
|
|
110
|
+
# @return [String]
|
|
111
|
+
#
|
|
59
112
|
def custom_field_1
|
|
60
113
|
order.number
|
|
61
114
|
end
|
|
62
115
|
|
|
116
|
+
##
|
|
117
|
+
# Email truncated to ShipStation's 50-character customer-code limit.
|
|
118
|
+
#
|
|
119
|
+
# @return [String, NilClass]
|
|
120
|
+
#
|
|
63
121
|
def customer_code
|
|
64
122
|
order.email&.slice(0, 50)
|
|
65
123
|
end
|
|
66
124
|
|
|
125
|
+
##
|
|
126
|
+
# @return [Spree::Address, NilClass]
|
|
127
|
+
#
|
|
67
128
|
def bill_address
|
|
68
129
|
order.bill_address
|
|
69
130
|
end
|
|
70
131
|
|
|
132
|
+
##
|
|
133
|
+
# @return [Spree::Address, NilClass]
|
|
134
|
+
#
|
|
71
135
|
def ship_address
|
|
72
136
|
order.ship_address
|
|
73
137
|
end
|
|
74
138
|
|
|
139
|
+
##
|
|
140
|
+
# One presenter per line item that still has a variant, built from the
|
|
141
|
+
# parcel's fulfillment items for that line.
|
|
142
|
+
#
|
|
143
|
+
# @return [Array<Spree::Shipstation::Export::ItemPresenter>]
|
|
144
|
+
#
|
|
75
145
|
def items
|
|
76
|
-
|
|
146
|
+
fulfillment.fulfillment_items.group_by(&:line_item).filter_map do |line_item, fulfillment_items|
|
|
77
147
|
next unless line_item.variant
|
|
78
148
|
|
|
79
|
-
ItemPresenter.new(line_item,
|
|
149
|
+
ItemPresenter.new(line_item, fulfillment_items)
|
|
80
150
|
end
|
|
81
151
|
end
|
|
82
152
|
|
|
@@ -3,19 +3,31 @@
|
|
|
3
3
|
module Spree
|
|
4
4
|
module Shipstation
|
|
5
5
|
module Export
|
|
6
|
-
|
|
6
|
+
##
|
|
7
|
+
# Value object converting a +Spree::Variant+'s weight into the
|
|
7
8
|
# (value, unit-label) pair ShipStation's export XML expects.
|
|
8
9
|
#
|
|
9
10
|
# ShipStation accepts Pounds, Ounces, and Grams; any unrecognised Spree
|
|
10
11
|
# weight unit (including a missing weight) falls back to Grams.
|
|
12
|
+
#
|
|
11
13
|
class Weight
|
|
12
14
|
attr_reader :value, :units
|
|
13
15
|
|
|
16
|
+
##
|
|
17
|
+
# @param value [Float] numeric weight
|
|
18
|
+
# @param units [String] ShipStation unit label
|
|
19
|
+
#
|
|
14
20
|
def initialize(value:, units:)
|
|
15
21
|
@value = value
|
|
16
22
|
@units = units
|
|
17
23
|
end
|
|
18
24
|
|
|
25
|
+
##
|
|
26
|
+
# Converts a variant's weight into ShipStation units.
|
|
27
|
+
#
|
|
28
|
+
# @param variant [Spree::Variant]
|
|
29
|
+
# @return [Spree::Shipstation::Export::Weight]
|
|
30
|
+
#
|
|
19
31
|
def self.from_variant(variant)
|
|
20
32
|
amount = (variant.weight || 0.0).to_f
|
|
21
33
|
|
|
@@ -31,11 +43,18 @@ module Spree
|
|
|
31
43
|
end
|
|
32
44
|
end
|
|
33
45
|
|
|
46
|
+
##
|
|
47
|
+
# @param other [Object]
|
|
48
|
+
# @return [TrueClass, FalseClass]
|
|
49
|
+
#
|
|
34
50
|
def ==(other)
|
|
35
51
|
other.is_a?(self.class) && value == other.value && units == other.units
|
|
36
52
|
end
|
|
37
53
|
alias_method :eql?, :==
|
|
38
54
|
|
|
55
|
+
##
|
|
56
|
+
# @return [Integer]
|
|
57
|
+
#
|
|
39
58
|
def hash
|
|
40
59
|
[value, units].hash
|
|
41
60
|
end
|