portage-ucp-shopify 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +41 -1
- data/README.md +3 -1
- data/lib/portage/ucp/shopify/adapter.rb +157 -15
- data/lib/portage/ucp/shopify/mapper.rb +142 -3
- data/lib/portage/ucp/shopify/queries.rb +151 -1
- data/lib/portage/ucp/shopify/version.rb +1 -1
- metadata +19 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8f438d6772598dbb5b6fcf3af94f98e6d63d4b2e4b79168aa86821ebb02d087
|
|
4
|
+
data.tar.gz: 72e29d9c756a5fbee5484bb7aa1a31780f7bf7682634da0681a2d6486befc465
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae783d948bf5cc3fa62e4dc23daaccf816af84504bd6cf9d371ff53fa3354a37a952e1d643d158fb725ccd31b97f88d276c051ea13761ee214233c23932240c3
|
|
7
|
+
data.tar.gz: 0f61debccf472e8d803bdf6c09ec40fce927bae828f6aba4a5d2191a79638613b9a896ee2ad42d8ebbccea5e150cd5769317942dee3a792afa91986655719ac5
|
data/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,47 @@ 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.
|
|
7
|
+
## [0.2.0] - 2026-08-21
|
|
8
|
+
|
|
9
|
+
- `CartInput.discountCodes` on create, `cartDiscountCodesUpdate` on update —
|
|
10
|
+
backs the core gem's new `dev.ucp.shopping.discount` extension, same
|
|
11
|
+
full-replacement posture `update_cart`'s line-item handling already uses.
|
|
12
|
+
`CART_FIELDS` grows `discountCodes` and `discountAllocations`; `#discounts`
|
|
13
|
+
treats the former as every code Shopify has on record and the latter as the
|
|
14
|
+
applied list. `AppliedDiscount#allocations` is left unpopulated —
|
|
15
|
+
`discountAllocations` doesn't resolve to a line-item index without a second
|
|
16
|
+
per-line query this pass didn't add. Only Shopify implements this so far.
|
|
17
|
+
- `dev.ucp.shopping.fulfillment` mapped onto Cart `deliveryGroups`: an address
|
|
18
|
+
via `cartDeliveryAddressesAdd`, a rate selection via
|
|
19
|
+
`cartSelectedDeliveryOptionsUpdate`. Storefront has no "fulfillment method"
|
|
20
|
+
above `deliveryGroups`, so the adapter synthesizes one `FulfillmentMethod`
|
|
21
|
+
per checkout wrapping all of a cart's `deliveryGroups` underneath it — see
|
|
22
|
+
design-log. The two mutations' input shapes
|
|
23
|
+
(`CartSelectableAddressInput`, `CartSelectedDeliveryOptionInput`) are this
|
|
24
|
+
pass's best-effort mapping, same unconfirmed-against-a-real-store caveat
|
|
25
|
+
`cartPaymentUpdate` already carries; the nullability mismatch on
|
|
26
|
+
`cartDiscountCodesUpdate`'s clear-codes path was caught and fixed against a
|
|
27
|
+
dev store, this one is still pending.
|
|
28
|
+
- `#complete_checkout` now raises `Portage::Ucp::OutOfStockError` (design-log
|
|
29
|
+
§16 "Stock/availability going stale") when a cart line is no longer
|
|
30
|
+
available for sale, instead of the generic `Portage::Ucp::Shopify::Error`
|
|
31
|
+
every other submission failure raises. Checked against a live dev store:
|
|
32
|
+
`cartSubmitForCompletion`'s `SubmitFailed` result doesn't give a sold-out
|
|
33
|
+
line its own error code — Shopify raises the same
|
|
34
|
+
`NO_DELIVERY_GROUP_SELECTED` it uses for an ordinary in-progress checkout
|
|
35
|
+
that hasn't picked a delivery option yet, so that mutation's errors can't
|
|
36
|
+
tell stale stock apart from an ordinary incomplete checkout. Detection
|
|
37
|
+
instead reads each line's `merchandise.availableForSale` off the cart
|
|
38
|
+
itself, before a payment is attempted.
|
|
39
|
+
- `#cancel_order` (`orderCancel`), `#refund_order` (`suggestedRefund` +
|
|
40
|
+
`refundCreate`), `#request_return` (`returnCreate`) — implements the core
|
|
41
|
+
gem's new `dev.ucp.shopping.order` cancel/return/refund extension. Each
|
|
42
|
+
re-fetches the order afterwards rather than trusting the mutation's own
|
|
43
|
+
payload, same posture as `#cancel_checkout`; the resulting
|
|
44
|
+
cancellation/refund/return state is read off `GET_ORDER`'s
|
|
45
|
+
`cancelledAt`/`refunds`/`returns` fields into `Order#adjustments`.
|
|
46
|
+
|
|
47
|
+
## [0.1.0] - 2026-08-14
|
|
8
48
|
|
|
9
49
|
- Initial pre-release. Shopify adapter against the Admin (catalog, order) and
|
|
10
50
|
Storefront (cart, checkout) GraphQL APIs.
|
data/README.md
CHANGED
|
@@ -9,13 +9,15 @@ Shopify adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::A
|
|
|
9
9
|
| `dev.ucp.shopping.catalog` | Admin | `search_catalog`, `get_product` |
|
|
10
10
|
| `dev.ucp.shopping.cart` | Storefront (Cart) | `get_cart`, `create_cart`, `update_cart`, `cancel_cart` |
|
|
11
11
|
| `dev.ucp.shopping.checkout` | Storefront (same Cart object) | `create_checkout`, `get_checkout`, `update_checkout`, `complete_checkout`, `cancel_checkout` |
|
|
12
|
-
| `dev.ucp.shopping.order` | Admin | `get_order` |
|
|
12
|
+
| `dev.ucp.shopping.order` | Admin | `get_order`, `cancel_order`, `refund_order`, `request_return` (gem-side extension beyond the real UCP spec — see below) |
|
|
13
13
|
| `dev.ucp.shopping.identity` | — | not implemented; Shopify's OAuth identity story lives in the separate Customer Account API, out of scope here |
|
|
14
14
|
|
|
15
15
|
Shopify has no separate "Checkout" object — Storefront's `Cart` **is** the checkout. The adapter tracks checkout status (`incomplete` / `completed` / `canceled` / `complete_in_progress`) itself, keyed by cart id, and resolves `Order#checkout_id` after completion via a `cart_token:` order search.
|
|
16
16
|
|
|
17
17
|
Update/replace operations (`update_cart`, `update_checkout`) are full-replacement: Storefront has no atomic "replace all lines" mutation, so the adapter removes every current line then re-adds the desired ones. Mutating methods dedup by `idempotency_key` in-process so a dropped-connection retry can't double-charge.
|
|
18
18
|
|
|
19
|
+
`cancel_order`, `refund_order`, and `request_return` cancel via `orderCancel`, refund via `suggestedRefund` + `refundCreate`, and request a return via `returnCreate`, respectively — each re-fetches the order afterwards rather than trusting the mutation's own payload, and the result shows up as an appended `Portage::Ucp::Adjustment` on `Order#adjustments` (type `cancellation`/`refund`/`return`). These three aren't in the real UCP spec's order lifecycle (get-only today) — they're a deliberate extension of the existing `dev.ucp.shopping.order` capability rather than a new top-level family, since it's the same resource as `get_order`.
|
|
20
|
+
|
|
19
21
|
## Installation
|
|
20
22
|
|
|
21
23
|
```ruby
|
|
@@ -56,15 +56,15 @@ module Portage
|
|
|
56
56
|
cart_node && Mapper.cart(cart_node)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def create_cart(line_items:, idempotency_key:)
|
|
60
|
-
dedup(idempotency_key) { Mapper.cart(create_cart_node(line_items)) }
|
|
59
|
+
def create_cart(line_items:, idempotency_key:, discount_codes: nil)
|
|
60
|
+
dedup(idempotency_key) { Mapper.cart(create_cart_node(line_items, discount_codes)) }
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Full replacement, matching UCP's real cart semantics: Storefront has
|
|
64
64
|
# no atomic "replace all lines" mutation, so this removes every current
|
|
65
65
|
# line then adds the desired ones back (two Storefront calls).
|
|
66
|
-
def update_cart(cart_id:, line_items:, idempotency_key:)
|
|
67
|
-
dedup(idempotency_key) { Mapper.cart(replace_lines(cart_id, line_items)) }
|
|
66
|
+
def update_cart(cart_id:, line_items:, idempotency_key:, discount_codes: nil)
|
|
67
|
+
dedup(idempotency_key) { Mapper.cart(replace_lines(cart_id, line_items, discount_codes)) }
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# Shopify has no cart-cancellation mutation — carts simply expire.
|
|
@@ -74,9 +74,10 @@ module Portage
|
|
|
74
74
|
dedup(idempotency_key) { get_cart(cart_id: cart_id) }
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
def create_checkout(line_items:, idempotency_key:)
|
|
77
|
+
def create_checkout(line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
|
|
78
78
|
dedup(idempotency_key) do
|
|
79
|
-
cart_node = create_cart_node(line_items)
|
|
79
|
+
cart_node = create_cart_node(line_items, discount_codes)
|
|
80
|
+
cart_node = apply_fulfillment(cart_node["id"], fulfillment) if fulfillment
|
|
80
81
|
record_checkout_status(cart_node["id"], "incomplete")
|
|
81
82
|
Mapper.checkout(cart_node, status: "incomplete")
|
|
82
83
|
end
|
|
@@ -89,14 +90,18 @@ module Portage
|
|
|
89
90
|
|
|
90
91
|
# Full replacement, same rationale as #update_cart — Shopify checkout
|
|
91
92
|
# *is* the cart object.
|
|
92
|
-
def update_checkout(checkout_id:, line_items:, idempotency_key:)
|
|
93
|
+
def update_checkout(checkout_id:, line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
|
|
93
94
|
dedup(idempotency_key) do
|
|
94
|
-
cart_node = replace_lines(checkout_id, line_items)
|
|
95
|
+
cart_node = replace_lines(checkout_id, line_items, discount_codes)
|
|
96
|
+
cart_node = apply_fulfillment(checkout_id, fulfillment) if fulfillment
|
|
95
97
|
record_checkout_status(checkout_id, "incomplete")
|
|
96
98
|
Mapper.checkout(cart_node, status: "incomplete")
|
|
97
99
|
end
|
|
98
100
|
end
|
|
99
101
|
|
|
102
|
+
def discount_codes_supported? = true
|
|
103
|
+
def fulfillment_supported? = true
|
|
104
|
+
|
|
100
105
|
# `payment_token` is the single-use tokenized credential from a UCP
|
|
101
106
|
# payment handler (already validated as non-PAN by PaymentTokenGuard
|
|
102
107
|
# before this is ever called, per §9). It's threaded straight into
|
|
@@ -123,8 +128,112 @@ module Portage
|
|
|
123
128
|
node && Mapper.order(node, checkout_id: checkout_id_for(order_id))
|
|
124
129
|
end
|
|
125
130
|
|
|
131
|
+
# orderCancel returns only a (possibly still-pending) job — the
|
|
132
|
+
# updated cancellation state is read back via #get_order, same
|
|
133
|
+
# "trust a fresh read over the mutation payload" posture as
|
|
134
|
+
# #cancel_checkout.
|
|
135
|
+
def cancel_order(order_id:, idempotency_key:, reason: nil)
|
|
136
|
+
dedup(idempotency_key) do
|
|
137
|
+
data = @client.admin_query(Queries::ORDER_CANCEL, variables: { orderId: order_id, staffNote: reason })
|
|
138
|
+
raise_on_errors!(data, "orderCancel", "orderCancelUserErrors")
|
|
139
|
+
get_order(order_id: order_id)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# `line_items:` here are order line items ({id:, quantity:}) — the
|
|
144
|
+
# same ids GET_ORDER's line_items carry — since refundLineItems keys
|
|
145
|
+
# off the plain LineItem id, unlike request_return's
|
|
146
|
+
# fulfillmentLineItemId below.
|
|
147
|
+
def refund_order(order_id:, line_items:, idempotency_key:, reason: nil)
|
|
148
|
+
dedup(idempotency_key) do
|
|
149
|
+
refund_line_items = line_items.map { |li| { lineItemId: li[:id], quantity: li[:quantity] } }
|
|
150
|
+
transactions = suggested_refund_transactions(order_id, refund_line_items)
|
|
151
|
+
data = @client.admin_query(
|
|
152
|
+
Queries::REFUND_CREATE,
|
|
153
|
+
variables: { input: { orderId: order_id, note: reason, refundLineItems: refund_line_items,
|
|
154
|
+
transactions: transactions } }
|
|
155
|
+
)
|
|
156
|
+
raise_on_errors!(data, "refundCreate", "userErrors")
|
|
157
|
+
get_order(order_id: order_id)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def request_return(order_id:, line_items:, idempotency_key:, reason: nil)
|
|
162
|
+
dedup(idempotency_key) do
|
|
163
|
+
return_line_items = line_items.map do |li|
|
|
164
|
+
{ fulfillmentLineItemId: li[:id], quantity: li[:quantity], returnReason: "OTHER",
|
|
165
|
+
returnReasonNote: reason }
|
|
166
|
+
end
|
|
167
|
+
data = @client.admin_query(Queries::RETURN_CREATE,
|
|
168
|
+
variables: { returnInput: { orderId: order_id,
|
|
169
|
+
returnLineItems: return_line_items } })
|
|
170
|
+
raise_on_errors!(data, "returnCreate", "userErrors")
|
|
171
|
+
get_order(order_id: order_id)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
126
175
|
private
|
|
127
176
|
|
|
177
|
+
def raise_on_errors!(data, field, errors_key)
|
|
178
|
+
errors = data.dig(field, errors_key)
|
|
179
|
+
raise Portage::Ucp::Shopify::UserError.new(field, errors) if errors && !errors.empty?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def suggested_refund_transactions(order_id, refund_line_items)
|
|
183
|
+
data = @client.admin_query(Queries::SUGGESTED_REFUND,
|
|
184
|
+
variables: { orderId: order_id, refundLineItems: refund_line_items })
|
|
185
|
+
(data.dig("order", "suggestedRefund", "transactions") || []).map do |t|
|
|
186
|
+
{ orderId: order_id, gateway: t["gateway"], kind: t["kind"],
|
|
187
|
+
amount: t.dig("amountSet", "shopMoney", "amount"), parentId: t.dig("parentTransaction", "id") }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Applies the agent's fulfillment request onto the cart: submits a
|
|
192
|
+
# shipping address (if the agent supplied one on `destinations`, the
|
|
193
|
+
# only way Shopify's deliveryOptions get priced at all) and/or the
|
|
194
|
+
# agent's `selected_option_id` choices per group. Either half is
|
|
195
|
+
# optional and independent — an agent picking a rate on an
|
|
196
|
+
# already-addressed cart sends only the latter.
|
|
197
|
+
def apply_fulfillment(cart_id, fulfillment)
|
|
198
|
+
cart_node = fetch_cart_node(cart_id)
|
|
199
|
+
|
|
200
|
+
destination = fulfillment.shipping_methods.flat_map(&:destinations).first
|
|
201
|
+
cart_node = apply_delivery_address(cart_id, destination.address) if destination
|
|
202
|
+
|
|
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?
|
|
205
|
+
|
|
206
|
+
cart_node
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def delivery_selection(group)
|
|
210
|
+
return nil unless group.selected_option_id
|
|
211
|
+
|
|
212
|
+
{ deliveryGroupId: group.id, deliveryOptionHandle: group.selected_option_id }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def apply_delivery_address(cart_id, address)
|
|
216
|
+
data = @client.storefront_query(
|
|
217
|
+
Queries::CART_DELIVERY_ADDRESSES_ADD,
|
|
218
|
+
variables: { cartId: cart_id,
|
|
219
|
+
addresses: [{ address: { deliveryAddress: delivery_address_input(address) },
|
|
220
|
+
selected: true }] }
|
|
221
|
+
)
|
|
222
|
+
unwrap!(data, "cartDeliveryAddressesAdd")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def delivery_address_input(address)
|
|
226
|
+
{ address1: address.street_address, address2: address.extended_address, city: address.address_locality,
|
|
227
|
+
provinceCode: address.address_region, countryCode: address.address_country, zip: address.postal_code,
|
|
228
|
+
firstName: address.first_name, lastName: address.last_name, phone: address.phone_number }.compact
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def apply_delivery_selections(cart_id, selections)
|
|
232
|
+
data = @client.storefront_query(Queries::CART_SELECTED_DELIVERY_OPTIONS_UPDATE,
|
|
233
|
+
variables: { cartId: cart_id, selectedDeliveryOptions: selections })
|
|
234
|
+
unwrap!(data, "cartSelectedDeliveryOptionsUpdate")
|
|
235
|
+
end
|
|
236
|
+
|
|
128
237
|
def fetch_cart_node(cart_id)
|
|
129
238
|
@client.storefront_query(Queries::GET_CART, variables: { id: cart_id })["cart"]
|
|
130
239
|
end
|
|
@@ -133,23 +242,37 @@ module Portage
|
|
|
133
242
|
line_items.map { |li| { merchandiseId: li[:product_id], quantity: li[:quantity] } }
|
|
134
243
|
end
|
|
135
244
|
|
|
136
|
-
def create_cart_node(line_items)
|
|
137
|
-
|
|
245
|
+
def create_cart_node(line_items, discount_codes)
|
|
246
|
+
input = { lines: cart_lines(line_items) }
|
|
247
|
+
input[:discountCodes] = discount_codes if discount_codes
|
|
248
|
+
data = @client.storefront_query(Queries::CART_CREATE, variables: { input: input })
|
|
138
249
|
unwrap!(data, "cartCreate")
|
|
139
250
|
end
|
|
140
251
|
|
|
141
|
-
def replace_lines(cart_id, line_items)
|
|
252
|
+
def replace_lines(cart_id, line_items, discount_codes)
|
|
142
253
|
current_line_ids = fetch_cart_node(cart_id).dig("lines", "nodes").map { |n| n["id"] }
|
|
143
254
|
unless current_line_ids.empty?
|
|
144
255
|
removed = @client.storefront_query(Queries::CART_LINES_REMOVE,
|
|
145
256
|
variables: { cartId: cart_id, lineIds: current_line_ids })
|
|
146
257
|
unwrap!(removed, "cartLinesRemove")
|
|
147
258
|
end
|
|
148
|
-
return fetch_cart_node(cart_id) if line_items.empty?
|
|
149
259
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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?
|
|
268
|
+
|
|
269
|
+
apply_discount_codes(cart_id, discount_codes)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def apply_discount_codes(cart_id, discount_codes)
|
|
273
|
+
data = @client.storefront_query(Queries::CART_DISCOUNT_CODES_UPDATE,
|
|
274
|
+
variables: { cartId: cart_id, discountCodes: discount_codes })
|
|
275
|
+
unwrap!(data, "cartDiscountCodesUpdate")
|
|
153
276
|
end
|
|
154
277
|
|
|
155
278
|
# Reuses idempotency_key as cartSubmitForCompletion's own attemptId too
|
|
@@ -159,6 +282,8 @@ module Portage
|
|
|
159
282
|
cart_node = fetch_cart_node(checkout_id)
|
|
160
283
|
raise Portage::Ucp::Shopify::Error, "cart #{checkout_id} not found" unless cart_node
|
|
161
284
|
|
|
285
|
+
raise_if_any_line_unavailable!(cart_node)
|
|
286
|
+
|
|
162
287
|
pay_for_cart(checkout_id, cart_node.dig("cost", "totalAmount"), payment_token)
|
|
163
288
|
|
|
164
289
|
submit_data = @client.storefront_query(Queries::CART_SUBMIT_FOR_COMPLETION,
|
|
@@ -209,6 +334,23 @@ module Portage
|
|
|
209
334
|
payload.fetch("cart")
|
|
210
335
|
end
|
|
211
336
|
|
|
337
|
+
# Checked against a live dev store: a sold-out line doesn't get its
|
|
338
|
+
# own SubmissionErrorCode on cartSubmitForCompletion — Shopify raises
|
|
339
|
+
# the same NO_DELIVERY_GROUP_SELECTED code it uses for an ordinary
|
|
340
|
+
# incomplete checkout that simply hasn't picked a delivery option yet,
|
|
341
|
+
# so a code-substring match on that mutation's errors can't tell stale
|
|
342
|
+
# stock apart from a normal in-progress checkout. availableForSale on
|
|
343
|
+
# each line's merchandise is the one field that actually flips when a
|
|
344
|
+
# variant goes out of stock, so the check happens here instead, before
|
|
345
|
+
# a payment is even attempted.
|
|
346
|
+
def raise_if_any_line_unavailable!(cart_node)
|
|
347
|
+
unavailable = cart_node.dig("lines", "nodes").reject { |n| n.dig("merchandise", "availableForSale") }
|
|
348
|
+
return if unavailable.empty?
|
|
349
|
+
|
|
350
|
+
titles = unavailable.map { |n| n.dig("merchandise", "product", "title") }.join(", ")
|
|
351
|
+
raise Portage::Ucp::OutOfStockError, "no longer available: #{titles}"
|
|
352
|
+
end
|
|
353
|
+
|
|
212
354
|
def unwrap_submit!(data)
|
|
213
355
|
payload = data.fetch("cartSubmitForCompletion")
|
|
214
356
|
errors = payload["userErrors"]
|
|
@@ -42,7 +42,30 @@ module Portage
|
|
|
42
42
|
id: node["id"],
|
|
43
43
|
line_items: node.dig("lines", "nodes").map { |n| cart_line_item(n) },
|
|
44
44
|
currency: node.dig("cost", "subtotalAmount", "currencyCode"),
|
|
45
|
-
totals: totals(node)
|
|
45
|
+
totals: totals(node),
|
|
46
|
+
discounts: discounts(node)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# dev.ucp.shopping.discount — `codes` echoes every code Shopify has on
|
|
51
|
+
# record for the cart (applicable or not); `applied` only lists
|
|
52
|
+
# allocations that actually reduced the price, since that's all
|
|
53
|
+
# `discountAllocations` ever returns. Allocation-level breakdown
|
|
54
|
+
# (applied_discount's `allocations` field) isn't sourced here —
|
|
55
|
+
# per-line attribution needs a separate line-level query this adapter
|
|
56
|
+
# doesn't make yet.
|
|
57
|
+
def discounts(node)
|
|
58
|
+
codes = (node["discountCodes"] || []).map { |c| c["code"] }
|
|
59
|
+
applied = (node["discountAllocations"] || []).map { |a| applied_discount(a) }
|
|
60
|
+
Portage::Ucp::Discounts.new(codes: codes, applied: applied)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def applied_discount(node)
|
|
64
|
+
Portage::Ucp::AppliedDiscount.new(
|
|
65
|
+
title: node["title"] || node["code"],
|
|
66
|
+
amount: minor_units(node["discountedAmount"]),
|
|
67
|
+
code: node["code"],
|
|
68
|
+
automatic: !node["code"]
|
|
46
69
|
)
|
|
47
70
|
end
|
|
48
71
|
|
|
@@ -69,10 +92,73 @@ module Portage
|
|
|
69
92
|
currency: node.dig("cost", "subtotalAmount", "currencyCode"),
|
|
70
93
|
totals: totals(node),
|
|
71
94
|
links: [],
|
|
72
|
-
order: order
|
|
95
|
+
order: order,
|
|
96
|
+
discounts: discounts(node),
|
|
97
|
+
fulfillment: checkout_fulfillment(node)
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# dev.ucp.shopping.fulfillment (pre-purchase shipping-option
|
|
102
|
+
# selection) built from Cart#deliveryGroups — read straight off
|
|
103
|
+
# CART_FIELDS, no separate query. Shopify has no "fulfillment method"
|
|
104
|
+
# grouping above deliveryGroups, and a cart only ever carries the one
|
|
105
|
+
# buyer-submitted address (split-shipment-to-multiple-addresses isn't
|
|
106
|
+
# modeled here), so every deliveryGroup collapses into a single
|
|
107
|
+
# synthesized FulfillmentMethod rather than one per Shopify group.
|
|
108
|
+
def checkout_fulfillment(node)
|
|
109
|
+
groups_nodes = node["deliveryGroups"] || []
|
|
110
|
+
return Portage::Ucp::CheckoutFulfillment.new if groups_nodes.empty?
|
|
111
|
+
|
|
112
|
+
groups = groups_nodes.map { |g| fulfillment_group(g) }
|
|
113
|
+
destination = shipping_destination(groups_nodes.first["deliveryAddress"])
|
|
114
|
+
method = Portage::Ucp::FulfillmentMethod.new(
|
|
115
|
+
id: "fm_#{node['id']}",
|
|
116
|
+
type: DELIVERY_METHOD_TYPES.fetch(groups_nodes.first.dig("deliveryOptions", 0, "deliveryMethodType"),
|
|
117
|
+
"shipping"),
|
|
118
|
+
line_item_ids: groups.flat_map(&:line_item_ids),
|
|
119
|
+
destinations: destination ? [destination] : [],
|
|
120
|
+
selected_destination_id: destination&.id,
|
|
121
|
+
groups: groups
|
|
122
|
+
)
|
|
123
|
+
Portage::Ucp::CheckoutFulfillment.new(shipping_methods: [method])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def fulfillment_group(node)
|
|
127
|
+
Portage::Ucp::FulfillmentGroup.new(
|
|
128
|
+
id: node["id"],
|
|
129
|
+
line_item_ids: (node.dig("cartLines", "nodes") || []).map { |n| n["id"] },
|
|
130
|
+
options: (node["deliveryOptions"] || []).map { |o| fulfillment_option(o) },
|
|
131
|
+
selected_option_id: node.dig("selectedDeliveryOption", "handle")
|
|
73
132
|
)
|
|
74
133
|
end
|
|
75
134
|
|
|
135
|
+
def fulfillment_option(node)
|
|
136
|
+
Portage::Ucp::FulfillmentOption.new(
|
|
137
|
+
id: node["handle"], title: node["title"] || node["handle"],
|
|
138
|
+
totals: [Portage::Ucp::Total.new(type: "total", amount: minor_units(node["estimatedCost"]))],
|
|
139
|
+
description: node["description"]
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Shopify's CartDeliveryGroup#deliveryAddress is a bare MailingAddress
|
|
144
|
+
# with no id of its own (unlike a saved address-book entry) — since a
|
|
145
|
+
# cart only ever carries the one buyer-submitted address (see
|
|
146
|
+
# #checkout_fulfillment), this always exposes it as the single
|
|
147
|
+
# destination "current" rather than fabricating a hash-based id that
|
|
148
|
+
# would drift for no real change.
|
|
149
|
+
def shipping_destination(address_node)
|
|
150
|
+
return nil unless address_node
|
|
151
|
+
|
|
152
|
+
Portage::Ucp::ShippingDestination.new(id: "current",
|
|
153
|
+
address: Portage::Ucp::PostalAddress.new(**fulfillment_address(address_node)))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def fulfillment_address(node)
|
|
157
|
+
{ street_address: node["address1"], extended_address: node["address2"], address_locality: node["city"],
|
|
158
|
+
address_region: node["provinceCode"], address_country: node["countryCode"], postal_code: node["zip"],
|
|
159
|
+
first_name: node["firstName"], last_name: node["lastName"], phone_number: node["phone"] }.compact
|
|
160
|
+
end
|
|
161
|
+
|
|
76
162
|
# `checkout_id` isn't a Shopify Order field — nothing on Order links
|
|
77
163
|
# back to its originating cart, so the adapter resolves it itself (via
|
|
78
164
|
# a cart_token order search at completion time, see
|
|
@@ -88,7 +174,60 @@ module Portage
|
|
|
88
174
|
line_items: node.dig("lineItems", "nodes").map { |n| order_line_item(n) },
|
|
89
175
|
fulfillment: fulfillment(node),
|
|
90
176
|
currency: node.dig("currentTotalPriceSet", "shopMoney", "currencyCode"),
|
|
91
|
-
totals: Portage::Ucp::Support::Totals.summary(subtotal: subtotal_amount, total: total_amount)
|
|
177
|
+
totals: Portage::Ucp::Support::Totals.summary(subtotal: subtotal_amount, total: total_amount),
|
|
178
|
+
adjustments: adjustments(node)
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# cancel_order/refund_order/request_return (design-log §16) don't map
|
|
183
|
+
# to a dedicated Shopify response shape each — they all resolve back
|
|
184
|
+
# to a fresh GET_ORDER read, so the order's cancellation/refund/return
|
|
185
|
+
# state is read straight off the Order node here rather than
|
|
186
|
+
# synthesized ad hoc per adapter method.
|
|
187
|
+
def adjustments(node)
|
|
188
|
+
[
|
|
189
|
+
*(node["cancelledAt"] ? [cancellation_adjustment(node)] : []),
|
|
190
|
+
*(node["refunds"] || []).map { |n| refund_adjustment(n) },
|
|
191
|
+
*(node.dig("returns", "nodes") || []).map { |n| return_adjustment(n) }
|
|
192
|
+
]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def cancellation_adjustment(node)
|
|
196
|
+
Portage::Ucp::Adjustment.new(
|
|
197
|
+
id: "#{node['id']}-cancellation", type: "cancellation", occurred_at: node["cancelledAt"],
|
|
198
|
+
status: "completed", description: node["cancelReason"]
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def refund_adjustment(node)
|
|
203
|
+
line_items = (node.dig("refundLineItems", "nodes") || []).map { |n| order_line_ref(n, -n["quantity"]) }
|
|
204
|
+
Portage::Ucp::Adjustment.new(
|
|
205
|
+
id: node["id"], type: "refund", occurred_at: node["createdAt"], status: "completed",
|
|
206
|
+
line_items: line_items.empty? ? nil : line_items,
|
|
207
|
+
totals: [Portage::Ucp::Total.new(type: "total", amount: -minor_units(node.dig("totalRefundedSet",
|
|
208
|
+
"shopMoney")))],
|
|
209
|
+
description: node["note"]
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Shopify's ReturnStatus enum (OPEN/REQUESTED/CLOSED/DECLINED/
|
|
214
|
+
# CANCELED/...); unmapped values fall back to "pending", same
|
|
215
|
+
# unmapped-enum posture as DELIVERY_METHOD_TYPES/
|
|
216
|
+
# FULFILLMENT_EVENT_TYPES below.
|
|
217
|
+
RETURN_STATUSES = {
|
|
218
|
+
"OPEN" => "pending", "REQUESTED" => "pending", "CLOSED" => "completed",
|
|
219
|
+
"DECLINED" => "failed", "CANCELED" => "failed"
|
|
220
|
+
}.freeze
|
|
221
|
+
|
|
222
|
+
def return_adjustment(node)
|
|
223
|
+
line_items = (node.dig("returnLineItems", "nodes") || []).map do |n|
|
|
224
|
+
{ "id" => n.dig("fulfillmentLineItem", "lineItem", "id"), "quantity" => -n["quantity"] }
|
|
225
|
+
end
|
|
226
|
+
description = node.dig("returnLineItems", "nodes")&.first&.fetch("returnReasonNote", nil)
|
|
227
|
+
Portage::Ucp::Adjustment.new(
|
|
228
|
+
id: node["id"], type: "return", occurred_at: node["requestedAt"],
|
|
229
|
+
status: RETURN_STATUSES.fetch(node["status"], "pending"),
|
|
230
|
+
line_items: line_items.empty? ? nil : line_items, description: description
|
|
92
231
|
)
|
|
93
232
|
end
|
|
94
233
|
|
|
@@ -38,9 +38,30 @@ module Portage
|
|
|
38
38
|
nodes {
|
|
39
39
|
id quantity
|
|
40
40
|
cost { totalAmount { amount currencyCode } }
|
|
41
|
-
merchandise {
|
|
41
|
+
merchandise {
|
|
42
|
+
... on ProductVariant {
|
|
43
|
+
id
|
|
44
|
+
product { id title }
|
|
45
|
+
price { amount currencyCode }
|
|
46
|
+
availableForSale
|
|
47
|
+
}
|
|
48
|
+
}
|
|
42
49
|
}
|
|
43
50
|
}
|
|
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 }
|
|
58
|
+
}
|
|
59
|
+
discountCodes { code applicable }
|
|
60
|
+
discountAllocations {
|
|
61
|
+
discountedAmount { amount currencyCode }
|
|
62
|
+
... on CartAutomaticDiscountAllocation { title }
|
|
63
|
+
... on CartCodeDiscountAllocation { code }
|
|
64
|
+
}
|
|
44
65
|
GRAPHQL
|
|
45
66
|
|
|
46
67
|
GET_CART = <<~GRAPHQL.freeze
|
|
@@ -73,6 +94,43 @@ module Portage
|
|
|
73
94
|
}
|
|
74
95
|
GRAPHQL
|
|
75
96
|
|
|
97
|
+
# `CartSelectableAddressInput`'s nested shape (Storefront API's
|
|
98
|
+
# current replacement for the deprecated
|
|
99
|
+
# CartBuyerIdentityInput#deliveryAddressPreferences path) is the
|
|
100
|
+
# adapter's mapping of dev.ucp.shopping.fulfillment's
|
|
101
|
+
# ShippingDestination onto it. Confirmed live against a real dev
|
|
102
|
+
# store 2026-08-21 — see design-log.md #18. CART_PAYMENT_UPDATE's
|
|
103
|
+
# paymentMethod sub-shape below is still unconfirmed (roadmap step 5).
|
|
104
|
+
CART_DELIVERY_ADDRESSES_ADD = <<~GRAPHQL.freeze
|
|
105
|
+
mutation CartDeliveryAddressesAdd($cartId: ID!, $addresses: [CartSelectableAddressInput!]!) {
|
|
106
|
+
cartDeliveryAddressesAdd(cartId: $cartId, addresses: $addresses) {
|
|
107
|
+
cart { #{CART_FIELDS} }
|
|
108
|
+
userErrors { field message }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
GRAPHQL
|
|
112
|
+
|
|
113
|
+
CART_SELECTED_DELIVERY_OPTIONS_UPDATE = <<~GRAPHQL.freeze
|
|
114
|
+
mutation CartSelectedDeliveryOptionsUpdate($cartId: ID!,
|
|
115
|
+
$selectedDeliveryOptions: [CartSelectedDeliveryOptionInput!]!) {
|
|
116
|
+
cartSelectedDeliveryOptionsUpdate(cartId: $cartId, selectedDeliveryOptions: $selectedDeliveryOptions) {
|
|
117
|
+
cart { #{CART_FIELDS} }
|
|
118
|
+
userErrors { field message }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
GRAPHQL
|
|
122
|
+
|
|
123
|
+
# Full-replacement, matching dev.ucp.shopping.discount's `codes`
|
|
124
|
+
# semantics — an empty array clears whatever codes were on the cart.
|
|
125
|
+
CART_DISCOUNT_CODES_UPDATE = <<~GRAPHQL.freeze
|
|
126
|
+
mutation CartDiscountCodesUpdate($cartId: ID!, $discountCodes: [String!]!) {
|
|
127
|
+
cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
|
|
128
|
+
cart { #{CART_FIELDS} }
|
|
129
|
+
userErrors { field message }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
GRAPHQL
|
|
133
|
+
|
|
76
134
|
CART_BUYER_IDENTITY_UPDATE = <<~GRAPHQL.freeze
|
|
77
135
|
mutation CartBuyerIdentityUpdate($cartId: ID!, $buyerIdentity: CartBuyerIdentityInput!) {
|
|
78
136
|
cartBuyerIdentityUpdate(cartId: $cartId, buyerIdentity: $buyerIdentity) {
|
|
@@ -110,6 +168,8 @@ module Portage
|
|
|
110
168
|
order(id: $id) {
|
|
111
169
|
id
|
|
112
170
|
statusPageUrl
|
|
171
|
+
cancelledAt
|
|
172
|
+
cancelReason
|
|
113
173
|
currentTotalPriceSet { shopMoney { amount currencyCode } }
|
|
114
174
|
currentSubtotalPriceSet { shopMoney { amount currencyCode } }
|
|
115
175
|
lineItems(first: 100) {
|
|
@@ -139,6 +199,96 @@ module Portage
|
|
|
139
199
|
nodes { quantity lineItem { id } }
|
|
140
200
|
}
|
|
141
201
|
}
|
|
202
|
+
refunds(first: 25) {
|
|
203
|
+
id
|
|
204
|
+
createdAt
|
|
205
|
+
note
|
|
206
|
+
totalRefundedSet { shopMoney { amount currencyCode } }
|
|
207
|
+
refundLineItems(first: 100) {
|
|
208
|
+
nodes { quantity lineItem { id } }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
returns(first: 25) {
|
|
212
|
+
nodes {
|
|
213
|
+
id
|
|
214
|
+
status
|
|
215
|
+
requestedAt
|
|
216
|
+
returnLineItems(first: 100) {
|
|
217
|
+
nodes {
|
|
218
|
+
quantity
|
|
219
|
+
returnReasonNote
|
|
220
|
+
fulfillmentLineItem { lineItem { id } }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
GRAPHQL
|
|
228
|
+
|
|
229
|
+
# orderCancel is async (returns a job, not the updated Order) — the
|
|
230
|
+
# adapter re-fetches via GET_ORDER afterwards for the current state,
|
|
231
|
+
# same posture as #cancel_checkout not trusting a mutation payload
|
|
232
|
+
# over a fresh read. `reason` is fixed to OTHER: UCP's cancel_order
|
|
233
|
+
# only carries a free-text note (`staffNote`), it doesn't expose
|
|
234
|
+
# Shopify's closed OrderCancelReason enum.
|
|
235
|
+
ORDER_CANCEL = <<~GRAPHQL.freeze
|
|
236
|
+
mutation OrderCancel($orderId: ID!, $staffNote: String) {
|
|
237
|
+
orderCancel(orderId: $orderId, reason: OTHER, refund: false, restock: false, staffNote: $staffNote) {
|
|
238
|
+
job { id done }
|
|
239
|
+
orderCancelUserErrors { field message code }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
GRAPHQL
|
|
243
|
+
|
|
244
|
+
# refundCreate requires explicit `transactions:` (which gateway
|
|
245
|
+
# transaction to refund against, and how much) — suggestedRefund is
|
|
246
|
+
# Shopify's documented way to compute that breakdown instead of the
|
|
247
|
+
# adapter guessing at payment-gateway specifics. #refund_order runs
|
|
248
|
+
# this first, then feeds its `transactions` straight into
|
|
249
|
+
# REFUND_CREATE's input.
|
|
250
|
+
SUGGESTED_REFUND = <<~GRAPHQL.freeze
|
|
251
|
+
query SuggestedRefund($orderId: ID!, $refundLineItems: [RefundLineItemInput!]!) {
|
|
252
|
+
order(id: $orderId) {
|
|
253
|
+
suggestedRefund(refundLineItems: $refundLineItems) {
|
|
254
|
+
transactions {
|
|
255
|
+
orderId
|
|
256
|
+
gateway
|
|
257
|
+
kind
|
|
258
|
+
amountSet { shopMoney { amount currencyCode } }
|
|
259
|
+
parentTransaction { id }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
GRAPHQL
|
|
265
|
+
|
|
266
|
+
REFUND_CREATE = <<~GRAPHQL.freeze
|
|
267
|
+
mutation RefundCreate($input: RefundInput!) {
|
|
268
|
+
refundCreate(input: $input) {
|
|
269
|
+
refund {
|
|
270
|
+
id
|
|
271
|
+
createdAt
|
|
272
|
+
note
|
|
273
|
+
totalRefundedSet { shopMoney { amount currencyCode } }
|
|
274
|
+
refundLineItems(first: 100) { nodes { quantity lineItem { id } } }
|
|
275
|
+
}
|
|
276
|
+
userErrors { field message }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
GRAPHQL
|
|
280
|
+
|
|
281
|
+
# returnCreate's returnLineItems key off `fulfillmentLineItemId`, not
|
|
282
|
+
# the plain order-line-item id REFUND_CREATE's refundLineItems use —
|
|
283
|
+
# a real return targets what was actually fulfilled. `request_return`
|
|
284
|
+
# callers pass fulfillment line item ids in `line_items:` for this
|
|
285
|
+
# action specifically; needs confirming against a live store, same
|
|
286
|
+
# caveat as #complete_checkout's payment sub-shape.
|
|
287
|
+
RETURN_CREATE = <<~GRAPHQL.freeze
|
|
288
|
+
mutation ReturnCreate($returnInput: ReturnInput!) {
|
|
289
|
+
returnCreate(returnInput: $returnInput) {
|
|
290
|
+
return { id status }
|
|
291
|
+
userErrors { field message }
|
|
142
292
|
}
|
|
143
293
|
}
|
|
144
294
|
GRAPHQL
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-ucp-shopify
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: portage-ucp
|
|
@@ -16,14 +15,28 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - "~>"
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '0.
|
|
18
|
+
version: '0.2'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - "~>"
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '0.
|
|
25
|
+
version: '0.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
27
40
|
- !ruby/object:Gem::Dependency
|
|
28
41
|
name: rspec
|
|
29
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -83,7 +96,6 @@ dependencies:
|
|
|
83
96
|
description: Implements Portage::Ucp::Adapter against Shopify's Admin (catalog, order)
|
|
84
97
|
and Storefront (cart, checkout) GraphQL APIs. Generic only — no merchant-specific
|
|
85
98
|
business logic. Plain Net::HTTP, no shopify_api runtime dependency.
|
|
86
|
-
email:
|
|
87
99
|
executables:
|
|
88
100
|
- portage-ucp-shopify
|
|
89
101
|
extensions: []
|
|
@@ -108,7 +120,6 @@ metadata:
|
|
|
108
120
|
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-shopify
|
|
109
121
|
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-shopify/CHANGELOG.md
|
|
110
122
|
rubygems_mfa_required: 'true'
|
|
111
|
-
post_install_message:
|
|
112
123
|
rdoc_options: []
|
|
113
124
|
require_paths:
|
|
114
125
|
- lib
|
|
@@ -123,8 +134,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
123
134
|
- !ruby/object:Gem::Version
|
|
124
135
|
version: '0'
|
|
125
136
|
requirements: []
|
|
126
|
-
rubygems_version:
|
|
127
|
-
signing_key:
|
|
137
|
+
rubygems_version: 4.0.18
|
|
128
138
|
specification_version: 4
|
|
129
139
|
summary: Shopify adapter for portage-ucp — standard catalog/cart/checkout/order over
|
|
130
140
|
MCP and UCP
|