spree-shipstation 5.0.1 → 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 +115 -0
- data/Gemfile +8 -4
- data/README.md +119 -28
- data/Rakefile +8 -9
- data/app/controllers/spree/shipstation_controller.rb +89 -19
- data/app/helpers/spree/shipstation/export_helper.rb +4 -10
- data/app/models/spree/integrations/shipstation.rb +50 -29
- data/app/presenters/spree/shipstation/export/item_presenter.rb +20 -15
- data/app/presenters/spree/shipstation/export/order_presenter.rb +33 -19
- data/app/views/spree/shipstation/export.xml.builder +9 -7
- data/config/initializers/spree.rb +3 -1
- data/config/locales/en.yml +4 -5
- data/config/routes.rb +12 -2
- data/lib/spree/shipstation/engine.rb +2 -14
- data/lib/spree/shipstation/errors.rb +36 -4
- data/lib/spree/shipstation/export_query.rb +136 -0
- data/lib/spree/shipstation/shipment_notice.rb +162 -41
- data/lib/spree/shipstation/version.rb +3 -2
- data/lib/spree/shipstation.rb +1 -1
- data/spree-shipstation.gemspec +8 -7
- metadata +10 -42
- data/.github/stale.yml +0 -17
- data/.github/workflows/lint.yml +0 -24
- data/.github/workflows/release.yml +0 -46
- data/.github/workflows/security.yml +0 -41
- data/.github/workflows/test.yml +0 -32
- data/Appraisals +0 -20
- data/CLAUDE.md +0 -98
- data/app/assets/config/spree_shipstation_manifest.js +0 -1
- data/app/models/spree/shipment_decorator.rb +0 -32
- 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
|
@@ -6,6 +6,18 @@ module Spree
|
|
|
6
6
|
# Per-store ShipStation credentials registered with Spree's integration framework.
|
|
7
7
|
#
|
|
8
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
|
+
|
|
9
21
|
preference :username, :string
|
|
10
22
|
|
|
11
23
|
validates :preferred_username,
|
|
@@ -17,7 +29,7 @@ module Spree
|
|
|
17
29
|
validates :preferred_username,
|
|
18
30
|
format: {
|
|
19
31
|
with: /\A[a-zA-Z0-9._@+-]+\z/,
|
|
20
|
-
message:
|
|
32
|
+
message: ->(_record, _data) { I18n.t("username_chars_error", scope: I18N_SCOPE) }
|
|
21
33
|
},
|
|
22
34
|
allow_blank: true
|
|
23
35
|
|
|
@@ -27,52 +39,61 @@ module Spree
|
|
|
27
39
|
presence: true,
|
|
28
40
|
length: {minimum: 20, maximum: 60}
|
|
29
41
|
|
|
30
|
-
|
|
31
|
-
format: {
|
|
32
|
-
with: /[!@#$%^&*(),.?":{}|<>]/,
|
|
33
|
-
message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_special_character")
|
|
34
|
-
},
|
|
35
|
-
allow_blank: true
|
|
36
|
-
|
|
37
|
-
validates :preferred_password,
|
|
38
|
-
format: {
|
|
39
|
-
with: /[A-Z]/,
|
|
40
|
-
message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_uppercase_letter")
|
|
41
|
-
},
|
|
42
|
-
allow_blank: true
|
|
43
|
-
|
|
44
|
-
validates :preferred_password,
|
|
45
|
-
format: {
|
|
46
|
-
with: /\d/,
|
|
47
|
-
message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_number")
|
|
48
|
-
},
|
|
49
|
-
allow_blank: true
|
|
42
|
+
validate :password_complexity
|
|
50
43
|
|
|
51
44
|
##
|
|
52
|
-
#
|
|
45
|
+
# Dashboard group key. Matches the group Spree's own shipping integrations use.
|
|
53
46
|
#
|
|
54
47
|
# @return [String]
|
|
55
48
|
#
|
|
56
49
|
def self.integration_group
|
|
57
|
-
"
|
|
50
|
+
"shipping"
|
|
58
51
|
end
|
|
59
52
|
|
|
60
53
|
##
|
|
61
|
-
#
|
|
54
|
+
# Brand name shown in the dashboard's integrations list.
|
|
62
55
|
#
|
|
63
56
|
# @return [String]
|
|
64
57
|
#
|
|
65
|
-
def self.
|
|
66
|
-
|
|
58
|
+
def self.integration_name
|
|
59
|
+
I18n.t("brand_name", scope: I18N_SCOPE)
|
|
67
60
|
end
|
|
68
61
|
|
|
69
62
|
##
|
|
70
|
-
#
|
|
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.
|
|
71
65
|
#
|
|
72
66
|
# @return [String]
|
|
73
67
|
#
|
|
74
|
-
def self.
|
|
75
|
-
|
|
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
|
|
72
|
+
end
|
|
73
|
+
|
|
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"
|
|
81
|
+
end
|
|
82
|
+
|
|
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
|
|
76
97
|
end
|
|
77
98
|
end
|
|
78
99
|
end
|
|
@@ -4,19 +4,20 @@ module Spree
|
|
|
4
4
|
module Shipstation
|
|
5
5
|
module Export
|
|
6
6
|
##
|
|
7
|
-
# Shapes a
|
|
8
|
-
# values ShipStation's export XML expects for an +<Item>+.
|
|
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
9
|
#
|
|
10
10
|
class ItemPresenter
|
|
11
|
-
attr_reader :line_item, :
|
|
11
|
+
attr_reader :line_item, :fulfillment_items
|
|
12
12
|
|
|
13
13
|
##
|
|
14
14
|
# @param line_item [Spree::LineItem] the line item
|
|
15
|
-
# @param
|
|
15
|
+
# @param fulfillment_items [Array<Spree::FulfillmentItem>] the parcel's
|
|
16
|
+
# fulfillment items for that line
|
|
16
17
|
#
|
|
17
|
-
def initialize(line_item,
|
|
18
|
+
def initialize(line_item, fulfillment_items)
|
|
18
19
|
@line_item = line_item
|
|
19
|
-
@
|
|
20
|
+
@fulfillment_items = fulfillment_items
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
##
|
|
@@ -43,13 +44,14 @@ module Spree
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
##
|
|
46
|
-
# The +Spree::
|
|
47
|
-
# view because it depends
|
|
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.
|
|
48
50
|
#
|
|
49
|
-
# @return [Spree::
|
|
51
|
+
# @return [Spree::Media, NilClass]
|
|
50
52
|
#
|
|
51
53
|
def image
|
|
52
|
-
variant.
|
|
54
|
+
variant.thumbnail
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
##
|
|
@@ -60,16 +62,16 @@ module Spree
|
|
|
60
62
|
end
|
|
61
63
|
|
|
62
64
|
##
|
|
63
|
-
# The number of items to ship, summed across the line's
|
|
65
|
+
# The number of items to ship, summed across the line's fulfillment items.
|
|
64
66
|
#
|
|
65
|
-
# Spree stores a quantity on each +Spree::
|
|
67
|
+
# Spree stores a quantity on each +Spree::FulfillmentItem+ rather than one
|
|
66
68
|
# row per item, so counting rows under-reports any line ordered more
|
|
67
69
|
# than once.
|
|
68
70
|
#
|
|
69
71
|
# @return [Integer]
|
|
70
72
|
#
|
|
71
73
|
def quantity
|
|
72
|
-
|
|
74
|
+
fulfillment_items.sum(&:quantity)
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
##
|
|
@@ -80,10 +82,13 @@ module Spree
|
|
|
80
82
|
end
|
|
81
83
|
|
|
82
84
|
##
|
|
83
|
-
#
|
|
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)>]
|
|
84
89
|
#
|
|
85
90
|
def option_values
|
|
86
|
-
variant.option_values
|
|
91
|
+
variant.option_values.map { |value| [value.option_type.label, value.name] }
|
|
87
92
|
end
|
|
88
93
|
end
|
|
89
94
|
end
|
|
@@ -4,49 +4,58 @@ module Spree
|
|
|
4
4
|
module Shipstation
|
|
5
5
|
module Export
|
|
6
6
|
##
|
|
7
|
-
# Shapes a single +Spree::
|
|
8
|
-
# expects, keeping data-massaging logic out of the
|
|
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.
|
|
9
10
|
#
|
|
10
|
-
# Note: ShipStation models one "Order" per
|
|
11
|
-
# fields are derived from +
|
|
12
|
-
#
|
|
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).
|
|
13
15
|
#
|
|
14
16
|
class OrderPresenter
|
|
15
|
-
attr_reader :
|
|
17
|
+
attr_reader :fulfillment
|
|
16
18
|
|
|
17
19
|
##
|
|
18
|
-
# @param
|
|
20
|
+
# @param fulfillment [Spree::Fulfillment] the parcel to export
|
|
19
21
|
#
|
|
20
|
-
def initialize(
|
|
21
|
-
@
|
|
22
|
+
def initialize(fulfillment)
|
|
23
|
+
@fulfillment = fulfillment
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
##
|
|
25
27
|
# @return [Spree::Order]
|
|
26
28
|
#
|
|
27
29
|
def order
|
|
28
|
-
|
|
30
|
+
fulfillment.order
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
##
|
|
32
34
|
# @return [Integer]
|
|
33
35
|
#
|
|
34
36
|
def order_id
|
|
35
|
-
|
|
37
|
+
fulfillment.id
|
|
36
38
|
end
|
|
37
39
|
|
|
38
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
|
+
#
|
|
39
45
|
# @return [String]
|
|
40
46
|
#
|
|
41
47
|
def order_number
|
|
42
|
-
|
|
48
|
+
fulfillment.number
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
##
|
|
52
|
+
# Always +ready+. The export only offers parcels that can ship now, and
|
|
53
|
+
# merchants' ShipStation status mappings key on this value.
|
|
54
|
+
#
|
|
46
55
|
# @return [String]
|
|
47
56
|
#
|
|
48
57
|
def order_status
|
|
49
|
-
|
|
58
|
+
"ready"
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
##
|
|
@@ -60,14 +69,16 @@ module Spree
|
|
|
60
69
|
# @return [String, NilClass]
|
|
61
70
|
#
|
|
62
71
|
def last_modified
|
|
63
|
-
format_date([order.completed_at,
|
|
72
|
+
format_date([order.completed_at, fulfillment.updated_at].compact.max)
|
|
64
73
|
end
|
|
65
74
|
|
|
66
75
|
##
|
|
76
|
+
# The name of the delivery method selected for the parcel.
|
|
77
|
+
#
|
|
67
78
|
# @return [String, NilClass]
|
|
68
79
|
#
|
|
69
80
|
def shipping_method_name
|
|
70
|
-
|
|
81
|
+
fulfillment.delivery_method&.name
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
##
|
|
@@ -85,10 +96,12 @@ module Spree
|
|
|
85
96
|
end
|
|
86
97
|
|
|
87
98
|
##
|
|
99
|
+
# The order's delivery charge, exported as ShipStation's ShippingAmount.
|
|
100
|
+
#
|
|
88
101
|
# @return [BigDecimal]
|
|
89
102
|
#
|
|
90
103
|
def ship_total
|
|
91
|
-
order.
|
|
104
|
+
order.delivery_total
|
|
92
105
|
end
|
|
93
106
|
|
|
94
107
|
##
|
|
@@ -124,15 +137,16 @@ module Spree
|
|
|
124
137
|
end
|
|
125
138
|
|
|
126
139
|
##
|
|
127
|
-
# One presenter per line item that still has a variant
|
|
140
|
+
# One presenter per line item that still has a variant, built from the
|
|
141
|
+
# parcel's fulfillment items for that line.
|
|
128
142
|
#
|
|
129
143
|
# @return [Array<Spree::Shipstation::Export::ItemPresenter>]
|
|
130
144
|
#
|
|
131
145
|
def items
|
|
132
|
-
|
|
146
|
+
fulfillment.fulfillment_items.group_by(&:line_item).filter_map do |line_item, fulfillment_items|
|
|
133
147
|
next unless line_item.variant
|
|
134
148
|
|
|
135
|
-
ItemPresenter.new(line_item,
|
|
149
|
+
ItemPresenter.new(line_item, fulfillment_items)
|
|
136
150
|
end
|
|
137
151
|
end
|
|
138
152
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
xml.instruct!
|
|
2
2
|
|
|
3
3
|
xml.Orders(pages: @pagy.pages) do
|
|
4
|
-
@
|
|
5
|
-
order = Spree::Shipstation::Export::OrderPresenter.new(
|
|
4
|
+
@fulfillments.each do |fulfillment|
|
|
5
|
+
order = Spree::Shipstation::Export::OrderPresenter.new(fulfillment)
|
|
6
6
|
|
|
7
7
|
xml.Order do
|
|
8
8
|
xml.OrderID order.order_id
|
|
@@ -27,13 +27,15 @@ xml.Orders(pages: @pagy.pages) do
|
|
|
27
27
|
xml.Items do
|
|
28
28
|
order.items.each do |item|
|
|
29
29
|
weight = item.weight
|
|
30
|
-
|
|
30
|
+
# The thumbnail may be a video. Its still image is the poster, and is
|
|
31
|
+
# nil for an embedded video, which has no attachment to link to.
|
|
32
|
+
still_image = item.image&.still_image
|
|
31
33
|
|
|
32
34
|
xml.Item do
|
|
33
35
|
xml.SKU item.sku
|
|
34
36
|
xml.Name item.name
|
|
35
37
|
|
|
36
|
-
image_url =
|
|
38
|
+
image_url = still_image && url_for(still_image)
|
|
37
39
|
xml.ImageUrl image_url if image_url
|
|
38
40
|
|
|
39
41
|
xml.Weight weight.value
|
|
@@ -43,10 +45,10 @@ xml.Orders(pages: @pagy.pages) do
|
|
|
43
45
|
|
|
44
46
|
if item.option_values.present?
|
|
45
47
|
xml.Options do
|
|
46
|
-
item.option_values.each do |value|
|
|
48
|
+
item.option_values.each do |name, value|
|
|
47
49
|
xml.Option do
|
|
48
|
-
xml.Name
|
|
49
|
-
xml.Value value
|
|
50
|
+
xml.Name name
|
|
51
|
+
xml.Value value
|
|
50
52
|
end
|
|
51
53
|
end
|
|
52
54
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Spree resolves registered entries to classes itself, so the class name string
|
|
4
|
+
# avoids loading the model before the app has finished booting.
|
|
3
5
|
Rails.application.config.after_initialize do
|
|
4
|
-
|
|
6
|
+
Spree.integrations << "Spree::Integrations::Shipstation"
|
|
5
7
|
end
|
data/config/locales/en.yml
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
en:
|
|
3
3
|
spree:
|
|
4
|
+
integrations:
|
|
5
|
+
shipstation:
|
|
6
|
+
description: Integrate with ShipStation to automate your shipping workflows from a single dashboard.
|
|
4
7
|
admin:
|
|
5
8
|
integrations:
|
|
6
9
|
shipstation:
|
|
10
|
+
brand_name: ShipStation
|
|
7
11
|
must_contain_at_least_one_special_character: must contain at least one special character
|
|
8
12
|
must_contain_at_least_one_uppercase_letter: must contain at least one uppercase letter
|
|
9
13
|
must_contain_at_least_one_number: must contain at least one number
|
|
10
14
|
username_chars_error: can only contain letters, numbers, and . _ @ + -
|
|
11
|
-
username: Username
|
|
12
|
-
password: Password
|
|
13
|
-
brand_name: ShipStation
|
|
14
|
-
description: Integrate with ShipStation to automate your shipping workflows from a single dashboard.
|
|
15
|
-
capture_at_notification: Capture payment when shipment notification received from ShipStation
|
data/config/routes.rb
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# ShipStation pulls orders and posts ship notices to the same URL. The bare path
|
|
4
|
+
# belongs to the default store. The path that names an integration (its prefixed
|
|
5
|
+
# id) belongs to that integration's store, which is how a second store gets an
|
|
6
|
+
# endpoint of its own.
|
|
7
|
+
#
|
|
8
|
+
# The export is XML whether or not the URL ends in +.xml+. Without the default,
|
|
9
|
+
# a pull that sends a browser-like Accept header would be refused with a 406.
|
|
3
10
|
Spree::Core::Engine.add_routes do
|
|
4
|
-
get "/shipstation", to: "shipstation#export"
|
|
5
|
-
post "/shipstation", to: "shipstation#shipnotify"
|
|
11
|
+
get "/shipstation(.:format)", to: "shipstation#export", defaults: {format: "xml"}
|
|
12
|
+
post "/shipstation(.:format)", to: "shipstation#shipnotify"
|
|
13
|
+
|
|
14
|
+
get "/shipstation/:integration_id(.:format)", to: "shipstation#export", defaults: {format: "xml"}
|
|
15
|
+
post "/shipstation/:integration_id(.:format)", to: "shipstation#shipnotify"
|
|
6
16
|
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Spree
|
|
4
4
|
module Shipstation
|
|
5
5
|
##
|
|
6
|
-
# Rails engine
|
|
6
|
+
# Rails engine for the ShipStation integration. It makes the gem's +app+ and
|
|
7
|
+
# +config+ directories part of the host application.
|
|
7
8
|
#
|
|
8
9
|
class Engine < ::Rails::Engine
|
|
9
10
|
require "spree/core"
|
|
@@ -17,19 +18,6 @@ module Spree
|
|
|
17
18
|
config.generators do |g|
|
|
18
19
|
g.test_framework :rspec
|
|
19
20
|
end
|
|
20
|
-
|
|
21
|
-
initializer "spree_shipstation.assets" do |app|
|
|
22
|
-
app.config.assets.precompile += %w[spree_shipstation_manifest] if app.config.respond_to?(:assets)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def self.activate
|
|
26
|
-
# Three levels up from lib/spree/shipstation/ to reach the gem root.
|
|
27
|
-
Dir.glob(File.join(File.dirname(__FILE__), "../../../app/**/*_decorator*.rb")).sort.each do |c|
|
|
28
|
-
Rails.configuration.cache_classes ? require(c) : load(c)
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
config.to_prepare(&method(:activate).to_proc)
|
|
33
21
|
end
|
|
34
22
|
end
|
|
35
23
|
end
|
|
@@ -20,14 +20,46 @@ module Spree
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
##
|
|
23
|
-
# Raised when
|
|
23
|
+
# Raised when the ship notice's +OrderID+ names a fulfillment of a different
|
|
24
|
+
# order from the one its order number names.
|
|
25
|
+
#
|
|
26
|
+
class ShipmentMismatchError < Error
|
|
27
|
+
##
|
|
28
|
+
# @param shipment_number [String] the order number from the notice
|
|
29
|
+
# @param shipment_id [Integer] the +OrderID+ from the notice
|
|
30
|
+
#
|
|
31
|
+
def initialize(shipment_number, shipment_id, *args)
|
|
32
|
+
super("Shipment #{shipment_number} does not match order id #{shipment_id}", *args)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# Raised when the shipnotify payload names a shipment Spree has canceled.
|
|
38
|
+
#
|
|
39
|
+
class FulfillmentCanceledError < Error
|
|
40
|
+
##
|
|
41
|
+
# @param shipment_number [String] the number of the canceled shipment
|
|
42
|
+
#
|
|
43
|
+
def initialize(shipment_number, *args)
|
|
44
|
+
super("Shipment #{shipment_number} is canceled", *args)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Raised when Spree refuses to fulfil the shipment, for instance because the
|
|
50
|
+
# order is not paid or holds backordered items. The message is Spree's own.
|
|
51
|
+
#
|
|
52
|
+
class FulfillmentRefusedError < Error; end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# Raised when a payment cannot be captured during shipnotify.
|
|
24
56
|
#
|
|
25
57
|
class PaymentError < Error
|
|
26
58
|
##
|
|
27
|
-
# @param
|
|
59
|
+
# @param message [String] why the capture failed, as the gateway reported it
|
|
28
60
|
#
|
|
29
|
-
def initialize(
|
|
30
|
-
super("Could not
|
|
61
|
+
def initialize(message, *args)
|
|
62
|
+
super("Could not capture payment: #{message}", *args)
|
|
31
63
|
end
|
|
32
64
|
end
|
|
33
65
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Shipstation
|
|
5
|
+
##
|
|
6
|
+
# Selects the parcels ShipStation should pull: the store's fulfillments that
|
|
7
|
+
# Spree will let ship right now.
|
|
8
|
+
#
|
|
9
|
+
# Spree 6 keeps no "ready" status. A fulfillment is +unfulfilled+ until
|
|
10
|
+
# someone hands it over, and whether it can go out is decided when it is
|
|
11
|
+
# fulfilled. This query asks the same questions ahead of time, so ShipStation
|
|
12
|
+
# is not offered parcels that Spree would refuse at the ship notice. A parcel
|
|
13
|
+
# is exported when all of these hold:
|
|
14
|
+
#
|
|
15
|
+
# * it is one of the store's fulfillments and is still +unfulfilled+;
|
|
16
|
+
# * its order is placed, which leaves out drafts and canceled orders;
|
|
17
|
+
# * its order is paid: +paid+, +overcharged+ or +partially_refunded+, or it
|
|
18
|
+
# owes nothing (+total = 0+, whose payment status stays +none+), or, on a
|
|
19
|
+
# store that captures on dispatch or manually, it is +authorized+ or
|
|
20
|
+
# +partially_paid+ (mixed tender: store credit captured at checkout, say,
|
|
21
|
+
# and a card authorized for the rest). A payment method that overrides
|
|
22
|
+
# the store's setting is not consulted;
|
|
23
|
+
# * none of its items is backordered;
|
|
24
|
+
# * its selected delivery method is not a pickup or a pickup point.
|
|
25
|
+
#
|
|
26
|
+
# These are most of Spree's fulfil checks, not all of them, so the ship
|
|
27
|
+
# notice may still be refused, with Spree's reason. Spree also checks, when
|
|
28
|
+
# the parcel is fulfilled, that the stock location has the units on hand,
|
|
29
|
+
# and it compares the net captured amount (net of refunds) with what was
|
|
30
|
+
# due: a partially refunded order is exported but may fall short, and a
|
|
31
|
+
# partially paid one ships only while the rest is authorized on payment
|
|
32
|
+
# methods that defer capture.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# Spree::Shipstation::ExportQuery.new(store: store, from: 1.day.ago).call
|
|
36
|
+
#
|
|
37
|
+
class ExportQuery
|
|
38
|
+
PAID_STATUSES = %w[paid overcharged partially_refunded].freeze
|
|
39
|
+
private_constant :PAID_STATUSES
|
|
40
|
+
|
|
41
|
+
# Shippable as well when the store captures on dispatch or manually.
|
|
42
|
+
DEFERRED_CAPTURE_STATUSES = %w[authorized partially_paid].freeze
|
|
43
|
+
private_constant :DEFERRED_CAPTURE_STATUSES
|
|
44
|
+
|
|
45
|
+
PRELOADS = {
|
|
46
|
+
order: [:bill_address, :ship_address, :fulfillments],
|
|
47
|
+
selected_delivery_rate: :delivery_method,
|
|
48
|
+
fulfillment_items: {
|
|
49
|
+
line_item: {
|
|
50
|
+
variant: [{product: :primary_media}, :primary_media, {option_values: :option_type}]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}.freeze
|
|
54
|
+
private_constant :PRELOADS
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# @param store [Spree::Store] the store whose fulfillments are exported
|
|
58
|
+
# @param from [Time, NilClass] start of the date window
|
|
59
|
+
# @param to [Time, NilClass] end of the date window
|
|
60
|
+
#
|
|
61
|
+
def initialize(store:, from: nil, to: nil)
|
|
62
|
+
@store = store
|
|
63
|
+
@from = from
|
|
64
|
+
@to = to
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# The exportable parcels, oldest update first, with everything the export
|
|
69
|
+
# presenters read already loaded.
|
|
70
|
+
#
|
|
71
|
+
# A parcel falls in the window when its own +updated_at+ or its order's does.
|
|
72
|
+
# With neither +from+ nor +to+ there is no window, and with only one of them
|
|
73
|
+
# the window is open-ended.
|
|
74
|
+
#
|
|
75
|
+
# @return [ActiveRecord::Relation<Spree::Fulfillment>]
|
|
76
|
+
#
|
|
77
|
+
def call
|
|
78
|
+
within_window(shippable)
|
|
79
|
+
.reorder(updated_at: :asc, id: :asc)
|
|
80
|
+
.preload(PRELOADS)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
attr_reader :store, :from, :to
|
|
86
|
+
|
|
87
|
+
def shippable
|
|
88
|
+
store.fulfillments
|
|
89
|
+
.unfulfilled
|
|
90
|
+
.merge(Spree::Order.placed_orders)
|
|
91
|
+
.merge(paid_orders)
|
|
92
|
+
.where.not(id: backordered_fulfillment_ids)
|
|
93
|
+
.where.not(id: pickup_fulfillment_ids)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A free order's payment status stays +none+, but Spree ships it.
|
|
97
|
+
def paid_orders
|
|
98
|
+
Spree::Order.where(payment_status: paid_statuses).or(Spree::Order.where(total: 0))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# An order that is authorized, in full or for the part not captured at
|
|
102
|
+
# checkout, can ship when the store charges it at dispatch or leaves it to
|
|
103
|
+
# staff: Spree lets it through when every pending payment defers capture.
|
|
104
|
+
# On a store that captures at checkout, authorized money is not yet paid.
|
|
105
|
+
def paid_statuses
|
|
106
|
+
store.capture_at_checkout? ? PAID_STATUSES : PAID_STATUSES + DEFERRED_CAPTURE_STATUSES
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The +fulfillment_id IS NOT NULL+ guards matter: a single NULL in a +NOT IN+
|
|
110
|
+
# list makes the comparison unknown for every row, which would export nothing.
|
|
111
|
+
def backordered_fulfillment_ids
|
|
112
|
+
Spree::FulfillmentItem.backordered.where.not(fulfillment_id: nil).select(:fulfillment_id)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Only the delivery method the customer chose counts. A parcel may carry a
|
|
116
|
+
# pickup rate among the options it was offered. Methods are read with
|
|
117
|
+
# deleted ones, since orders outlive the method they were placed with.
|
|
118
|
+
def pickup_fulfillment_ids
|
|
119
|
+
pickups = Spree::DeliveryMethod.with_deleted.with_provider(:pickup?)
|
|
120
|
+
.or(Spree::DeliveryMethod.with_deleted.with_provider(:pickup_point?))
|
|
121
|
+
|
|
122
|
+
Spree::DeliveryRate
|
|
123
|
+
.where(selected: true, delivery_method_id: pickups.select(:id))
|
|
124
|
+
.where.not(fulfillment_id: nil)
|
|
125
|
+
.select(:fulfillment_id)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def within_window(scope)
|
|
129
|
+
return scope if from.nil? && to.nil?
|
|
130
|
+
|
|
131
|
+
window = from..to
|
|
132
|
+
scope.where(updated_at: window).or(scope.where(Spree::Order.table_name => {updated_at: window}))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|