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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/CHANGELOG.md +115 -0
  4. data/Gemfile +8 -4
  5. data/README.md +119 -28
  6. data/Rakefile +8 -9
  7. data/app/controllers/spree/shipstation_controller.rb +89 -19
  8. data/app/helpers/spree/shipstation/export_helper.rb +4 -10
  9. data/app/models/spree/integrations/shipstation.rb +50 -29
  10. data/app/presenters/spree/shipstation/export/item_presenter.rb +20 -15
  11. data/app/presenters/spree/shipstation/export/order_presenter.rb +33 -19
  12. data/app/views/spree/shipstation/export.xml.builder +9 -7
  13. data/config/initializers/spree.rb +3 -1
  14. data/config/locales/en.yml +4 -5
  15. data/config/routes.rb +12 -2
  16. data/lib/spree/shipstation/engine.rb +2 -14
  17. data/lib/spree/shipstation/errors.rb +36 -4
  18. data/lib/spree/shipstation/export_query.rb +136 -0
  19. data/lib/spree/shipstation/shipment_notice.rb +162 -41
  20. data/lib/spree/shipstation/version.rb +3 -2
  21. data/lib/spree/shipstation.rb +1 -1
  22. data/spree-shipstation.gemspec +8 -7
  23. metadata +10 -42
  24. data/.github/stale.yml +0 -17
  25. data/.github/workflows/lint.yml +0 -24
  26. data/.github/workflows/release.yml +0 -46
  27. data/.github/workflows/security.yml +0 -41
  28. data/.github/workflows/test.yml +0 -32
  29. data/Appraisals +0 -20
  30. data/CLAUDE.md +0 -98
  31. data/app/assets/config/spree_shipstation_manifest.js +0 -1
  32. data/app/models/spree/shipment_decorator.rb +0 -32
  33. data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +0 -6
  34. data/gemfiles/spree_5_2.gemfile +0 -18
  35. data/gemfiles/spree_5_3.gemfile +0 -18
  36. 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: Spree.t("admin.integrations.shipstation.username_chars_error")
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
- validates :preferred_password,
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
- # Admin grouping for this integration.
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
- "Shipping"
50
+ "shipping"
58
51
  end
59
52
 
60
53
  ##
61
- # Path to the logo shown in the admin integrations list.
54
+ # Brand name shown in the dashboard's integrations list.
62
55
  #
63
56
  # @return [String]
64
57
  #
65
- def self.icon_path
66
- "integration_icons/shipstation-logo.webp"
58
+ def self.integration_name
59
+ I18n.t("brand_name", scope: I18N_SCOPE)
67
60
  end
68
61
 
69
62
  ##
70
- # Translated brand name shown in the admin integrations list.
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.integration_name
75
- Spree.t("admin.integrations.shipstation.brand_name")
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 shipment line item (a line item plus its inventory units) into the
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, :units
11
+ attr_reader :line_item, :fulfillment_items
12
12
 
13
13
  ##
14
14
  # @param line_item [Spree::LineItem] the line item
15
- # @param units [Array<Spree::InventoryUnit>] inventory units for that line
15
+ # @param fulfillment_items [Array<Spree::FulfillmentItem>] the parcel's
16
+ # fulfillment items for that line
16
17
  #
17
- def initialize(line_item, units)
18
+ def initialize(line_item, fulfillment_items)
18
19
  @line_item = line_item
19
- @units = units
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::Image+ to advertise, or +nil+. URL generation stays in the
47
- # view because it depends on Rails route/URL helpers.
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::Image, NilClass]
51
+ # @return [Spree::Media, NilClass]
50
52
  #
51
53
  def image
52
- variant.images.first || variant.product.master.images.first
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 inventory units.
65
+ # The number of items to ship, summed across the line's fulfillment items.
64
66
  #
65
- # Spree stores a quantity on each +Spree::InventoryUnit+ rather than one
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
- units.sum(&:quantity)
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
- # @return [ActiveRecord::Relation, Array]
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::Shipment+ into the values ShipStation's export XML
8
- # expects, keeping data-massaging logic out of the Builder template.
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 shipment, so most order-level
11
- # fields are derived from +shipment.order+ while identifiers come from the
12
- # shipment itself (mirroring the +<OrderNumber>+ = shipment.number contract).
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 :shipment
17
+ attr_reader :fulfillment
16
18
 
17
19
  ##
18
- # @param shipment [Spree::Shipment] the shipment to export
20
+ # @param fulfillment [Spree::Fulfillment] the parcel to export
19
21
  #
20
- def initialize(shipment)
21
- @shipment = shipment
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
- shipment.order
30
+ fulfillment.order
29
31
  end
30
32
 
31
33
  ##
32
34
  # @return [Integer]
33
35
  #
34
36
  def order_id
35
- shipment.id
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
- shipment.number
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
- shipment.state
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, shipment.updated_at].compact.max)
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
- shipment.shipping_method&.name
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.ship_total
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
- shipment.inventory_units.group_by(&:line_item).filter_map do |line_item, units|
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, units)
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
- @shipments.each do |shipment|
5
- order = Spree::Shipstation::Export::OrderPresenter.new(shipment)
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
- image = item.image
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 = image && url_for(image.attachment)
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 value.option_type.presentation
49
- xml.Value value.name
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
- Rails.application.config.spree.integrations << Spree::Integrations::Shipstation
6
+ Spree.integrations << "Spree::Integrations::Shipstation"
5
7
  end
@@ -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 that loads decorator files and registers ShipStation assets.
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 a pending payment cannot be captured during shipnotify.
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 payment [Spree::Payment] the payment that failed to capture
59
+ # @param message [String] why the capture failed, as the gateway reported it
28
60
  #
29
- def initialize(payment, *args)
30
- super("Could not process payment #{payment.id}", *args)
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