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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/CHANGELOG.md +126 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/Gemfile +10 -4
  6. data/README.md +125 -28
  7. data/Rakefile +10 -9
  8. data/app/controllers/spree/shipstation_controller.rb +99 -16
  9. data/app/helpers/spree/shipstation/export_helper.rb +21 -10
  10. data/app/models/spree/integrations/shipstation.rb +64 -25
  11. data/app/presenters/spree/shipstation/export/item_presenter.rb +54 -10
  12. data/app/presenters/spree/shipstation/export/order_presenter.rb +87 -17
  13. data/app/presenters/spree/shipstation/export/weight.rb +20 -1
  14. data/app/views/spree/shipstation/export.xml.builder +9 -7
  15. data/config/initializers/spree.rb +5 -1
  16. data/config/locales/en.yml +4 -5
  17. data/config/routes.rb +12 -2
  18. data/lib/spree/shipstation/engine.rb +4 -13
  19. data/lib/spree/shipstation/errors.rb +52 -2
  20. data/lib/spree/shipstation/export_query.rb +136 -0
  21. data/lib/spree/shipstation/shipment_notice.rb +179 -28
  22. data/lib/spree/shipstation/testing_support/factories/shipstation_integration.rb +2 -0
  23. data/lib/spree/shipstation/version.rb +8 -2
  24. data/lib/spree/shipstation.rb +1 -1
  25. data/spree-shipstation.gemspec +8 -7
  26. metadata +11 -41
  27. data/.github/stale.yml +0 -17
  28. data/.github/workflows/lint.yml +0 -24
  29. data/.github/workflows/security.yml +0 -41
  30. data/.github/workflows/test.yml +0 -32
  31. data/Appraisals +0 -20
  32. data/CLAUDE.md +0 -95
  33. data/app/assets/config/spree_shipstation_manifest.js +0 -1
  34. data/app/models/spree/shipment_decorator.rb +0 -28
  35. data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +0 -6
  36. data/gemfiles/spree_5_2.gemfile +0 -18
  37. data/gemfiles/spree_5_3.gemfile +0 -18
  38. 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
- class ShipstationController < Spree::BaseController
5
- include Spree::IntegrationsHelper
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
- protect_from_forgery with: :null_session, only: :shipnotify
22
+ PER_PAGE = 50
23
+ private_constant :PER_PAGE
9
24
 
10
- before_action :ensure_active_integration
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
- @pagy, @shipments = pagy(
15
- current_store.shipments
16
- .exportable
17
- .between(date_param(:start_date), date_param(:end_date)),
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
- Shipstation::ShipmentNotice.from_payload(params.permit(:order_number, :tracking_number).to_h, store: current_store).apply
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
- def ensure_active_integration
38
- head :not_found unless store_integration("shipstation")&.active?
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 = store_integration("shipstation")
52
- ActiveSupport::SecurityUtils.secure_compare(username.to_s, integration.preferred_username.to_s) &&
53
- ActiveSupport::SecurityUtils.secure_compare(password.to_s, integration.preferred_password.to_s)
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 name_from(address)
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.zipcode
34
- xml.Country address.country&.iso
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.state&.abbr || address.state_name
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: Spree.t("admin.integrations.shipstation.username_chars_error")
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
- validates :preferred_password,
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
- validates :preferred_password,
35
- format: {
36
- with: /[A-Z]/,
37
- message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_uppercase_letter")
38
- },
39
- allow_blank: true
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
- validates :preferred_password,
42
- format: {
43
- with: /\d/,
44
- message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_number")
45
- },
46
- allow_blank: true
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
- def self.integration_group
49
- "Shipping"
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
- def self.icon_path
53
- "integration_icons/shipstation-logo.webp"
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
- def self.integration_name
57
- Spree.t("admin.integrations.shipstation.brand_name")
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
- # Shapes a shipment line item (a line item plus its inventory units) into the
7
- # values ShipStation's export XML expects for an <Item>.
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, :units
11
+ attr_reader :line_item, :fulfillment_items
10
12
 
11
- def initialize(line_item, units)
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
- @units = units
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
- # The Spree::Image to advertise, or nil. URL generation stays in the view
29
- # because it depends on Rails route/URL helpers.
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.images.first || variant.product.master.images.first
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
- units.size
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
- # Shapes a single Spree::Shipment into the values ShipStation's export XML
7
- # expects, keeping data-massaging logic out of the Builder template.
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 :shipment
17
+ attr_reader :fulfillment
14
18
 
15
- def initialize(shipment)
16
- @shipment = shipment
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
- shipment.order
30
+ fulfillment.order
21
31
  end
22
32
 
33
+ ##
34
+ # @return [Integer]
35
+ #
23
36
  def order_id
24
- shipment.id
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
- shipment.number
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
- shipment.state
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, shipment.updated_at].compact.max)
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
- shipment.shipping_method&.name
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.ship_total
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
- 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|
77
147
  next unless line_item.variant
78
148
 
79
- ItemPresenter.new(line_item, units)
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
- # Value object converting a Spree::Variant's weight into the
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