portage-ucp-shopify 0.4.0 → 0.4.3

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: 7a765da54993cf41e31bf88ed87b7b77980c699d9870cc8669ed3129bd215322
4
- data.tar.gz: f6ed02a45de96177706fd90283972623164b1cc48dc1d2e2b985bd2f34e3d002
3
+ metadata.gz: f492f3dcd92e8efe69af5241e1e216808a562c2b2f0647bc95992b4b90527956
4
+ data.tar.gz: bf68a6808d5e7f799d2eafca32cefec602b68c98b6e637d512c7278f9a17b3a8
5
5
  SHA512:
6
- metadata.gz: 2c56b50e718ffffc7fffe8298d94b2b1ae716ea1664e85d3b7ba6ad4cb2e066906b8c0b6dc2b0bae67b2fce6060769707c4c104f9fcba04e53a55400b1984df1
7
- data.tar.gz: 2b36bc54e055429bddf8b0ea01ca17f3abfe7f152576c6007775f3b5b31db2684348044dd2f54ee9be81d4f5b908ceef7e32a47ec404e057a3db4214c9881659
6
+ metadata.gz: a6e88da354126c906ea58feffed4fd49a290123c0afb5327930fbcb58d3e1d30b0576a835037a55a45d402d19a761a94733f2549427bf82d13086130dfee74f0
7
+ data.tar.gz: 0bf85bef1f694f5d5be78e0b927c7cdd0c175514530018686c594fc5ddc0e4e15e5a3a09124942ad70b243a4ff68d53e7ed9229ab318dd8c359b6a68b6610a3e
data/CHANGELOG.md CHANGED
@@ -4,7 +4,33 @@ 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
- ## [Unreleased]
7
+ ## [0.4.3] - 2026-09-16
8
+
9
+ - No behavior change — 0.4.2 was built and pushed with `gem build` run from
10
+ the workspace root instead of this gem's own directory, so `spec.files =
11
+ Dir[...]` resolved against the wrong working directory and packaged an
12
+ empty gem. 0.4.2 has been yanked; 0.4.3 repackages the exact same 0.4.2
13
+ code correctly.
14
+
15
+ ## [0.4.2] - 2026-09-16
16
+
17
+ - Fix `GraphqlError` crashing with `NoMethodError` instead of surfacing the
18
+ real message when Shopify's `errors` field is a bare string rather than
19
+ the usual array of objects (confirmed live on an expired/invalid admin
20
+ token).
21
+ - Add a live-store spec that searches the catalog, buys a random in-stock
22
+ product through checkout creation, and retries past a line Storefront
23
+ silently rejects (`quantity: 0`, empty `userErrors`) despite the Admin
24
+ API reporting it available.
25
+ - Widens the `portage-ucp` dependency pin to `~> 0.7` so this gem can
26
+ install alongside `portage-ucp` 0.7.0 (the pessimistic `~> 0.6` pin
27
+ published with 0.4.1 excludes it).
28
+
29
+ ## [0.4.1] - 2026-09-15
30
+
31
+ - No behavior change — widens the `portage-ucp` dependency pin to `~> 0.6`
32
+ so this gem can install alongside `portage-ucp` 0.6.0 (the pessimistic
33
+ `~> 0.5` pin published with 0.4.0 excludes it).
8
34
 
9
35
  ## [0.4.0] - 2026-09-14
10
36
 
@@ -274,28 +274,33 @@ module Portage
274
274
 
275
275
  def replace_lines(cart_id, line_items, discount_codes)
276
276
  synchronize(cart_id) do
277
- current_line_ids = fetch_cart_node(cart_id).dig("lines", "nodes").map { |n| n["id"] }
278
- unless current_line_ids.empty?
279
- removed = @client.storefront_query(Queries::CART_LINES_REMOVE,
280
- variables: { cartId: cart_id, lineIds: current_line_ids })
281
- unwrap!(removed, "cartLinesRemove")
282
- end
283
-
284
- cart_node = if line_items.empty?
285
- fetch_cart_node(cart_id)
286
- else
287
- added = @client.storefront_query(
288
- Queries::CART_LINES_ADD,
289
- variables: { cartId: cart_id, lines: cart_lines(line_items) }
290
- )
291
- unwrap!(added, "cartLinesAdd")
292
- end
277
+ remove_current_lines(cart_id)
278
+ cart_node = add_lines(cart_id, line_items)
293
279
  next cart_node if discount_codes.nil?
294
280
 
295
281
  apply_discount_codes(cart_id, discount_codes)
296
282
  end
297
283
  end
298
284
 
285
+ def remove_current_lines(cart_id)
286
+ current_line_ids = fetch_cart_node(cart_id).dig("lines", "nodes").map { |n| n["id"] }
287
+ return if current_line_ids.empty?
288
+
289
+ removed = @client.storefront_query(Queries::CART_LINES_REMOVE,
290
+ variables: { cartId: cart_id, lineIds: current_line_ids })
291
+ unwrap!(removed, "cartLinesRemove")
292
+ end
293
+
294
+ def add_lines(cart_id, line_items)
295
+ return fetch_cart_node(cart_id) if line_items.empty?
296
+
297
+ added = @client.storefront_query(
298
+ Queries::CART_LINES_ADD,
299
+ variables: { cartId: cart_id, lines: cart_lines(line_items) }
300
+ )
301
+ unwrap!(added, "cartLinesAdd")
302
+ end
303
+
299
304
  def apply_discount_codes(cart_id, discount_codes)
300
305
  data = @client.storefront_query(Queries::CART_DISCOUNT_CODES_UPDATE,
301
306
  variables: { cartId: cart_id, discountCodes: discount_codes })
@@ -314,8 +319,8 @@ module Portage
314
319
 
315
320
  pay_for_cart(checkout_id, cart_node.dig("cost", "totalAmount"), payment_token)
316
321
 
317
- status, order = poll_submission(checkout_id, idempotency_key)
318
- Mapper.checkout(cart_node, status: status, order: order)
322
+ status, order, resume_url = poll_submission(checkout_id, idempotency_key)
323
+ Mapper.checkout(cart_node, status: status, order: order, resume_url: resume_url)
319
324
  end
320
325
  end
321
326
 
@@ -359,10 +364,10 @@ module Portage
359
364
  with_retry(max_attempts: SUBMIT_POLL_MAX_ATTEMPTS) do
360
365
  submit_data = @client.storefront_query(Queries::CART_SUBMIT_FOR_COMPLETION,
361
366
  variables: { cartId: checkout_id, attemptToken: idempotency_key })
362
- status = unwrap_submit!(submit_data)
367
+ status, resume_url = unwrap_submit!(submit_data)
363
368
  record_checkout_status(checkout_id, status)
364
369
  order = link_cart_to_order(checkout_id) if status == "completed"
365
- [status, order]
370
+ [status, order, resume_url]
366
371
  end
367
372
  rescue Portage::Ucp::Shopify::SubmitThrottled
368
373
  raise Portage::Ucp::UpstreamThrottledError, "checkout #{checkout_id} still processing after retries"
@@ -397,16 +402,21 @@ module Portage
397
402
  raise Portage::Ucp::OutOfStockError, "no longer available: #{titles}"
398
403
  end
399
404
 
405
+ # On SubmitFailed (declines, 3DS/verification challenges, etc.),
406
+ # Shopify hands back its own `checkoutUrl` for the buyer to finish in
407
+ # a browser rather than a bare error — surfaced as "requires_escalation"
408
+ # plus that URL so #submit_payment can thread it into a resume-checkout
409
+ # Link, instead of raising and discarding it.
400
410
  def unwrap_submit!(data)
401
411
  payload = data.fetch("cartSubmitForCompletion")
402
412
  errors = payload["userErrors"]
403
413
  raise Portage::Ucp::Shopify::UserError.new("cartSubmitForCompletion", errors) if errors && !errors.empty?
404
414
 
405
415
  result = payload["result"]
406
- raise Portage::Ucp::Shopify::Error, result.dig("errors", 0, "message") if result["errors"]
416
+ return ["requires_escalation", result["checkoutUrl"]] if result["errors"]
407
417
  raise Portage::Ucp::Shopify::SubmitThrottled, result["pollAfter"] if result.key?("pollAfter")
408
418
 
409
- "completed"
419
+ ["completed", nil]
410
420
  end
411
421
  end
412
422
  end
@@ -11,8 +11,14 @@ module Portage
11
11
  # rejection (e.g. "line item not found").
12
12
  class GraphqlError < Error
13
13
  def initialize(errors)
14
- @errors = errors
15
- super(errors.map { |e| e["message"] }.join("; "))
14
+ # Normally an array of {"message" => ...} objects, but an
15
+ # authentication rejection (confirmed live: an expired/invalid
16
+ # admin token) skips GraphQL's own error shape entirely and sends
17
+ # a bare string instead — e.g. {"errors":"[API] Invalid API key
18
+ # or access token..."}. Wrapping it here keeps #message and
19
+ # #throttled? working either way instead of crashing on `#map`.
20
+ @errors = errors.is_a?(String) ? [{ "message" => errors }] : errors
21
+ super(@errors.map { |e| e["message"] }.join("; "))
16
22
  end
