portage-ucp-shopify 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8f438d6772598dbb5b6fcf3af94f98e6d63d4b2e4b79168aa86821ebb02d087
4
- data.tar.gz: 72e29d9c756a5fbee5484bb7aa1a31780f7bf7682634da0681a2d6486befc465
3
+ metadata.gz: ac2f2ad9119012864ff7ab41827c1cff716f4f7b1092d200d2810d8cb2ef7b5b
4
+ data.tar.gz: 4225325c543f10ed8a2c1f0adda77667f6224ec70c8e3d412b666a55345a81f4
5
5
  SHA512:
6
- metadata.gz: ae783d948bf5cc3fa62e4dc23daaccf816af84504bd6cf9d371ff53fa3354a37a952e1d643d158fb725ccd31b97f88d276c051ea13761ee214233c23932240c3
7
- data.tar.gz: 0f61debccf472e8d803bdf6c09ec40fce927bae828f6aba4a5d2191a79638613b9a896ee2ad42d8ebbccea5e150cd5769317942dee3a792afa91986655719ac5
6
+ metadata.gz: 49d90b4545a07f1685fe54d34bf53f0acc3919c0e1d8ca37398d3ac460ab460047ba42406d711c3e7c27c42c26d677c625114693be3914aa3676023dee6d5325
7
+ data.tar.gz: 81aba41eed72b01960b045978c9657de5b29d352df4ce52f3c165deee1f833d3ca2c9274e55e6a08d4a110ffcadcfb8dcf737eeff22c5412c517ca8e3cad5b12
data/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
+ ## [0.3.0] - 2026-08-27
8
+
9
+ - `metadata_field` config DSL (`Configuration`) — lets a merchant map their
10
+ own Shopify metafields onto UCP catalog attributes without a code change,
11
+ instead of every merchant-specific field needing a fork of `mapper.rb`.
12
+ - Four catalog/cart GraphQL shape fixes against the real Admin/Storefront
13
+ API, none catchable by `adapter_spec.rb`'s hand-rolled webmock stubs since
14
+ those fabricate response shapes by hand:
15
+ - `ProductVariant#price`/`#compareAtPrice` are the bare `Money` scalar (a
16
+ decimal string), not a `MoneyV2` object — the live Admin API 2026-04
17
+ schema rejects `{ amount currencyCode }` sub-selections on them.
18
+ - `ProductCompareAtPriceRange`'s fields are `minVariantCompareAtPrice`/
19
+ `maxVariantCompareAtPrice`, not `minVariantPrice`/`maxVariantPrice`
20
+ (those belong to `ProductPriceRangeV2` and were copy-pasted onto the
21
+ compare-at query and mapper).
22
+ - Cart `deliveryGroups` is a connection (`.nodes`), not a bare array.
23
+ - Cart `totalTaxAmount` is nullable — a fresh cart genuinely has no tax
24
+ amount yet rather than a zero one, until Shopify has enough context
25
+ (shipping address, tax-registered market) to compute it.
26
+ - Bounded retry with backoff and normalized conflict/throttle errors on
27
+ requests to Shopify, via the core gem's new `Support::Retry`.
28
+ - Per-cart/checkout mutations against Shopify are now serialized per session
29
+ (core gem's new `Support::SessionLock`), so two concurrent requests against
30
+ the same cart can't race each other.
31
+ - `existing_variant_id` support in the adapter conformance kit — Shopify
32
+ needs a Product GID for catalog lookups and a separate ProductVariant GID
33
+ for cart line items, which `existing_product_id` alone can't express.
34
+ - Adapter now runs the core gem's conformance kit against a real `Adapter`
35
+ through a real `Dispatcher` (`spec/portage/ucp/shopify/conformance_spec.rb`).
36
+
7
37
  ## [0.2.0] - 2026-08-21
8
38
 
9
39
  - `CartInput.discountCodes` on create, `cartDiscountCodesUpdate` on update —
@@ -34,6 +34,20 @@ module Portage
34
34
  # back to the cart that produced it — both are tracked adapter-side
35
35
  # via Support::CheckoutState.
36
36
  include Portage::Ucp::Support::CheckoutState
37
+ # #replace_lines, #apply_fulfillment and #submit_payment are each a
38
+ # read-modify-write across multiple Storefront calls with no atomic
39
+ # equivalent upstream — two concurrent calls against the same
40
+ # cart/checkout id can interleave and drop each other's writes, not
41
+ # just race on the same eventual value (§21). Support::SessionLock
42
+ # serializes by id so the second call waits instead of interleaving.
43
+ include Portage::Ucp::Support::SessionLock
44
+ # #poll_submission retries a SubmitThrottled cartSubmitForCompletion
45
+ # response instead of handing the caller an ambiguous
46
+ # "complete_in_progress" — safe because #submit_payment (its only
47
+ # caller) is always reached through #complete_checkout's dedup.
48
+ include Portage::Ucp::Support::Retry
49
+
50
+ SUBMIT_POLL_MAX_ATTEMPTS = 3
37
51
 
38
52
  def initialize(client:)
39
53
  super()
@@ -41,14 +55,15 @@ module Portage
41
55
  end
42
56
 
43
57
  def search_catalog(query:, limit:)
44
- data = @client.admin_query(Queries::SEARCH_CATALOG, variables: { query: query, first: limit })
45
- data.dig("products", "nodes").map { |node| Mapper.product(node) }
58
+ data = @client.admin_query(Queries.search_catalog_query, variables: { query: query, first: limit })
59
+ products = data.dig("products", "nodes").map { |node| Mapper.product(node) }
60
+ Portage::Ucp::CatalogSearchResult.new(products: products)
46
61
  end
47
62
 
48
63
  def get_product(product_id:)
49
- data = @client.admin_query(Queries::GET_PRODUCT, variables: { id: product_id })
64
+ data = @client.admin_query(Queries.product_by_id_query, variables: { id: product_id })
50
65
  node = data["product"]
51
- node && Mapper.product(node)
66
+ node && Portage::Ucp::ProductDetail.new(product: Mapper.product(node))
52
67
  end
53
68
 
54
69
  def get_cart(cart_id:)
@@ -195,15 +210,17 @@ module Portage
195
210
  # optional and independent — an agent picking a rate on an
196
211
  # already-addressed cart sends only the latter.
197
212
  def apply_fulfillment(cart_id, fulfillment)
198
- cart_node = fetch_cart_node(cart_id)
213
+ synchronize(cart_id) do
214
+ cart_node = fetch_cart_node(cart_id)
199
215
 
200
- destination = fulfillment.shipping_methods.flat_map(&:destinations).first
201
- cart_node = apply_delivery_address(cart_id, destination.address) if destination
216
+ destination = fulfillment.shipping_methods.flat_map(&:destinations).first
217
+ cart_node = apply_delivery_address(cart_id, destination.address) if destination
202
218
 
203
- selections = fulfillment.shipping_methods.flat_map(&:groups).filter_map { |g| delivery_selection(g) }
204
- cart_node = apply_delivery_selections(cart_id, selections) unless selections.empty?
219
+ selections = fulfillment.shipping_methods.flat_map(&:groups).filter_map { |g| delivery_selection(g) }
220
+ cart_node = apply_delivery_selections(cart_id, selections) unless selections.empty?
205
221
 
206
- cart_node
222
+ cart_node
223
+ end
207
224
  end
208
225
 
209
226
  def delivery_selection(group)
@@ -250,23 +267,27 @@ module Portage
250
267
  end
251
268
 
252
269
  def replace_lines(cart_id, line_items, discount_codes)
253
- current_line_ids = fetch_cart_node(cart_id).dig("lines", "nodes").map { |n| n["id"] }
254
- unless current_line_ids.empty?
255
- removed = @client.storefront_query(Queries::CART_LINES_REMOVE,
256
- variables: { cartId: cart_id, lineIds: current_line_ids })
257
- unwrap!(removed, "cartLinesRemove")
258
- end
259
-
260
- cart_node = if line_items.empty?
261
- fetch_cart_node(cart_id)
262
- else
263
- added = @client.storefront_query(Queries::CART_LINES_ADD,
264
- variables: { cartId: cart_id, lines: cart_lines(line_items) })
265
- unwrap!(added, "cartLinesAdd")
266
- end
267
- return cart_node if discount_codes.nil?
270
+ synchronize(cart_id) do
271
+ current_line_ids = fetch_cart_node(cart_id).dig("lines", "nodes").map { |n| n["id"] }
272
+ unless current_line_ids.empty?
273
+ removed = @client.storefront_query(Queries::CART_LINES_REMOVE,
274
+ variables: { cartId: cart_id, lineIds: current_line_ids })
275
+ unwrap!(removed, "cartLinesRemove")
276
+ end
268
277
 
269
- apply_discount_codes(cart_id, discount_codes)
278
+ cart_node = if line_items.empty?
279
+ fetch_cart_node(cart_id)
280
+ else
281
+ added = @client.storefront_query(
282
+ Queries::CART_LINES_ADD,
283
+ variables: { cartId: cart_id, lines: cart_lines(line_items) }
284
+ )
285
+ unwrap!(added, "cartLinesAdd")
286
+ end
287
+ next cart_node if discount_codes.nil?
288
+
289
+ apply_discount_codes(cart_id, discount_codes)
290
+ end
270
291
  end
271
292
 
272
293
  def apply_discount_codes(cart_id, discount_codes)
@@ -279,19 +300,17 @@ module Portage
279
300
  # — Shopify natively dedups that one call via SubmitAlreadyAccepted, on
280
301
  # top of #complete_checkout's own dedup wrapper.
281
302
  def submit_payment(checkout_id, payment_token, idempotency_key)
282
- cart_node = fetch_cart_node(checkout_id)
283
- raise Portage::Ucp::Shopify::Error, "cart #{checkout_id} not found" unless cart_node
303
+ synchronize(checkout_id) do
304
+ cart_node = fetch_cart_node(checkout_id)
305
+ raise Portage::Ucp::Shopify::Error, "cart #{checkout_id} not found" unless cart_node
284
306
 
285
- raise_if_any_line_unavailable!(cart_node)
307
+ raise_if_any_line_unavailable!(cart_node)
286
308
 
287
- pay_for_cart(checkout_id, cart_node.dig("cost", "totalAmount"), payment_token)
309
+ pay_for_cart(checkout_id, cart_node.dig("cost", "totalAmount"), payment_token)
288
310
 
289
- submit_data = @client.storefront_query(Queries::CART_SUBMIT_FOR_COMPLETION,
290
- variables: { cartId: checkout_id, attemptToken: idempotency_key })
291
- status = unwrap_submit!(submit_data)
292
- record_checkout_status(checkout_id, status)
293
- order = link_cart_to_order(checkout_id) if status == "completed"
294
- Mapper.checkout(cart_node, status: status, order: order)
311
+ status, order = poll_submission(checkout_id, idempotency_key)
312
+ Mapper.checkout(cart_node, status: status, order: order)
313
+ end
295
314
  end
296
315
 
297
316
  def pay_for_cart(checkout_id, total, payment_token)
@@ -304,16 +323,14 @@ module Portage
304
323
  unwrap!(payment_data, "cartPaymentUpdate")
305
324
  end
306
325
 
307
- # Best-effort: if `status` is `complete_in_progress` (SubmitThrottled),
308
- # the order doesn't exist yet, so there's nothing to look up — a poller
309
- # calling #get_checkout again later would need to retry this too, which
310
- # this adapter doesn't do on its own. If the order search index hasn't
311
- # caught up yet even for a synchronously-completed cart, this silently
312
- # finds nothing (returns nil, #get_order's checkout_id stays blank)
313
- # rather than raising, matching the "not information-complete but
314
- # schema-valid" posture used elsewhere in this file. Also the only
315
- # place that can hand the freshly-created order id back to
316
- # #complete_checkout's own caller — nothing else surfaces it.
326
+ # Best-effort: only called once `status` is "completed" (#poll_submission
327
+ # already retried through any SubmitThrottled), but if the order search
328
+ # index hasn't caught up yet even so, this silently finds nothing
329
+ # (returns nil, #get_order's checkout_id stays blank) rather than
330
+ # raising, matching the "not information-complete but schema-valid"
331
+ # posture used elsewhere in this file. Also the only place that can
332
+ # hand the freshly-created order id back to #complete_checkout's own
333
+ # caller nothing else surfaces it.
317
334
  def link_cart_to_order(checkout_id)
318
335
  token = checkout_id[%r{Cart/([^?]+)}, 1]
319
336
  return unless token
@@ -326,6 +343,29 @@ module Portage
326
343
  Portage::Ucp::OrderConfirmation.new(id: order_node["id"], permalink_url: order_node["statusPageUrl"])
327
344
  end
328
345
 
346
+ # Retries a SubmitThrottled cartSubmitForCompletion up to
347
+ # SUBMIT_POLL_MAX_ATTEMPTS, honoring each attempt's own pollAfter as
348
+ # the wait (see SubmitThrottled#retry_after). If Shopify is still
349
+ # throttled once attempts run out, that's surfaced to the caller as
350
+ # Portage::Ucp::UpstreamThrottledError rather than silently handing
351
+ # back an ambiguous "complete_in_progress" forever.
352
+ def poll_submission(checkout_id, idempotency_key)
353
+ with_retry(max_attempts: SUBMIT_POLL_MAX_ATTEMPTS) do
354
+ submit_data = @client.storefront_query(Queries::CART_SUBMIT_FOR_COMPLETION,
355
+ variables: { cartId: checkout_id, attemptToken: idempotency_key })
356
+ status = unwrap_submit!(submit_data)
357
+ record_checkout_status(checkout_id, status)
358
+ order = link_cart_to_order(checkout_id) if status == "completed"
359
+ [status, order]
360
+ end
361
+ rescue Portage::Ucp::Shopify::SubmitThrottled
362
+ raise Portage::Ucp::UpstreamThrottledError, "checkout #{checkout_id} still processing after retries"
363
+ end
364
+
365
+ def retryable_error?(error)
366
+ error.is_a?(Portage::Ucp::Shopify::SubmitThrottled) || super
367
+ end
368
+
329
369
  def unwrap!(data, field)
330
370
  payload = data.fetch(field)
331
371
  errors = payload["userErrors"]
@@ -358,8 +398,9 @@ module Portage
358
398
 
359
399
  result = payload["result"]
360
400
  raise Portage::Ucp::Shopify::Error, result.dig("errors", 0, "message") if result["errors"]
401
+ raise Portage::Ucp::Shopify::SubmitThrottled, result["pollAfter"] if result.key?("pollAfter")
361
402
 
362
- result.key?("pollAfter") ? "complete_in_progress" : "completed"
403
+ "completed"
363
404
  end
364
405
  end
365
406
  end
@@ -19,6 +19,12 @@ module Portage
19
19
  # arbitrary order). The adapter picks whichever API actually has the
20
20
  # data it needs (see Portage::Ucp::Shopify::Adapter).
21
21
  class Client
22
+ # Retries a bare THROTTLED GraphQL response or a transport 5xx —
23
+ # safe here because every Adapter mutation calling into this Client
24
+ # is already wrapped in Support::Idempotency#dedup, and every read
25
+ # is naturally idempotent (see Support::Retry's own doc comment).
26
+ include Portage::Ucp::Support::Retry
27
+
22
28
  DEFAULT_API_VERSION = "2026-04".freeze
23
29
 
24
30
  def initialize(shop_domain:, admin_access_token: nil, storefront_access_token: nil,
@@ -31,15 +37,19 @@ module Portage
31
37
 
32
38
  def admin_query(query, variables: {})
33
39
  require_token!(@admin_access_token, "admin_access_token")
34
- post("/admin/api/#{@api_version}/graphql.json", headers: { "X-Shopify-Access-Token" => @admin_access_token },
35
- query: query, variables: variables)
40
+ with_retry do
41
+ post("/admin/api/#{@api_version}/graphql.json",
42
+ headers: { "X-Shopify-Access-Token" => @admin_access_token }, query: query, variables: variables)
43
+ end
36
44
  end
37
45
 
38
46
  def storefront_query(query, variables: {})
39
47
  require_token!(@storefront_access_token, "storefront_access_token")
40
- post("/api/#{@api_version}/graphql.json",
41
- headers: { "X-Shopify-Storefront-Access-Token" => @storefront_access_token },
42
- query: query, variables: variables)
48
+ with_retry do
49
+ post("/api/#{@api_version}/graphql.json",
50
+ headers: { "X-Shopify-Storefront-Access-Token" => @storefront_access_token },
51
+ query: query, variables: variables)
52
+ end
43
53
  end
44
54
 
45
55
  private
@@ -48,6 +58,18 @@ module Portage
48
58
  raise ArgumentError, "Portage::Ucp::Shopify::Client requires #{name} for this call" unless token
49
59
  end
50
60
 
61
+ # Shopify's GraphQL THROTTLED code has no HTTP status of its own, and
62
+ # a transport 5xx here has no well-formed userErrors/GraphQL body to
63
+ # fall back on — Support::Retry's default `#status`-based check
64
+ # covers neither, so this hands both to it explicitly.
65
+ def retryable_error?(error)
66
+ case error
67
+ when Portage::Ucp::Shopify::ServerError then true
68
+ when Portage::Ucp::Shopify::GraphqlError then error.throttled?
69
+ else super
70
+ end
71
+ end
72
+
51
73
  def post(path, headers:, query:, variables:)
52
74
  uri = URI("https://#{@shop_domain}#{path}")
53
75
  request = Net::HTTP::Post.new(uri)
@@ -56,6 +78,9 @@ module Portage
56
78
  request.body = JSON.generate({ query: query, variables: variables })
57
79
 
58
80
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
81
+ status = response.code.to_i
82
+ raise Portage::Ucp::Shopify::ServerError, status if status >= 500
83
+
59
84
  body = JSON.parse(response.body)
60
85
  raise Portage::Ucp::Shopify::GraphqlError, body["errors"] if body["errors"]
61
86
 
@@ -0,0 +1,70 @@
1
+ module Portage
2
+ module Ucp
3
+ module Shopify
4
+ # Shopify-specific config, deliberately separate from core's own
5
+ # Portage::Ucp::Configuration (registry/authenticator/rate_limiter/...,
6
+ # already adapter-agnostic — see portage-ucp/lib/portage/ucp/configuration.rb).
7
+ # `Portage::Ucp::Shopify.configuration.metadata_fields` living on this
8
+ # singleton instead is what keeps Mapper's "nothing Shopify-shaped leaks
9
+ # past this file" posture intact: a Wix/WooCommerce consumer configuring
10
+ # their own adapter never sees Shopify's metafield config surface.
11
+ class Configuration
12
+ # Shopify caps `metafields(identifiers:)` at 250 identifiers per call
13
+ # (design-log §20). Enforced here rather than left to a confusing
14
+ # GraphQL cost-limit rejection from Shopify itself.
15
+ MAX_METAFIELD_IDENTIFIERS = 250
16
+
17
+ class InvalidMetadataField < ArgumentError; end
18
+
19
+ def initialize
20
+ @product_metadata_fields = []
21
+ @variant_metadata_fields = []
22
+ end
23
+
24
+ # Registers a merchant-defined metafield as a Product/Variant#metadata
25
+ # entry. `metafield:` is "namespace.key" (Shopify's own addressing,
26
+ # e.g. "custom.color_code"). `scope:` picks which GraphQL field
27
+ # (Product#metafields vs. ProductVariant#metafields — separate fields,
28
+ # separate cost, per Shopify's Admin API) the identifier is fetched
29
+ # through, since a real catalog needs both (color_hex naturally varies
30
+ # per variant, fabric_content is usually product-wide).
31
+ def metadata_field(key, metafield:, scope: :product)
32
+ namespace, metafield_key = metafield.split(".", 2)
33
+ unless metafield_key
34
+ raise InvalidMetadataField, "metafield: must be \"namespace.key\", got #{metafield.inspect}"
35
+ end
36
+
37
+ fields = fields_for(scope)
38
+ if fields.size >= MAX_METAFIELD_IDENTIFIERS
39
+ raise InvalidMetadataField,
40
+ "#{scope} metadata_fields already at Shopify's #{MAX_METAFIELD_IDENTIFIERS}-identifier " \
41
+ "metafields(identifiers:) cap — drop one before adding #{key.inspect}"
42
+ end
43
+
44
+ fields << { key: key.to_s, namespace: namespace, metafield_key: metafield_key }
45
+ end
46
+
47
+ # Read by Queries (to build the metafields fragment) and Mapper (to
48
+ # know which response entry maps to which UCP key) — both index by
49
+ # scope, not by name, so this is the one shared accessor.
50
+ def fields_for(scope)
51
+ case scope
52
+ when :product then @product_metadata_fields
53
+ when :variant then @variant_metadata_fields
54
+ else raise InvalidMetadataField, "scope: must be :product or :variant, got #{scope.inspect}"
55
+ end
56
+ end
57
+ end
58
+
59
+ class << self
60
+ def configuration
61
+ @configuration ||= Configuration.new
62
+ end
63
+
64
+ def configure
65
+ yield configuration
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,3 +1,5 @@
1
+ require "time"
2
+
1
3
  module Portage
2
4
  module Ucp
3
5
  module Shopify
@@ -9,8 +11,16 @@ module Portage
9
11
  # rejection (e.g. "line item not found").
10
12
  class GraphqlError < Error
11
13
  def initialize(errors)
14
+ @errors = errors
12
15
  super(errors.map { |e| e["message"] }.join("; "))
13
16
  end
17
+
18
+ # Shopify's cost-throttling reports THROTTLED as a top-level error's
19
+ # extensions.code, not an HTTP status — Client#retryable_error? uses
20
+ # this to hand it to Support::Retry alongside 429/5xx.
21
+ def throttled?
22
+ @errors.any? { |e| e.dig("extensions", "code") == "THROTTLED" }
23
+ end
14
24
  end
15
25
 
16
26
  # Raised when a mutation's `userErrors` array is non-empty.
@@ -19,6 +29,39 @@ module Portage
19
29
  super("#{field} userErrors: #{errors.map { |e| e['message'] }.join('; ')}")
20
30
  end
21
31
  end
32
+
33
+ # Raised for a bare 5xx HTTP status from either GraphQL endpoint —
34
+ # unlike GraphqlError/UserError, there's no well-formed JSON body to
35
+ # read a message from at this layer. Retryable by
36
+ # Client#retryable_error?, same as any other platform's transport 5xx.
37
+ class ServerError < Error
38
+ attr_reader :status
39
+
40
+ def initialize(status)
41
+ @status = status
42
+ super("Shopify API server error (#{status})")
43
+ end
44
+ end
45
+
46
+ # Raised when cartSubmitForCompletion answers SubmitThrottled —
47
+ # Shopify is still processing the submission and says to poll again
48
+ # after `poll_after`. Adapter#poll_submission retries on this (see
49
+ # Support::Retry) instead of handing an ambiguous
50
+ # "complete_in_progress" straight back to the caller; if retries
51
+ # exhaust while Shopify is still throttled, it's re-raised as
52
+ # Portage::Ucp::UpstreamThrottledError.
53
+ class SubmitThrottled < Error
54
+ attr_reader :poll_after
55
+
56
+ def initialize(poll_after)
57
+ @poll_after = poll_after
58
+ super("cartSubmitForCompletion still processing, poll after #{poll_after}")
59
+ end
60
+
61
+ def retry_after
62
+ [Time.parse(poll_after) - Time.now, 0].max
63
+ end
64
+ end
22
65
  end
23
66
  end
24
67
  end
@@ -21,20 +21,153 @@ module Portage
21
21
  Portage::Ucp::Support::Amounts.decimal_to_minor(price["amount"])
22
22
  end
23
23
 
24
+ # dev.ucp.shopping.catalog's Price (types/price.json) — the
25
+ # wire-shape counterpart to #money above, used everywhere a Product/
26
+ # Variant field carries currency directly rather than through the
27
+ # arithmetic-only Money type.
28
+ def price(node)
29
+ Portage::Ucp::Price.new(amount: minor_units(node), currency: node["currencyCode"])
30
+ end
31
+
24
32
  def product(node)
33
+ currency = node.dig("priceRange", "minVariantPrice", "currencyCode")
25
34
  Portage::Ucp::Product.new(
26
35
  id: node["id"],
36
+ handle: node["handle"],
27
37
  title: node["title"],
28
- description: node["description"],
29
- price: money(node.dig("priceRange", "minVariantPrice")),
30
- available: node["availableForSale"],
31
- variants: node.dig("variants", "nodes").map { |v| variant(v) },
32
- url: node["onlineStoreUrl"]
38
+ description: description(node),
39
+ price_range: price_range(node["priceRange"]),
40
+ list_price_range: compare_at_price_range(node["compareAtPriceRange"]),
41
+ variants: node.dig("variants", "nodes").map { |v| variant(v, description(node), currency) },
42
+ options: (node["options"] || []).map { |o| product_option(o) },
43
+ media: product_media(node.dig("featuredMedia", "nodes", 0)),
44
+ tags: node["tags"] || [],
45
+ url: node["onlineStoreUrl"],
46
+ metadata: metafields_metadata(node["metafields"], :product)
33
47
  )
34
48
  end
35
49
 
36
- def variant(node)
37
- { id: node["id"], title: node["title"], available: node["availableForSale"], price: money(node["price"]) }
50
+ # Shopify's `type` tags a metafield with one of ~20 encodings
51
+ # (design-log §20 handoff). Only the ones where passing the raw
52
+ # string through would be actively wrong for an agent to consume get
53
+ # parsed here — `json`, `dimension`/`measurement` (both
54
+ # `{"value":..,"unit":..}`-shaped JSON strings), and any `list.*`
55
+ # (a JSON array of the scalar type). Everything else (plain text,
56
+ # numbers, dates, refs, ...) passes through as Shopify sent it.
57
+ METAFIELD_JSON_TYPES = %w[json dimension measurement].freeze
58
+
59
+ def parse_metafield_value(value, type)
60
+ return JSON.parse(value) if METAFIELD_JSON_TYPES.include?(type) || type&.start_with?("list.")
61
+
62
+ value
63
+ end
64
+
65
+ # `metafields` is nil when nothing's configured for `scope` (the
66
+ # fragment itself was omitted from the query — see
67
+ # Queries.metafields_fragment) — that's the common case and returns
68
+ # nil rather than {}, matching Product/Variant#metadata's own
69
+ # nil-by-default. Otherwise it's Shopify's `metafields(identifiers:)`
70
+ # array, positional against the same
71
+ # `Portage::Ucp::Shopify.configuration.fields_for(scope)` list the
72
+ # query was built from, with a nil entry wherever that metafield
73
+ # isn't set on this product/variant.
74
+ def metafields_metadata(metafields, scope)
75
+ return nil if metafields.nil?
76
+
77
+ fields = Portage::Ucp::Shopify.configuration.fields_for(scope)
78
+ metadata = fields.zip(metafields).each_with_object({}) do |(field, metafield), acc|
79
+ next unless field && metafield && metafield["value"]
80
+
81
+ acc[field[:key]] = parse_metafield_value(metafield["value"], metafield["type"])
82
+ end
83
+ metadata.empty? ? nil : metadata
84
+ end
85
+
86
+ def description(node)
87
+ Portage::Ucp::Description.new(plain: node["description"], html: node["descriptionHtml"])
88
+ end
89
+
90
+ def price_range(node)
91
+ Portage::Ucp::PriceRange.new(min: price(node["minVariantPrice"]), max: price(node["maxVariantPrice"]))
92
+ end
93
+
94
+ # nil when Shopify's compareAtPriceRange itself is nil (no variant
95
+ # has a compare-at price set) rather than a zeroed-out range —
96
+ # Product#list_price_range is optional, so "no strikethrough price"
97
+ # should mean the field is absent, not present-and-zero.
98
+ def compare_at_price_range(node)
99
+ return nil unless node
100
+
101
+ Portage::Ucp::PriceRange.new(min: price(node["minVariantCompareAtPrice"]),
102
+ max: price(node["maxVariantCompareAtPrice"]))
103
+ end
104
+
105
+ def product_option(node)
106
+ values = (node["optionValues"] || []).map { |v| Portage::Ucp::OptionValue.new(id: v["id"], label: v["name"]) }
107
+ Portage::Ucp::ProductOption.new(name: node["name"], values: values)
108
+ end
109
+
110
+ def product_media(node)
111
+ image = node && node["image"]
112
+ return [] unless image
113
+
114
+ [Portage::Ucp::Media.new(type: "image", url: image["url"], alt_text: image["altText"],
115
+ width: image["width"], height: image["height"])]
116
+ end
117
+
118
+ # `barcode` is a single untyped Shopify string with no declared
119
+ # standard — inferred from length rather than asserted, since
120
+ # Shopify doesn't tag which GS1 standard (UPC-A/EAN-13/EAN-8) a
121
+ # given value follows. Anything else (an internal SKU-shaped
122
+ # barcode, a non-numeric value) is passed through as bare "GTIN"
123
+ # rather than dropped, so the field still reaches the agent.
124
+ BARCODE_TYPES = { 8 => "EAN", 12 => "UPC", 13 => "EAN" }.freeze
125
+
126
+ def barcodes(value)
127
+ return [] if value.nil? || value.empty?
128
+
129
+ [{ "type" => BARCODE_TYPES.fetch(value.length, "GTIN"), "value" => value }]
130
+ end
131
+
132
+ # `product_description` is the parent Product's own Description —
133
+ # Shopify's ProductVariant has no description field of its own, and
134
+ # types/variant.json requires one, so this reuses the product's, same
135
+ # posture as Wix's variant title falling back to its product's (see
136
+ # Portage::Ucp::Wix::Mapper#variant).
137
+ # `price`/`compareAtPrice` come back as the bare `Money` scalar (a
138
+ # decimal string, no currency of its own) rather than a MoneyV2
139
+ # object — confirmed against the live Admin API 2026-04 schema, which
140
+ # rejects `{ amount currencyCode }` sub-selections on them. `currency`
141
+ # is the product's own (from priceRange), which every variant shares.
142
+ def scalar_price(amount, currency)
143
+ Portage::Ucp::Price.new(amount: Portage::Ucp::Support::Amounts.decimal_to_minor(amount), currency: currency)
144
+ end
145
+
146
+ def variant(node, product_description, currency)
147
+ Portage::Ucp::Variant.new(
148
+ id: node["id"],
149
+ title: node["title"],
150
+ description: product_description,
151
+ price: scalar_price(node["price"], currency),
152
+ sku: node["sku"],
153
+ barcodes: barcodes(node["barcode"]),
154
+ list_price: node["compareAtPrice"] && scalar_price(node["compareAtPrice"], currency),
155
+ availability: { "available" => node["availableForSale"] },
156
+ options: selected_options(node["selectedOptions"]),
157
+ media: variant_media(node["image"]),
158
+ metadata: metafields_metadata(node["metafields"], :variant)
159
+ )
160
+ end
161
+
162
+ def selected_options(nodes)
163
+ (nodes || []).map { |o| Portage::Ucp::SelectedOption.new(name: o["name"], label: o["value"]) }
164
+ end
165
+
166
+ def variant_media(image)
167
+ return [] unless image
168
+
169
+ [Portage::Ucp::Media.new(type: "image", url: image["url"], alt_text: image["altText"],
170
+ width: image["width"], height: image["height"])]
38
171
  end
39
172
 
40
173
  def cart(node)
@@ -106,7 +239,7 @@ module Portage
106
239
  # modeled here), so every deliveryGroup collapses into a single
107
240
  # synthesized FulfillmentMethod rather than one per Shopify group.
108
241
  def checkout_fulfillment(node)
109
- groups_nodes = node["deliveryGroups"] || []
242
+ groups_nodes = node.dig("deliveryGroups", "nodes") || []
110
243
  return Portage::Ucp::CheckoutFulfillment.new if groups_nodes.empty?
111
244
 
112
245
  groups = groups_nodes.map { |g| fulfillment_group(g) }
@@ -317,10 +450,16 @@ module Portage
317
450
  end
318
451
 
319
452
  # Builds the top-level totals array from Shopify's cost breakdown.
453
+ # `totalTaxAmount` is nullable on a real cart — Shopify doesn't
454
+ # compute tax until it has enough context (a shipping address, a
455
+ # tax-registered market), so a fresh cart genuinely has no tax
456
+ # amount yet rather than a zero one. Confirmed live against
457
+ # ucp-test-bc2vif1p.myshopify.com (design-log §17).
320
458
  def totals(node)
321
459
  cost = node["cost"]
460
+ tax = cost["totalTaxAmount"]
322
461
  Portage::Ucp::Support::Totals.summary(subtotal: minor_units(cost["subtotalAmount"]),
323
- tax: minor_units(cost["totalTaxAmount"]),
462
+ tax: tax ? minor_units(tax) : 0,
324
463
  total: minor_units(cost["totalAmount"]))
325
464
  end
326
465
  end
@@ -5,29 +5,83 @@ module Portage
5
5
  # and the response mapping (Mapper) can each be read and tested on their
6
6
  # own.
7
7
  module Queries
8
- PRODUCT_FIELDS = <<~GRAPHQL.freeze
8
+ # `descriptionHtml` sourced alongside plain `description` — UCP's
9
+ # description.json accepts both, and Storefront sanitizes its own
10
+ # HTML output, satisfying the schema's "platforms MUST sanitize"
11
+ # note on the read side without this gem doing its own pass.
12
+ # `options`/`selectedOptions` and `sku`/`barcode` back the
13
+ # dev.ucp.shopping.catalog structured-attribute gap (design-log
14
+ # §20): GS1 identifiers and Size/Color axes as real fields instead
15
+ # of chaotic HTML the agent has to parse itself.
16
+ # `%<product_metafields>s`/`%<variant_metafields>s` carry whatever
17
+ # Portage::Ucp::Shopify.configuration.metadata_field entries a
18
+ # consumer has registered (design-log §20's metadata_field DSL).
19
+ # Kept as a template rather than the frozen constant the rest of this
20
+ # file uses: configured metafield identifiers aren't known until
21
+ # `Shopify.configure` runs, which happens after this file loads, so
22
+ # the fragment has to be built per call — see .product_fields below.
23
+ PRODUCT_FIELDS_TEMPLATE = <<~GRAPHQL.freeze
9
24
  id
25
+ handle
10
26
  title
11
27
  description
28
+ descriptionHtml
12
29
  onlineStoreUrl
13
- availableForSale
14
- priceRange { minVariantPrice { amount currencyCode } }
30
+ tags
31
+ priceRange { minVariantPrice { amount currencyCode } maxVariantPrice { amount currencyCode } }
32
+ compareAtPriceRange {
33
+ minVariantCompareAtPrice { amount currencyCode }
34
+ maxVariantCompareAtPrice { amount currencyCode }
35
+ }
36
+ featuredMedia: media(first: 1) { nodes { ... on MediaImage { image { url altText width height } } } }
37
+ options(first: 10) { name optionValues { id name } }
38
+ %<product_metafields>s
15
39
  variants(first: 25) {
16
- nodes { id title availableForSale price { amount currencyCode } }
40
+ nodes {
41
+ id title availableForSale sku barcode
42
+ price
43
+ compareAtPrice
44
+ selectedOptions { name value }
45
+ image { url altText width height }
46
+ %<variant_metafields>s
47
+ }
17
48
  }
18
49
  GRAPHQL
19
50
 
20
- SEARCH_CATALOG = <<~GRAPHQL.freeze
21
- query SearchCatalog($query: String!, $first: Int!) {
22
- products(query: $query, first: $first) { nodes { #{PRODUCT_FIELDS} } }
23
- }
24
- GRAPHQL
51
+ # Product-level and variant-level metafields are separate GraphQL
52
+ # fields with separate cost on Shopify's Admin API (Product#metafields
53
+ # vs. ProductVariant#metafields) `scope` picks which configured list
54
+ # to build the identifiers from. Empty when nothing's configured for
55
+ # that scope, so an unconfigured consumer's query is byte-identical to
56
+ # before this fragment existed.
57
+ def self.metafields_fragment(scope)
58
+ fields = Portage::Ucp::Shopify.configuration.fields_for(scope)
59
+ return "" if fields.empty?
25
60
 
26
- GET_PRODUCT = <<~GRAPHQL.freeze
27
- query GetProduct($id: ID!) {
28
- product(id: $id) { #{PRODUCT_FIELDS} }
29
- }
30
- GRAPHQL
61
+ identifiers = fields.map { |f| %({namespace: "#{f[:namespace]}", key: "#{f[:metafield_key]}"}) }.join(", ")
62
+ "metafields(identifiers: [#{identifiers}]) { key namespace value type }"
63
+ end
64
+
65
+ def self.product_fields
66
+ format(PRODUCT_FIELDS_TEMPLATE, product_metafields: metafields_fragment(:product),
67
+ variant_metafields: metafields_fragment(:variant))
68
+ end
69
+
70
+ def self.search_catalog_query
71
+ <<~GRAPHQL
72
+ query SearchCatalog($query: String!, $first: Int!) {
73
+ products(query: $query, first: $first) { nodes { #{product_fields} } }
74
+ }
75
+ GRAPHQL
76
+ end
77
+
78
+ def self.product_by_id_query
79
+ <<~GRAPHQL
80
+ query GetProduct($id: ID!) {
81
+ product(id: $id) { #{product_fields} }
82
+ }
83
+ GRAPHQL
84
+ end
31
85
 
32
86
  CART_FIELDS = <<~GRAPHQL.freeze
33
87
  id
@@ -48,13 +102,15 @@ module Portage
48
102
  }
49
103
  }
50
104
  }
51
- deliveryGroups {
52
- id
53
- cartLines(first: 100) { nodes { id } }
54
- deliveryOptions { handle title description deliveryMethodType estimatedCost { amount currencyCode } }
55
- selectedDeliveryOption { handle }
56
- deliveryAddress { address1 address2 city provinceCode zip firstName lastName phone
57
- countryCode: countryCodeV2 }
105
+ deliveryGroups(first: 10) {
106
+ nodes {
107
+ id
108
+ cartLines(first: 100) { nodes { id } }
109
+ deliveryOptions { handle title description deliveryMethodType estimatedCost { amount currencyCode } }
110
+ selectedDeliveryOption { handle }
111
+ deliveryAddress { address1 address2 city provinceCode zip firstName lastName phone
112
+ countryCode: countryCodeV2 }
113
+ }
58
114
  }
59
115
  discountCodes { code applicable }
60
116
  discountAllocations {
@@ -1,7 +1,7 @@
1
1
  module Portage
2
2
  module Ucp
3
3
  module Shopify
4
- VERSION = "0.2.0".freeze
4
+ VERSION = "0.3.0".freeze
5
5
  end
6
6
  end
7
7
  end
@@ -1,5 +1,6 @@
1
1
  require "portage/ucp"
2
2
  require_relative "shopify/version"
3
+ require_relative "shopify/configuration"
3
4
  require_relative "shopify/errors"
4
5
  require_relative "shopify/client"
5
6
  require_relative "shopify/access_token_fetcher"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp-shopify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.2'
18
+ version: '0.3'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.2'
25
+ version: '0.3'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rake
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -109,6 +109,7 @@ files:
109
109
  - lib/portage/ucp/shopify/access_token_fetcher.rb
110
110
  - lib/portage/ucp/shopify/adapter.rb
111
111
  - lib/portage/ucp/shopify/client.rb
112
+ - lib/portage/ucp/shopify/configuration.rb
112
113
  - lib/portage/ucp/shopify/errors.rb
113
114
  - lib/portage/ucp/shopify/mapper.rb
114
115
  - lib/portage/ucp/shopify/queries.rb
@@ -134,7 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
135
  - !ruby/object:Gem::Version
135
136
  version: '0'
136
137
  requirements: []
137
- rubygems_version: 4.0.18
138
+ rubygems_version: 3.6.9
138
139
  specification_version: 4
139
140
  summary: Shopify adapter for portage-ucp — standard catalog/cart/checkout/order over
140
141
  MCP and UCP