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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b3dc91f25f74865134c14455b389377ae19c8fbbf8499d6b2830bbedec4ec26
4
- data.tar.gz: b682672e45cf4242bbbfb67c005e3d1f521f5b3eff17faede2b4a36fe19bd5f6
3
+ metadata.gz: c8f438d6772598dbb5b6fcf3af94f98e6d63d4b2e4b79168aa86821ebb02d087
4
+ data.tar.gz: 72e29d9c756a5fbee5484bb7aa1a31780f7bf7682634da0681a2d6486befc465
5
5
  SHA512:
6
- metadata.gz: 5132d69c63d402f907f51ce56bcc97bc2d3ac41ded7554cba49de1b7d1cc333d17beeca7fdfd6c7e48e17458df3b998e8145afbd79bfebc9b8019761495770d0
7
- data.tar.gz: 901cae55f88b03b6af29feb48c30656921f77c7f97a88366c05e5556b3d7a980062a9f6f52f6d71c6ddf6b90cdd6b7ad8a7c286e0472ffe8e8a9a0cf4a8b1be8
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.1.0] - Unreleased
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
- data = @client.storefront_query(Queries::CART_CREATE, variables: { input: { lines: cart_lines(line_items) } })
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
- added = @client.storefront_query(Queries::CART_LINES_ADD,
151
- variables: { cartId: cart_id, lines: cart_lines(line_items) })
152
- unwrap!(added, "cartLinesAdd")
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 { ... on ProductVariant { id product { id title } price { amount currencyCode } } }
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
@@ -1,7 +1,7 @@
1
1
  module Portage
2
2
  module Ucp
3
3
  module Shopify
4
- VERSION = "0.1.0".freeze
4
+ VERSION = "0.2.0".freeze
5
5
  end
6
6
  end
7
7
  end
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.1.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: 2026-08-14 00:00:00.000000000 Z
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.1'
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.1'
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: 3.5.22
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