17
23
 
18
24
  # Shopify's cost-throttling reports THROTTLED as a top-level error's
@@ -217,20 +217,34 @@ module Portage
217
217
  # `status` isn't a Shopify Cart field — Cart/Checkout is one object in
218
218
  # Shopify's model, so the adapter tracks status itself across the
219
219
  # create/update/complete/cancel lifecycle and passes it in here.
220
- def checkout(node, status:, order: nil)
220
+ #
221
+ # `resume_url` is the SubmitFailed-specific checkoutUrl (3DS/
222
+ # verification/decline) — falls back to the cart's own checkoutUrl
223
+ # (CART_FIELDS, always present) so any non-completed checkout still
224
+ # carries a browser handoff link.
225
+ def checkout(node, status:, order: nil, resume_url: nil)
221
226
  Portage::Ucp::Checkout.new(
222
227
  id: node["id"],
223
228
  status: status,
224
229
  line_items: node.dig("lines", "nodes").map { |n| cart_line_item(n) },
225
230
  currency: node.dig("cost", "subtotalAmount", "currencyCode"),
226
231
  totals: totals(node),
227
- links: [],
232
+ links: checkout_links(node, status: status, resume_url: resume_url),
228
233
  order: order,
229
234
  discounts: discounts(node),
230
235
  fulfillment: checkout_fulfillment(node)
231
236
  )
232
237
  end
233
238
 
239
+ def checkout_links(node, status:, resume_url:)
240
+ return [] if status == "completed"
241
+
242
+ url = resume_url || node["checkoutUrl"]
243
+ return [] unless url
244
+
245
+ [Portage::Ucp::Link.new(type: "resume-checkout", url: url)]
246
+ end
247
+
234
248
  # dev.ucp.shopping.fulfillment (pre-purchase shipping-option
235
249
  # selection) built from Cart#deliveryGroups — read straight off
236
250
  # CART_FIELDS, no separate query. Shopify has no "fulfillment method"
@@ -1,7 +1,7 @@
1
1
  module Portage
2
2
  module Ucp
3
3
  module Shopify
4
- VERSION = "0.4.0".freeze
4
+ VERSION = "0.4.3".freeze
5
5
  end
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp-shopify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: portage-ucp
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.5'
19
+ version: '0.7'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.5'
26
+ version: '0.7'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rake
29
29
  requirement: !ruby/object:Gem::Requirement