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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +145 -0
- data/exe/portage-ucp-magento +39 -0
- data/lib/portage/ucp/magento/access_token_fetcher.rb +40 -0
- data/lib/portage/ucp/magento/adapter.rb +248 -0
- data/lib/portage/ucp/magento/client.rb +71 -0
- data/lib/portage/ucp/magento/errors.rb +21 -0
- data/lib/portage/ucp/magento/mapper.rb +183 -0
- data/lib/portage/ucp/magento/version.rb +7 -0
- data/lib/portage/ucp/magento.rb +7 -0
- metadata +131 -0
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
|
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: []
|