portage-ucp-magento 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 85fd968524956be2175b09568a42d4ebd9613f2004c9dd3e4497b36c14f60eb9
4
+ data.tar.gz: f97c08659973cc75e6569cce14166af3e665dc41427442acd8c5fdb08bd42d0c
5
+ SHA512:
6
+ metadata.gz: 6ace0250eb9bb47f8efe87fa9e8195ce043c3ff6ec58c7da409070572d8e61358dd54b7916e21f35fcec3873fd45ad1c59e0b5c5b2a35b822631763e856f4a5b
7
+ data.tar.gz: a028622437eaf233e7e786419151c9421ed68885c8dab4b569252ce5c5ca66a6352fff7aeea7a3f4add23d453e3246272ea22be72eeb6e340a0e7c954d1246f5
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format loosely follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
+ pre-1.0, so APIs may still shift between minor versions.
6
+
7
+ ## [0.1.0] - Unreleased
8
+
9
+ - Initial pre-release. Magento/Adobe Commerce adapter against the REST v1 API
10
+ (admin-token catalog/order, anonymous guest-cart cart/checkout).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Whitbread
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # portage-ucp-magento
2
+
3
+ Magento/Adobe Commerce adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against a Magento site's REST v1 API. Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no Magento PHP SDK dependency.
4
+
5
+ ## What it covers
6
+
7
+ | UCP capability | Backing Magento API | Notes |
8
+ |---|---|---|
9
+ | `dev.ucp.shopping.catalog` | Admin-token REST v1 | `search_catalog`, `get_product` |
10
+ | `dev.ucp.shopping.cart` | Anonymous guest-cart REST v1 | `get_cart`, `create_cart`, `update_cart`, `cancel_cart` |
11
+ | `dev.ucp.shopping.checkout` | Same guest-cart, plus `shipping-information`/`payment-information` | `create_checkout`, `get_checkout`, `update_checkout`, `complete_checkout`, `cancel_checkout` |
12
+ | `dev.ucp.shopping.order` | Admin-token REST v1 | `get_order` |
13
+ | `dev.ucp.shopping.identity` | — | not implemented; Magento customer account login is a separate concern from the admin token + anonymous guest-cart flow used here |
14
+
15
+ Like Shopify, a Magento guest cart *is* the checkout — there's no separate Checkout resource, just the same cart plus two extra calls at completion time. Cart/checkout ids are Magento's masked guest-cart id, not a numeric id. Line items are keyed by **sku**, not product id — that's what Magento's `cartItem` payloads take, so `product_id` in every `line_items:` argument means sku here.
16
+
17
+ Update/replace operations (`update_cart`, `update_checkout`) are full-replacement: the guest-cart items API has no atomic "replace all lines" either, 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
+
19
+ Like Shopify/Wix, there's an `AccessTokenFetcher` — but it's a plain username/password exchange (`/rest/V1/integration/admin/token`), not a client_credentials grant, and Magento doesn't report an expiry, so plan to refresh on a 401 rather than a known lifetime.
20
+
21
+ ## ⚠️ Unverified against a live site, and one real gap
22
+
23
+ Built from Magento's documented REST shapes, not run against a real site yet.
24
+
25
+ - **`#complete_checkout` needs an address UCP doesn't carry.** Magento's real guest-checkout flow requires a billing/shipping address before `payment-information` will create an order, but `complete_checkout(checkout_id:, payment_token:, idempotency_key:)` has no address parameter at all. This adapter works around that with a single `default_address:` configured once at initialization — every order ships and bills to the same address. That's fine for a single-fulfillment-address integration (e.g. all-digital goods, or a business account with one known destination) and wrong for anything where the address varies per order. A real UCP address extension, if one lands, would replace this.
26
+ - **`payment_data` shape is gateway-specific** (configured via `payment_method:`/`payment_data_key:`) and hasn't been confirmed against a live site with a real payment gateway installed — same posture as the other adapters' payment steps.
27
+ - **Order fulfillment** — Magento core has no per-line-item fulfillment tracking on the Order resource (that's the separate Shipments API), so every order line gets the same coarse status derived from the order's own top-level `status`.
28
+ - **`Order#permalink_url`** — left blank; Magento's Orders API doesn't return a public order-status page URL.
29
+ - **Configurable product variant titles** — built from each child's own `name`, not verified against how a real store names its configurable children.
30
+
31
+ ## Installation
32
+
33
+ ```ruby
34
+ # Gemfile
35
+ gem "portage-ucp-magento"
36
+ ```
37
+
38
+ ```bash
39
+ bundle install
40
+ ```
41
+
42
+ ## Setup
43
+
44
+ You need a base URL, an admin bearer token (catalog/order), your store's currency (the product resource doesn't return one), and — only if you'll call `complete_checkout` — a payment method id and a default address.
45
+
46
+ ```ruby
47
+ require "portage/ucp/magento"
48
+
49
+ client = Portage::Ucp::Magento::Client.new(
50
+ base_url: "https://your-shop.example.com",
51
+ admin_token: ENV.fetch("MAGENTO_ADMIN_TOKEN")
52
+ )
53
+
54
+ adapter = Portage::Ucp::Magento::Adapter.new(
55
+ client: client,
56
+ currency: "USD",
57
+ site_url: "https://your-shop.example.com",
58
+ payment_method: "checkmo", # only required for #complete_checkout
59
+ default_address: { firstname: "Store", lastname: "Fulfillment", street: ["1 Main St"], city: "Boston",
60
+ region: "MA", postcode: "02110", country_id: "US", telephone: "555-0100",
61
+ email: "orders@your-shop.example.com" }
62
+ )
63
+ ```
64
+
65
+ ### Fetching an admin token from username/password
66
+
67
+ ```ruby
68
+ fetcher = Portage::Ucp::Magento::AccessTokenFetcher.new(
69
+ base_url: "https://your-shop.example.com",
70
+ username: ENV.fetch("MAGENTO_USERNAME"),
71
+ password: ENV.fetch("MAGENTO_PASSWORD")
72
+ )
73
+
74
+ result = fetcher.fetch
75
+ result.access_token # => admin_token to pass into Client.new
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ ```ruby
81
+ # Catalog — product_id is a sku, e.g. "cold-brew"
82
+ products = adapter.search_catalog(query: "cold brew", limit: 10)
83
+ product = adapter.get_product(product_id: products.first.id)
84
+
85
+ # Cart — cart_id is only known after the first call, since Magento assigns
86
+ # it (as a masked guest-cart id) rather than taking one from the caller
87
+ cart = adapter.create_cart(
88
+ line_items: [{ product_id: product.variants.first[:id], quantity: 2 }],
89
+ idempotency_key: SecureRandom.uuid
90
+ )
91
+ cart = adapter.update_cart(cart_id: cart.id, line_items: [], idempotency_key: SecureRandom.uuid) # empties cart
92
+
93
+ # Checkout
94
+ checkout = adapter.create_checkout(
95
+ line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
96
+ idempotency_key: SecureRandom.uuid
97
+ )
98
+ checkout = adapter.complete_checkout(
99
+ checkout_id: checkout.id,
100
+ payment_token: single_use_token_from_payment_handler,
101
+ idempotency_key: SecureRandom.uuid
102
+ )
103
+
104
+ # Order
105
+ order = adapter.get_order(order_id: checkout_order_id) # only once linked post-completion
106
+ ```
107
+
108
+ ## Wiring into portage-ucp
109
+
110
+ Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
111
+
112
+ ```ruby
113
+ dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
114
+
115
+ dispatcher.call(
116
+ capability: "dev.ucp.shopping.cart",
117
+ action: "create",
118
+ arguments: { line_items: [{ product_id: "cold-brew", quantity: 1 }], idempotency_key: SecureRandom.uuid }
119
+ )
120
+ ```
121
+
122
+ Because `link_identity` is left unoverridden, `Capability#advertised_for?` simply won't advertise `dev.ucp.shopping.identity` for this adapter — callers get an absent capability, not a 500.
123
+
124
+ ## Errors
125
+
126
+ ```ruby
127
+ Portage::Ucp::Magento::Error # base class
128
+ Portage::Ucp::Magento::ApiError # any non-2xx response from Magento's REST API
129
+ ```
130
+
131
+ ## Development
132
+
133
+ ```bash
134
+ bundle exec rspec # tests (WebMock-stubbed, no live site needed)
135
+ bundle exec rubocop # lint
136
+
137
+ # fetch a real admin token for a dev site
138
+ MAGENTO_BASE_URL=https://your-shop.example.com \
139
+ MAGENTO_USERNAME=... MAGENTO_PASSWORD=... \
140
+ bundle exec rake magento_access_token
141
+ ```
142
+
143
+ ## License
144
+
145
+ [MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Standalone MCP server over stdio. Configure the site connection via env
5
+ # vars, and — since this gem ships with permissive-nothing defaults (see
6
+ # README's "Security hooks") — wire a real Authenticator/RateLimiter/etc. via
7
+ # a config file loaded with PORTAGE_UCP_CONFIG, the same way `rackup -r` or
8
+ # Sidekiq's `-r` work — see ../examples/portage_ucp.rb for a starting point.
9
+ # Without one, the server still starts but rejects every mutating call.
10
+ #
11
+ # Only MAGENTO_ADMIN_TOKEN is read here, not username/password: this exe
12
+ # expects a bearer token you've already fetched (e.g. via
13
+ # `bundle exec rake magento_access_token`) and refresh out-of-band, since
14
+ # Magento's token endpoint reports no expiry to plan a refresh against.
15
+ #
16
+ # MAGENTO_PAYMENT_METHOD/MAGENTO_DEFAULT_ADDRESS (a JSON object) are optional
17
+ # — only needed to call #complete_checkout, see the README's CAVEATS.
18
+
19
+ require "json"
20
+ require "portage/ucp"
21
+ require "portage/ucp/magento"
22
+
23
+ require ENV["PORTAGE_UCP_CONFIG"] if ENV["PORTAGE_UCP_CONFIG"]
24
+
25
+ client = Portage::Ucp::Magento::Client.new(
26
+ base_url: ENV.fetch("MAGENTO_BASE_URL"),
27
+ admin_token: ENV.fetch("MAGENTO_ADMIN_TOKEN")
28
+ )
29
+ default_address = ENV.fetch("MAGENTO_DEFAULT_ADDRESS", nil)
30
+
31
+ adapter = Portage::Ucp::Magento::Adapter.new(
32
+ client: client,
33
+ currency: ENV.fetch("MAGENTO_CURRENCY", "USD"),
34
+ site_url: ENV.fetch("MAGENTO_SITE_URL", ENV.fetch("MAGENTO_BASE_URL", nil)),
35
+ payment_method: ENV.fetch("MAGENTO_PAYMENT_METHOD", nil),
36
+ default_address: default_address && JSON.parse(default_address, symbolize_names: true)
37
+ )
38
+
39
+ Portage::Ucp::Mcp::Server.build(adapter: adapter).start
@@ -0,0 +1,40 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Magento
7
+ # Exchanges Magento admin credentials for a bearer token via
8
+ # `/rest/V1/integration/admin/token`.
9
+ #
10
+ # Unlike Shopify/Wix's client_credentials grant, this is a plain
11
+ # username/password exchange (Magento's third-party OAuth1 Integration
12
+ # flow is the alternative, but that signs every request rather than
13
+ # handing back a bearer token, which doesn't fit this gem's plain-
14
+ # Net::HTTP posture). Also unlike Shopify/Wix, the endpoint returns a
15
+ # bare token string with no `expires_in` — Magento's default admin
16
+ # token lifetime is a store-config value (`admin/security/session_
17
+ # lifetime`-adjacent, but token-specific), not something this response
18
+ # reports, so callers need their own refresh-on-401 strategy rather
19
+ # than a reported expiry.
20
+ class AccessTokenFetcher
21
+ include Portage::Ucp::Support::TokenExchange
22
+
23
+ Result = Struct.new(:access_token, keyword_init: true)
24
+
25
+ def initialize(base_url:, username:, password:)
26
+ @base_url = base_url.chomp("/")
27
+ @username = username
28
+ @password = password
29
+ end
30
+
31
+ def fetch
32
+ body = exchange("#{@base_url}/rest/V1/integration/admin/token",
33
+ { username: @username, password: @password },
34
+ error_class: Portage::Ucp::Magento::Error)
35
+ Result.new(access_token: body)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,248 @@
1
+ require "uri"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Magento
6
+ # Generic Portage::Ucp::Adapter over a Magento/Adobe Commerce site's
7
+ # REST v1 API — no merchant-specific business logic, same posture as
8
+ # Portage::Ucp::Shopify::Adapter and Portage::Ucp::WooCommerce::Adapter.
9
+ #
10
+ # Deliberately doesn't override `link_identity`: Magento customer
11
+ # account login is a separate concern from the admin token and
12
+ # anonymous guest-cart flow this gem uses, so this generic adapter
13
+ # leaves it unimplemented — Capability#advertised_for? simply won't
14
+ # advertise dev.ucp.shopping.identity for this adapter, not a 500.
15
+ #
16
+ # Catalog and Order are read through the admin-token API. Cart and
17
+ # Checkout are read/written through the anonymous guest-cart API — a
18
+ # Magento guest cart *is* the checkout (there's no separate Checkout
19
+ # resource), same as Shopify's Cart-as-Checkout.
20
+ #
21
+ # IMPORTANT CAVEATS:
22
+ #
23
+ # - #complete_checkout needs a billing/shipping address to call
24
+ # Magento's real guest-checkout flow (`shipping-information` then
25
+ # `payment-information`) — UCP's `complete_checkout(checkout_id:,
26
+ # payment_token:, idempotency_key:)` carries no address at all. This
27
+ # adapter works around that with a single `default_address:`
28
+ # configured at initialization (every order ships/bills to the
29
+ # same address) rather than guessing a UCP address extension that
30
+ # doesn't exist yet — fine for a single-fulfillment-address
31
+ # integration, wrong for anything else.
32
+ # - `payment_data` shape is gateway-specific (configured via
33
+ # `payment_method:`/`payment_data_key:`) and hasn't been confirmed
34
+ # against a live site with a real gateway installed — same posture
35
+ # as Portage::Ucp::WooCommerce::Adapter's own payment step.
36
+ class Adapter < Portage::Ucp::Adapter
37
+ # The guest-cart API takes no idempotency key natively, so §9a dedup
38
+ # comes from Support::Idempotency's in-process table.
39
+ include Portage::Ucp::Support::Idempotency
40
+ # A Magento guest cart has no native status field, and an Order's own
41
+ # `quote_id` is the cart's internal integer id rather than the masked
42
+ # guest-cart id this gem uses (see Mapper.order) — both are tracked
43
+ # adapter-side via Support::CheckoutState.
44
+ include Portage::Ucp::Support::CheckoutState
45
+ # The admin-token API answers a missing product or order with a 404
46
+ # rather than an empty body, which UCP's reads report as nil.
47
+ include Portage::Ucp::Support::NotFound
48
+
49
+ def initialize(client:, currency:, site_url: nil, payment_method: nil, payment_data_key: "cc_token",
50
+ default_address: nil)
51
+ super()
52
+ @client = client
53
+ @currency = currency
54
+ @site_url = site_url&.chomp("/")
55
+ @payment_method = payment_method
56
+ @payment_data_key = payment_data_key
57
+ @default_address = default_address
58
+ end
59
+
60
+ def search_catalog(query:, limit:)
61
+ params = { "searchCriteria[filterGroups][0][filters][0][field]" => "name",
62
+ "searchCriteria[filterGroups][0][filters][0][value]" => "%#{query}%",
63
+ "searchCriteria[filterGroups][0][filters][0][conditionType]" => "like",
64
+ "searchCriteria[pageSize]" => limit }
65
+ data = @client.admin_get("/products?#{URI.encode_www_form(params)}")
66
+ (data["items"] || []).map { |node| Mapper.product(node, currency: @currency, site_url: @site_url) }
67
+ end
68
+
69
+ def get_product(product_id:)
70
+ nil_on_not_found do
71
+ node = @client.admin_get("/products/#{URI.encode_www_form_component(product_id)}")
72
+ node["sku"] ? Mapper.product(with_children(node), currency: @currency, site_url: @site_url) : nil
73
+ end
74
+ end
75
+
76
+ def get_cart(cart_id:)
77
+ items, currency = cart_snapshot(cart_id)
78
+ Mapper.cart(items, id: cart_id, currency: currency)
79
+ end
80
+
81
+ def create_cart(line_items:, idempotency_key:)
82
+ dedup(idempotency_key) do
83
+ cart_id = @client.guest_post("/guest-carts")
84
+ add_items(cart_id, line_items)
85
+ items, currency = cart_snapshot(cart_id)
86
+ Mapper.cart(items, id: cart_id, currency: currency)
87
+ end
88
+ end
89
+
90
+ # Full replacement, same rationale as Portage::Ucp::Shopify::Adapter
91
+ # #update_cart: Magento's guest-cart items API has no atomic
92
+ # "replace all lines" either, so this removes every current line
93
+ # then adds the desired ones back.
94
+ def update_cart(cart_id:, line_items:, idempotency_key:)
95
+ dedup(idempotency_key) do
96
+ replace_items(cart_id, line_items)
97
+ items, currency = cart_snapshot(cart_id)
98
+ Mapper.cart(items, id: cart_id, currency: currency)
99
+ end
100
+ end
101
+
102
+ # A guest cart is tied to its quote, not a deletable resource with
103
+ # its own lifecycle — there's no cancellation call, so this clears
104
+ # every line and returns the now-empty cart, the closest real
105
+ # equivalent (same posture as Portage::Ucp::WooCommerce::Adapter).
106
+ def cancel_cart(cart_id:, idempotency_key:)
107
+ dedup(idempotency_key) do
108
+ replace_items(cart_id, [])
109
+ items, currency = cart_snapshot(cart_id)
110
+ Mapper.cart(items, id: cart_id, currency: currency)
111
+ end
112
+ end
113
+
114
+ def create_checkout(line_items:, idempotency_key:)
115
+ dedup(idempotency_key) do
116
+ cart_id = @client.guest_post("/guest-carts")
117
+ add_items(cart_id, line_items)
118
+ record_checkout_status(cart_id, "incomplete")
119
+ items, currency = cart_snapshot(cart_id)
120
+ Mapper.checkout(items, id: cart_id, currency: currency, status: "incomplete")
121
+ end
122
+ end
123
+
124
+ def get_checkout(checkout_id:)
125
+ items, currency = cart_snapshot(checkout_id)
126
+ Mapper.checkout(items, id: checkout_id, currency: currency,
127
+ status: checkout_status(checkout_id))
128
+ end
129
+
130
+ # Full replacement, same rationale as #update_cart.
131
+ def update_checkout(checkout_id:, line_items:, idempotency_key:)
132
+ dedup(idempotency_key) do
133
+ replace_items(checkout_id, line_items)
134
+ items, currency = cart_snapshot(checkout_id)
135
+ record_checkout_status(checkout_id, "incomplete")
136
+ Mapper.checkout(items, id: checkout_id, currency: currency, status: "incomplete")
137
+ end
138
+ end
139
+
140
+ def complete_checkout(checkout_id:, payment_token:, idempotency_key:)
141
+ dedup(idempotency_key) { submit_checkout(checkout_id, payment_token) }
142
+ end
143
+
144
+ # Magento has no cancellation endpoint for an in-progress guest cart
145
+ # either (see #cancel_cart) — this just marks the tracked status
146
+ # canceled without touching the underlying cart contents.
147
+ def cancel_checkout(checkout_id:, idempotency_key:)
148
+ dedup(idempotency_key) do
149
+ record_checkout_status(checkout_id, "canceled")
150
+ items, currency = cart_snapshot(checkout_id)
151
+ Mapper.checkout(items, id: checkout_id, currency: currency, status: "canceled")
152
+ end
153
+ end
154
+
155
+ def get_order(order_id:)
156
+ nil_on_not_found do
157
+ node = @client.admin_get("/orders/#{order_id}")
158
+ next nil unless node["entity_id"]
159
+
160
+ Mapper.order(node, checkout_id: checkout_id_for(order_id))
161
+ end
162
+ end
163
+
164
+ private
165
+
166
+ # Merges the guest-cart `items` resource (sku/name/qty/price) with
167
+ # the `totals` resource (row_total/tax_amount/quote_currency_code)
168
+ # by `item_id` — see Mapper.cart's comment on why Magento needs this
169
+ # two-call merge where Shopify/Wix/WooCommerce each have one
170
+ # response that already carries everything.
171
+ def cart_snapshot(cart_id)
172
+ items = @client.guest_get("/guest-carts/#{cart_id}/items")
173
+ totals_data = @client.guest_get("/guest-carts/#{cart_id}/totals")
174
+ totals_by_id = (totals_data["items"] || []).to_h { |t| [t["item_id"], t] }
175
+ merged = items.map { |i| i.merge(totals_by_id.fetch(i["item_id"], {})) }
176
+ [merged, totals_data["quote_currency_code"] || @currency]
177
+ end
178
+
179
+ # A variable product's own resource only lists child skus under
180
+ # `extension_attributes.configurable_product_links` — fetching the
181
+ # full child product objects is a second call, only made for
182
+ # #get_product's single-product path, not #search_catalog's list
183
+ # (an N+1 fetch per search result would be too expensive).
184
+ def with_children(node)
185
+ return node unless node["type_id"] == "configurable"
186
+
187
+ children = @client.admin_get("/configurable-products/#{URI.encode_www_form_component(node['sku'])}/children")
188
+ node.merge("children_detail" => children)
189
+ end
190
+
191
+ def add_items(cart_id, line_items)
192
+ line_items.each do |li|
193
+ @client.guest_post("/guest-carts/#{cart_id}/items",
194
+ { cartItem: { sku: li[:product_id], qty: li[:quantity], quote_id: cart_id } })
195
+ end
196
+ end
197
+
198
+ def replace_items(cart_id, line_items)
199
+ current = @client.guest_get("/guest-carts/#{cart_id}/items")
200
+ current.each { |item| @client.guest_delete("/guest-carts/#{cart_id}/items/#{item['item_id']}") }
201
+ add_items(cart_id, line_items)
202
+ end
203
+
204
+ # See the class-level CAVEATS: `default_address` stands in for
205
+ # UCP's missing per-checkout address, and `payment_data` uses a
206
+ # single configurable key rather than a confirmed gateway-specific
207
+ # shape.
208
+ def verify_checkout_configured!
209
+ raise Portage::Ucp::Magento::Error, "no payment_method configured on this Adapter" unless @payment_method
210
+ raise Portage::Ucp::Magento::Error, "no default_address configured on this Adapter" unless @default_address
211
+ end
212
+
213
+ def submit_checkout(checkout_id, payment_token)
214
+ verify_checkout_configured!
215
+ items, currency = cart_snapshot(checkout_id)
216
+ submit_shipping_information(checkout_id)
217
+ order_id = submit_payment_information(checkout_id, payment_token)
218
+ record_checkout_status(checkout_id, "completed")
219
+ record_order_checkout(order_id, checkout_id)
220
+ Mapper.checkout(items, id: checkout_id, currency: currency, status: "completed",
221
+ order: order_confirmation(order_id))
222
+ end
223
+
224
+ def submit_shipping_information(checkout_id)
225
+ @client.guest_post("/guest-carts/#{checkout_id}/shipping-information",
226
+ { addressInformation: { shipping_address: @default_address,
227
+ billing_address: @default_address,
228
+ shipping_carrier_code: "flatrate",
229
+ shipping_method_code: "flatrate" } })
230
+ end
231
+
232
+ def submit_payment_information(checkout_id, payment_token)
233
+ @client.guest_post("/guest-carts/#{checkout_id}/payment-information",
234
+ { email: @default_address[:email] || "guest@example.com",
235
+ paymentMethod: { method: @payment_method,
236
+ additional_data: { @payment_data_key => payment_token } },
237
+ billingAddress: @default_address })
238
+ end
239
+
240
+ # Same blank permalink_url posture as Mapper.order — Magento has no
241
+ # public order-status link to hand back here either.
242
+ def order_confirmation(order_id)
243
+ Portage::Ucp::OrderConfirmation.new(id: order_id.to_s, permalink_url: "")
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,71 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Magento
7
+ # Minimal REST client over a Magento/Adobe Commerce site's `/rest/V1`
8
+ # API.
9
+ #
10
+ # Deliberately plain Net::HTTP, not the Magento PHP SDK (there isn't a
11
+ # Ruby one) — a generic adapter that any Ruby app can drop in only
12
+ # needs a base URL and a bearer token, no framework coupling,
13
+ # trivially stubbable with WebMock.
14
+ #
15
+ # Like Shopify's Admin/Storefront split and WooCommerce's Admin/Store
16
+ # API split, Magento's REST surface has two effective auth tiers under
17
+ # the same `/rest/V1` path:
18
+ #
19
+ # - Admin-token calls (catalog, order): require a Bearer token minted
20
+ # from admin credentials (see
21
+ # Portage::Ucp::Magento::AccessTokenFetcher) or a pre-generated
22
+ # Integration token.
23
+ # - Guest-cart calls (cart, checkout): Magento's guest-cart endpoints
24
+ # (`/guest-carts/*`) are anonymous by design — no token at all,
25
+ # identified purely by the masked cart id in the URL.
26
+ class Client
27
+ include Portage::Ucp::Support::HttpClient
28
+
29
+ def initialize(base_url:, admin_token: nil)
30
+ @base_url = base_url.chomp("/")
31
+ @admin_token = admin_token
32
+ end
33
+
34
+ def admin_get(path)
35
+ admin_request(Net::HTTP::Get, path)
36
+ end
37
+
38
+ def admin_post(path, body = {})
39
+ admin_request(Net::HTTP::Post, path, body)
40
+ end
41
+
42
+ def guest_get(path)
43
+ guest_request(Net::HTTP::Get, path)
44
+ end
45
+
46
+ def guest_post(path, body = {})
47
+ guest_request(Net::HTTP::Post, path, body)
48
+ end
49
+
50
+ def guest_delete(path)
51
+ guest_request(Net::HTTP::Delete, path)
52
+ end
53
+
54
+ private
55
+
56
+ def admin_request(http_method, path, body = nil)
57
+ raise ArgumentError, "Portage::Ucp::Magento::Client requires admin_token for this call" unless @admin_token
58
+
59
+ json_request(http_method, "#{@base_url}/rest/V1#{path}",
60
+ body: body, headers: { "Authorization" => "Bearer #{@admin_token}" })
61
+ end
62
+
63
+ def guest_request(http_method, path, body = nil)
64
+ json_request(http_method, "#{@base_url}/rest/V1#{path}", body: body)
65
+ end
66
+
67
+ def api_error_class = Portage::Ucp::Magento::ApiError
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,21 @@
1
+ module Portage
2
+ module Ucp
3
+ module Magento
4
+ class Error < StandardError; end
5
+
6
+ # Raised for any non-2xx response from Magento's REST API (`/rest/V1`)
7
+ # — both the admin-token side (catalog, order) and the anonymous
8
+ # guest-cart side (cart, checkout) report errors the same way: a
9
+ # non-2xx status with a JSON `{message, parameters}` body.
10
+ class ApiError < Error
11
+ include Portage::Ucp::Support::ApiError
12
+
13
+ private
14
+
15
+ def detail(body)
16
+ body["message"] || body
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,183 @@
1
+ module Portage
2
+ module Ucp
3
+ module Magento
4
+ # Converts Magento REST v1 response bodies into the protocol-layer
5
+ # value objects from Portage::Ucp::ValueObjects — nothing Magento-
6
+ # shaped is allowed to leak past this file.
7
+ #
8
+ # `currency` is threaded in by the caller everywhere it's needed: the
9
+ # product resource has no currency field (a site-wide/store-view
10
+ # setting, not per-product), same limitation as WooCommerce's Admin
11
+ # product resource.
12
+ #
13
+ # Cart items are identified by `sku`, not a numeric id — Magento's
14
+ # `cartItem` add/update payloads take `sku`, so that's what's threaded
15
+ # through as UCP's `product_id` everywhere in this gem, not the
16
+ # product's numeric `id`.
17
+ module Mapper
18
+ module_function
19
+
20
+ def money(amount, currency)
21
+ Portage::Ucp::Support::Amounts.money(amount, currency)
22
+ end
23
+
24
+ def minor_units(amount)
25
+ Portage::Ucp::Support::Amounts.decimal_to_minor(amount)
26
+ end
27
+
28
+ def custom_attribute(node, code)
29
+ (node["custom_attributes"] || []).find { |a| a["attribute_code"] == code }&.dig("value")
30
+ end
31
+
32
+ def in_stock?(node)
33
+ node.dig("extension_attributes", "stock_item", "is_in_stock") != false
34
+ end
35
+
36
+ # `node["children_detail"]` is adapter-populated, not a real Magento
37
+ # field: a configurable product's own resource only lists child skus
38
+ # under `extension_attributes.configurable_product_links` (bare ids)
39
+ # — fetching the full child product objects
40
+ # (`/configurable-products/{sku}/children`) is a second request the
41
+ # Adapter makes and merges in before calling this, same reasoning as
42
+ # Portage::Ucp::WooCommerce::Mapper's `variations_detail`.
43
+ def product(node, currency:, site_url: nil)
44
+ Portage::Ucp::Product.new(
45
+ id: node["sku"],
46
+ title: node["name"],
47
+ description: custom_attribute(node, "description"),
48
+ price: money(node["price"], currency),
49
+ available: node["status"] == 1 && in_stock?(node),
50
+ variants: variants(node, currency),
51
+ url: product_url(node, site_url)
52
+ )
53
+ end
54
+
55
+ def product_url(node, site_url)
56
+ url_key = custom_attribute(node, "url_key")
57
+ return nil unless site_url && url_key
58
+
59
+ "#{site_url}/#{url_key}.html"
60
+ end
61
+
62
+ # A simple product has no real variants — it's its own single
63
+ # implicit variant, same as a single-variant Shopify product using
64
+ # that variant's own id.
65
+ def variants(node, currency)
66
+ children = node["children_detail"]
67
+ unless children
68
+ return [{ id: node["sku"], title: node["name"], available: node["status"] == 1 && in_stock?(node),
69
+ price: money(node["price"], currency) }]
70
+ end
71
+
72
+ children.map do |c|
73
+ { id: c["sku"], title: c["name"], available: c["status"] == 1 && in_stock?(c),
74
+ price: money(c["price"], currency) }
75
+ end
76
+ end
77
+
78
+ # `id:` and `currency:` are caller-supplied: the guest-cart `items`
79
+ # resource (sku/name/qty/price) and `totals` resource (row totals,
80
+ # tax, `quote_currency_code`) are two separate Magento REST calls —
81
+ # the Adapter merges them by `item_id` before calling this, same
82
+ # reasoning as Portage::Ucp::WooCommerce::Mapper needing `id:`
83
+ # supplied (Magento's cart has no single resource id of its own
84
+ # either, just the masked cart id used in the URL).
85
+ def cart(items, id:, currency:)
86
+ Portage::Ucp::Cart.new(
87
+ id: id,
88
+ line_items: items.map { |n| cart_line_item(n) },
89
+ currency: currency,
90
+ totals: totals(items)
91
+ )
92
+ end
93
+
94
+ # `status` isn't a Magento cart field — a Magento guest cart *is*
95
+ # the checkout (there's no separate Checkout resource, only the
96
+ # shipping-information/payment-information calls that act on the
97
+ # same cart), so the Adapter tracks status itself and passes it in
98
+ # here, same rationale as Shopify's Cart-as-Checkout.
99
+ def checkout(items, id:, currency:, status:, order: nil)
100
+ Portage::Ucp::Checkout.new(
101
+ id: id,
102
+ status: status,
103
+ line_items: items.map { |n| cart_line_item(n) },
104
+ currency: currency,
105
+ totals: totals(items),
106
+ links: [],
107
+ order: order
108
+ )
109
+ end
110
+
111
+ def cart_line_item(node)
112
+ line_total = minor_units(node["row_total"])
113
+ quantity = node["qty"]
114
+ unit_price = quantity.to_i.positive? ? (line_total / quantity).round : minor_units(node["price"])
115
+ Portage::Ucp::LineItem.new(
116
+ id: node["item_id"].to_s,
117
+ item: Portage::Ucp::Item.new(id: node["sku"], title: node["name"], price: unit_price),
118
+ quantity: quantity,
119
+ totals: Portage::Ucp::Support::Totals.line(line_total)
120
+ )
121
+ end
122
+
123
+ # Builds the top-level totals array from the merged line items'
124
+ # row_total (subtotal) plus each line's own tax_amount, since
125
+ # there's no single caller-supplied totals hash here the way
126
+ # Shopify/Wix/WooCommerce have one — see #cart's comment on why
127
+ # items/totals are merged per-line before reaching this module.
128
+ def totals(items)
129
+ subtotal = items.sum { |n| minor_units(n["row_total"]) }
130
+ tax = items.sum { |n| minor_units(n["tax_amount"]) }
131
+ Portage::Ucp::Support::Totals.summary(subtotal: subtotal, tax: tax, total: subtotal + tax)
132
+ end
133
+
134
+ # Magento core has no per-line-item fulfillment tracking on the
135
+ # Order resource itself (that's the separate Shipments API) — every
136
+ # line is given the same coarse status derived from the order's own
137
+ # top-level `status`, same simplification as
138
+ # Portage::Ucp::WooCommerce::Mapper.
139
+ ORDER_LINE_ITEM_STATUS = {
140
+ "complete" => "fulfilled", "closed" => "fulfilled", "processing" => "processing",
141
+ "pending" => "processing", "canceled" => "removed"
142
+ }.freeze
143
+
144
+ # `permalink_url` is left blank — Magento's Orders API doesn't
145
+ # return a public order-status page URL (guest order tracking is a
146
+ # storefront form, not a direct link). `checkout_id` isn't a
147
+ # reachable Magento order field either: `order.quote_id` is the
148
+ # cart's *internal* integer id, not the masked guest-cart id used in
149
+ # every REST URL, and Magento doesn't expose the mapping between the
150
+ # two via the API — so, like WooCommerce, the Adapter records it
151
+ # itself at #complete_checkout time instead.
152
+ def order(node, checkout_id: "")
153
+ currency = node["order_currency_code"]
154
+ subtotal = minor_units(node["subtotal"])
155
+ total = minor_units(node["grand_total"])
156
+ status = Portage::Ucp::Support::LineItemStatus.from_table(ORDER_LINE_ITEM_STATUS, node["status"])
157
+ Portage::Ucp::Order.new(
158
+ id: node["entity_id"].to_s,
159
+ checkout_id: checkout_id,
160
+ permalink_url: "",
161
+ line_items: (node["items"] || []).map { |n| order_line_item(n, status) },
162
+ fulfillment: Portage::Ucp::Fulfillment.new,
163
+ currency: currency,
164
+ totals: Portage::Ucp::Support::Totals.summary(subtotal: subtotal, total: total)
165
+ )
166
+ end
167
+
168
+ def order_line_item(node, status)
169
+ quantity = node["qty_ordered"].to_i
170
+ fulfilled = Portage::Ucp::Support::LineItemStatus.fulfilled_quantity(status, quantity)
171
+ line_total = minor_units(node["row_total"])
172
+ Portage::Ucp::OrderLineItem.new(
173
+ id: node["item_id"].to_s,
174
+ item: Portage::Ucp::Item.new(id: node["sku"], title: node["name"], price: minor_units(node["price"])),
175
+ quantity: { original: quantity, total: quantity, fulfilled: fulfilled },
176
+ totals: Portage::Ucp::Support::Totals.line(line_total),
177
+ status: status
178
+ )
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,7 @@
1
+ module Portage
2
+ module Ucp
3
+ module Magento
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ require "portage/ucp"
2
+ require_relative "magento/version"
3
+ require_relative "magento/errors"
4
+ require_relative "magento/client"
5
+ require_relative "magento/access_token_fetcher"
6
+ require_relative "magento/mapper"
7
+ require_relative "magento/adapter"
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-ucp-magento
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tom Whitbread
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: portage-ucp
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.88'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.88'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.24'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.24'
69
+ - !ruby/object:Gem::Dependency
70
+ name: yard
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
83
+ description: Implements Portage::Ucp::Adapter against a Magento/Adobe Commerce site's
84
+ REST v1 API (admin-token catalog/order, anonymous guest-cart cart/checkout). Generic
85
+ only — no merchant-specific business logic. Plain Net::HTTP, no Magento PHP SDK
86
+ dependency.
87
+ email:
88
+ executables:
89
+ - portage-ucp-magento
90
+ extensions: []
91
+ extra_rdoc_files: []
92
+ files:
93
+ - CHANGELOG.md
94
+ - LICENSE
95
+ - README.md
96
+ - exe/portage-ucp-magento
97
+ - lib/portage/ucp/magento.rb
98
+ - lib/portage/ucp/magento/access_token_fetcher.rb
99
+ - lib/portage/ucp/magento/adapter.rb
100
+ - lib/portage/ucp/magento/client.rb
101
+ - lib/portage/ucp/magento/errors.rb
102
+ - lib/portage/ucp/magento/mapper.rb
103
+ - lib/portage/ucp/magento/version.rb
104
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-magento
105
+ licenses:
106
+ - MIT
107
+ metadata:
108
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-magento
109
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-magento/CHANGELOG.md
110
+ rubygems_mfa_required: 'true'
111
+ post_install_message:
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '3.2'
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 3.5.22
127
+ signing_key:
128
+ specification_version: 4
129
+ summary: Magento/Adobe Commerce adapter for portage-ucp — standard catalog/cart/checkout/order
130
+ over MCP and UCP
131
+ test_files: []