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 +4 -4
- data/CHANGELOG.md +27 -1
- data/lib/portage/ucp/shopify/adapter.rb +32 -22
- data/lib/portage/ucp/shopify/errors.rb +8 -2
- data/lib/portage/ucp/shopify/mapper.rb +16 -2
- data/lib/portage/ucp/shopify/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f492f3dcd92e8efe69af5241e1e216808a562c2b2f0647bc95992b4b90527956
|
|
4
|
+
data.tar.gz: bf68a6808d5e7f799d2eafca32cefec602b68c98b6e637d512c7278f9a17b3a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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"
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
26
|
+
version: '0.7'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: rake
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|