portage-ucp-woocommerce 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: c4a4539d949226ab96ca758ccbea5d8ad40c789a7bd25d5512b9af0fcf8c0240
4
+ data.tar.gz: '0289fb79597b556193107a4d32efb3b10f2ae1800df73910e2d62793799fd6d8'
5
+ SHA512:
6
+ metadata.gz: 9b442c458f7157abb0140eabb5700d87fdb7f55d7c34d740b4057d5530873bc6200342a5017aea73d402d04c86f7111858d369de7578907ed622164c4f4e36e3
7
+ data.tar.gz: 5790802bc3de14b69490c41eb50902d2ba36889256cb7077c990da6b296aff6c103c1f10a661e63dfb5a0372805a2f09a81bc51ba9673ec5c91dfd1ec1ba879f
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. WooCommerce adapter against the Admin REST API v3
10
+ (catalog, order) and Store API v1 (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,129 @@
1
+ # portage-ucp-woocommerce
2
+
3
+ WooCommerce adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against a WooCommerce site's Admin REST API v3 (catalog, order) and Store API v1 (cart, checkout). Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no `woocommerce-api` runtime dependency.
4
+
5
+ ## What it covers
6
+
7
+ | UCP capability | Backing WooCommerce API | Notes |
8
+ |---|---|---|
9
+ | `dev.ucp.shopping.catalog` | Admin REST v3 | `search_catalog`, `get_product` |
10
+ | `dev.ucp.shopping.cart` | Store API (Cart) | `get_cart`, `create_cart`, `update_cart`, `cancel_cart` |
11
+ | `dev.ucp.shopping.checkout` | Store API (same Cart, plus `/checkout`) | `create_checkout`, `get_checkout`, `update_checkout`, `complete_checkout`, `cancel_checkout` |
12
+ | `dev.ucp.shopping.order` | Admin REST v3 | `get_order` |
13
+ | `dev.ucp.shopping.identity` | — | not implemented; WordPress/WooCommerce user auth is a separate concern from the Admin keys + anonymous Store API session used here |
14
+
15
+ Like Shopify, WooCommerce has no separate "Checkout" object — the Store API's Cart **is** the checkout, identified by an opaque `Cart-Token` session header rather than a resource id. The adapter tracks checkout status itself, keyed by that token, and records `Order#checkout_id` itself at completion time (WooCommerce orders don't link back to their originating cart natively).
16
+
17
+ Update/replace operations (`update_cart`, `update_checkout`) are full-replacement: the Store API has no atomic "replace all lines" mutation 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
+ Unlike Shopify/Wix, there's no `AccessTokenFetcher` — WooCommerce Admin keys are static, generated once in wp-admin (WooCommerce → Settings → Advanced → REST API), with nothing to exchange or expire.
20
+
21
+ ## ⚠️ Unverified against a live site
22
+
23
+ Built from WooCommerce's documented REST/Store API shapes, not run against a real site yet. Before relying on this in production, confirm:
24
+
25
+ - **`#complete_checkout`** — posts `payment_method` + `payment_data` to the Store API's `/checkout` endpoint. `payment_method` must match an installed, enabled WC gateway id, and the `payment_data` key a gateway expects (`payment_data_key:`, default `"token"`) is gateway-specific — Stripe's block-based gateway and PayPal's don't necessarily read the same key. Needs confirming against a live site with a real gateway installed.
26
+ - **Variable product variants** — `Mapper.variant`'s title is built by joining `attributes[].option`; not verified against a real variable product's actual attribute shape.
27
+ - **Order fulfillment** — WooCommerce core has no per-line-item fulfillment tracking (that's a shipment-tracking-plugin concern), so every order line is given the same coarse status derived from the order's own top-level `status`, not a real per-line signal.
28
+ - **Store API checkout response shape** — assumed to carry the same `items`/`totals` shape as the Cart response, plus `order_id`. Not confirmed live.
29
+
30
+ ## Installation
31
+
32
+ ```ruby
33
+ # Gemfile
34
+ gem "portage-ucp-woocommerce"
35
+ ```
36
+
37
+ ```bash
38
+ bundle install
39
+ ```
40
+
41
+ ## Setup
42
+
43
+ You need a site URL, an Admin REST API consumer key/secret pair (wp-admin → WooCommerce → Settings → Advanced → REST API — grant Read/Write), your store's currency (the Admin product resource doesn't return one), and — only if you'll call `complete_checkout` — the WC payment gateway id you want to submit orders through.
44
+
45
+ ```ruby
46
+ require "portage/ucp/woocommerce"
47
+
48
+ client = Portage::Ucp::WooCommerce::Client.new(
49
+ site_url: "https://your-shop.example.com",
50
+ consumer_key: ENV.fetch("WOOCOMMERCE_CONSUMER_KEY"),
51
+ consumer_secret: ENV.fetch("WOOCOMMERCE_CONSUMER_SECRET")
52
+ )
53
+
54
+ adapter = Portage::Ucp::WooCommerce::Adapter.new(
55
+ client: client,
56
+ site_url: "https://your-shop.example.com",
57
+ currency: "USD",
58
+ payment_method: "stripe_cc" # only required for #complete_checkout
59
+ )
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ```ruby
65
+ # Catalog
66
+ products = adapter.search_catalog(query: "hoodie", limit: 10)
67
+ product = adapter.get_product(product_id: products.first.id)
68
+
69
+ # Cart — cart_id is only known after the first call, since the Store API
70
+ # assigns it (as a Cart-Token) rather than taking one from the caller
71
+ cart = adapter.create_cart(
72
+ line_items: [{ product_id: product.variants.first[:id], quantity: 2 }],
73
+ idempotency_key: SecureRandom.uuid
74
+ )
75
+ cart = adapter.update_cart(cart_id: cart.id, line_items: [], idempotency_key: SecureRandom.uuid) # empties cart
76
+
77
+ # Checkout
78
+ checkout = adapter.create_checkout(
79
+ line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
80
+ idempotency_key: SecureRandom.uuid
81
+ )
82
+ checkout = adapter.complete_checkout(
83
+ checkout_id: checkout.id,
84
+ payment_token: single_use_token_from_payment_handler,
85
+ idempotency_key: SecureRandom.uuid
86
+ )
87
+
88
+ # Order
89
+ order = adapter.get_order(order_id: checkout_order_id) # only once linked post-completion
90
+ ```
91
+
92
+ ## Wiring into portage-ucp
93
+
94
+ Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
95
+
96
+ ```ruby
97
+ dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
98
+
99
+ dispatcher.call(
100
+ capability: "dev.ucp.shopping.cart",
101
+ action: "create",
102
+ arguments: { line_items: [{ product_id: variant_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
103
+ )
104
+ ```
105
+
106
+ 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.
107
+
108
+ ## Errors
109
+
110
+ ```ruby
111
+ Portage::Ucp::WooCommerce::Error # base class
112
+ Portage::Ucp::WooCommerce::ApiError # any non-2xx response from either the Admin or Store API
113
+ ```
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ bundle exec rspec # tests (WebMock-stubbed, no live site needed)
119
+ bundle exec rubocop # lint
120
+
121
+ # verify a real site's Admin key/secret by listing one product
122
+ WOOCOMMERCE_SITE_URL=https://your-shop.example.com \
123
+ WOOCOMMERCE_CONSUMER_KEY=... WOOCOMMERCE_CONSUMER_SECRET=... \
124
+ bundle exec rake woocommerce_smoke_test
125
+ ```
126
+
127
+ ## License
128
+
129
+ [MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
@@ -0,0 +1,31 @@
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
+ # WOOCOMMERCE_PAYMENT_METHOD is optional — only needed to call
12
+ # #complete_checkout (must match an installed, enabled WC gateway id).
13
+
14
+ require "portage/ucp"
15
+ require "portage/ucp/woocommerce"
16
+
17
+ require ENV["PORTAGE_UCP_CONFIG"] if ENV["PORTAGE_UCP_CONFIG"]
18
+
19
+ client = Portage::Ucp::WooCommerce::Client.new(
20
+ site_url: ENV.fetch("WOOCOMMERCE_SITE_URL"),
21
+ consumer_key: ENV.fetch("WOOCOMMERCE_CONSUMER_KEY"),
22
+ consumer_secret: ENV.fetch("WOOCOMMERCE_CONSUMER_SECRET")
23
+ )
24
+ adapter = Portage::Ucp::WooCommerce::Adapter.new(
25
+ client: client,
26
+ site_url: ENV.fetch("WOOCOMMERCE_SITE_URL"),
27
+ currency: ENV.fetch("WOOCOMMERCE_CURRENCY", "USD"),
28
+ payment_method: ENV.fetch("WOOCOMMERCE_PAYMENT_METHOD", nil)
29
+ )
30
+
31
+ Portage::Ucp::Mcp::Server.build(adapter: adapter).start
@@ -0,0 +1,193 @@
1
+ require "uri"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module WooCommerce
6
+ # Generic Portage::Ucp::Adapter over a WooCommerce site's Admin REST
7
+ # API v3 (catalog, order) and Store API v1 (cart, checkout) — no
8
+ # merchant-specific business logic, same posture as
9
+ # Portage::Ucp::Shopify::Adapter.
10
+ #
11
+ # Deliberately doesn't override `link_identity`: WooCommerce/WordPress
12
+ # user auth is a separate concern from the store-owner Admin keys and
13
+ # anonymous Store API session this gem uses, so this generic adapter
14
+ # leaves it unimplemented — Capability#advertised_for? simply won't
15
+ # advertise dev.ucp.shopping.identity for this adapter, not a 500.
16
+ #
17
+ # Catalog and Order are read through the Admin API (the Store API has
18
+ # no product search and can't look up an arbitrary past order without
19
+ # a customer session). Cart and Checkout are read/written through the
20
+ # Store API — Admin's REST v3 has no "Cart" resource at all, only
21
+ # Orders, and an Order only exists once checkout completes.
22
+ #
23
+ # IMPORTANT CAVEAT: #complete_checkout calls the Store API's
24
+ # `/checkout` endpoint with `payment_method` (configured at
25
+ # initialization, since it must match an installed, enabled WC
26
+ # payment gateway id) and `payment_data` built from `payment_token`.
27
+ # The exact `payment_data` key(s) a given gateway expects are gateway-
28
+ # specific (e.g. Stripe's block-based gateway reads a different key
29
+ # than PayPal's) and this hasn't been confirmed against a live site
30
+ # with a real gateway installed — same "needs confirming against a
31
+ # live store" posture as Portage::Ucp::Shopify::Adapter's own payment
32
+ # step.
33
+ class Adapter < Portage::Ucp::Adapter
34
+ # The Store API takes no idempotency key natively, so §9a dedup comes
35
+ # from Support::Idempotency's in-process table.
36
+ include Portage::Ucp::Support::Idempotency
37
+ # Store API cart and checkout share one underlying session (keyed by
38
+ # Cart-Token) with no status of its own, and WC orders don't link
39
+ # back to the Cart-Token that produced them (see Mapper.order) —
40
+ # both are tracked adapter-side via Support::CheckoutState.
41
+ include Portage::Ucp::Support::CheckoutState
42
+ # The Admin API answers a missing product or order with a 404 rather
43
+ # than an empty body, which UCP's reads report as nil.
44
+ include Portage::Ucp::Support::NotFound
45
+
46
+ def initialize(client:, site_url:, currency:, payment_method: nil, payment_data_key: "token")
47
+ super()
48
+ @client = client
49
+ @site_url = site_url.chomp("/")
50
+ @currency = currency
51
+ @payment_method = payment_method
52
+ @payment_data_key = payment_data_key
53
+ end
54
+
55
+ def search_catalog(query:, limit:)
56
+ data = @client.admin_get("/products?search=#{URI.encode_www_form_component(query)}&per_page=#{limit}")
57
+ data.map { |node| Mapper.product(node, currency: @currency) }
58
+ end
59
+
60
+ def get_product(product_id:)
61
+ nil_on_not_found do
62
+ node = @client.admin_get("/products/#{product_id}")
63
+ node["id"] ? Mapper.product(with_variations(node), currency: @currency) : nil
64
+ end
65
+ end
66
+
67
+ def get_cart(cart_id:)
68
+ Mapper.cart(fetch_cart_node, id: cart_id)
69
+ end
70
+
71
+ def create_cart(line_items:, idempotency_key:)
72
+ dedup(idempotency_key) { Mapper.cart(replace_cart_lines(line_items), id: @client.cart_token) }
73
+ end
74
+
75
+ # Full replacement, same rationale as Portage::Ucp::Shopify::Adapter
76
+ # #update_cart: the Store API has no atomic "replace all lines"
77
+ # either, so this removes every current line then adds the desired
78
+ # ones back.
79
+ def update_cart(cart_id:, line_items:, idempotency_key:)
80
+ dedup(idempotency_key) { Mapper.cart(replace_cart_lines(line_items), id: cart_id) }
81
+ end
82
+
83
+ # The Store API's cart is tied to a session, not a deletable
84
+ # resource with its own lifecycle — there's no cancellation call,
85
+ # so this clears every line and returns the now-empty cart, the
86
+ # closest real equivalent.
87
+ def cancel_cart(cart_id:, idempotency_key:)
88
+ dedup(idempotency_key) { Mapper.cart(replace_cart_lines([]), id: cart_id) }
89
+ end
90
+
91
+ def create_checkout(line_items:, idempotency_key:)
92
+ dedup(idempotency_key) do
93
+ node = replace_cart_lines(line_items)
94
+ record_checkout_status(@client.cart_token, "incomplete")
95
+ Mapper.checkout(node, id: @client.cart_token, status: "incomplete")
96
+ end
97
+ end
98
+
99
+ def get_checkout(checkout_id:)
100
+ Mapper.checkout(fetch_cart_node, id: checkout_id, status: checkout_status(checkout_id))
101
+ end
102
+
103
+ # Full replacement, same rationale as #update_cart.
104
+ def update_checkout(checkout_id:, line_items:, idempotency_key:)
105
+ dedup(idempotency_key) do
106
+ node = replace_cart_lines(line_items)
107
+ record_checkout_status(checkout_id, "incomplete")
108
+ Mapper.checkout(node, id: checkout_id, status: "incomplete")
109
+ end
110
+ end
111
+
112
+ def complete_checkout(checkout_id:, payment_token:, idempotency_key:)
113
+ dedup(idempotency_key) { submit_checkout(checkout_id, payment_token) }
114
+ end
115
+
116
+ # The Store API has no cancellation endpoint for an in-progress
117
+ # checkout either (see #cancel_cart) — this just marks the tracked
118
+ # status canceled without touching the underlying cart contents.
119
+ def cancel_checkout(checkout_id:, idempotency_key:)
120
+ dedup(idempotency_key) do
121
+ record_checkout_status(checkout_id, "canceled")
122
+ Mapper.checkout(fetch_cart_node, id: checkout_id, status: "canceled")
123
+ end
124
+ end
125
+
126
+ def get_order(order_id:)
127
+ nil_on_not_found do
128
+ node = @client.admin_get("/orders/#{order_id}")
129
+ next nil unless node["id"]
130
+
131
+ Mapper.order(node, site_url: @site_url, checkout_id: checkout_id_for(order_id))
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ def fetch_cart_node
138
+ @client.store_get("/cart")
139
+ end
140
+
141
+ # A variable product's Admin resource only lists variation *ids*
142
+ # (`variations: [123, 124]`) — fetching the full variation objects
143
+ # (price, stock, attribute choices) is a second call, only made for
144
+ # #get_product's single-product path, not #search_catalog's list
145
+ # (an N+1 fetch per search result would be too expensive).
146
+ def with_variations(node)
147
+ return node unless node["type"] == "variable" && node["variations"]&.any?
148
+
149
+ node.merge("variations_detail" => @client.admin_get("/products/#{node['id']}/variations?per_page=100"))
150
+ end
151
+
152
+ def cart_lines(line_items)
153
+ line_items.map { |li| { id: li[:product_id].to_i, quantity: li[:quantity] } }
154
+ end
155
+
156
+ def replace_cart_lines(line_items)
157
+ fetch_cart_node["items"].each { |item| @client.store_post("/cart/remove-item", { key: item["key"] }) }
158
+ cart_lines(line_items).each { |line| @client.store_post("/cart/add-item", line) }
159
+ fetch_cart_node
160
+ end
161
+
162
+ # See the class-level CAVEAT: `payment_data` uses a single
163
+ # configurable key (`payment_data_key:`, default "token") rather
164
+ # than a confirmed gateway-specific shape.
165
+ def submit_checkout(checkout_id, payment_token)
166
+ raise Portage::Ucp::WooCommerce::Error, "no payment_method configured on this Adapter" unless @payment_method
167
+
168
+ data = @client.store_post("/checkout", {
169
+ payment_method: @payment_method,
170
+ payment_data: [{ key: @payment_data_key, value: payment_token }]
171
+ })
172
+ record_checkout_status(checkout_id, "completed")
173
+ order = build_order_confirmation(data, checkout_id)
174
+ Mapper.checkout(data, id: checkout_id, status: "completed", order: order)
175
+ end
176
+
177
+ # Same order-received URL pattern as Mapper.order's `permalink_url` —
178
+ # the Store API's checkout response hands back order_id/order_key
179
+ # directly, so there's no need for a second Admin fetch just to build
180
+ # this link.
181
+ def build_order_confirmation(data, checkout_id)
182
+ return unless data["order_id"]
183
+
184
+ record_order_checkout(data["order_id"], checkout_id)
185
+ Portage::Ucp::OrderConfirmation.new(
186
+ id: data["order_id"].to_s,
187
+ permalink_url: "#{@site_url}/checkout/order-received/#{data['order_id']}/?key=#{data['order_key']}"
188
+ )
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,92 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module WooCommerce
7
+ # Minimal REST client over a WooCommerce site's two distinct APIs.
8
+ #
9
+ # Deliberately plain Net::HTTP, not the `woocommerce-api` gem — a
10
+ # generic adapter that any Ruby app can drop in only needs a site URL
11
+ # and a pair of keys, no framework coupling, trivially stubbable with
12
+ # WebMock.
13
+ #
14
+ # Like Shopify's Admin/Storefront split, WooCommerce has two separate
15
+ # APIs with two separate auth models:
16
+ #
17
+ # - Admin REST API (`/wp-json/wc/v3`): store-owner Basic Auth via a
18
+ # static consumer_key/consumer_secret pair generated once in
19
+ # wp-admin. No OAuth exchange, no expiry — unlike Shopify/Wix, there
20
+ # is no Portage::Ucp::WooCommerce::AccessTokenFetcher because there's
21
+ # nothing to exchange.
22
+ # - Store API (`/wp-json/wc/store/v1`): the public, unauthenticated
23
+ # cart/checkout API WooCommerce Blocks itself uses. It's session-
24
+ # based rather than token-based: the *first* request gets back a
25
+ # `Cart-Token` response header identifying an anonymous cart, which
26
+ # must be resent as a `Cart-Token` request header on every
27
+ # subsequent call to stay on the same cart. State-changing calls
28
+ # (POST/PUT/DELETE) also require a `Nonce` header, sourced the same
29
+ # way from an earlier response. This client tracks both headers
30
+ # in-instance so callers don't have to.
31
+ class Client
32
+ include Portage::Ucp::Support::HttpClient
33
+
34
+ def initialize(site_url:, consumer_key:, consumer_secret:)
35
+ @site_url = site_url.chomp("/")
36
+ @consumer_key = consumer_key
37
+ @consumer_secret = consumer_secret
38
+ @cart_token = nil
39
+ @nonce = nil
40
+ end
41
+
42
+ attr_reader :cart_token
43
+
44
+ def admin_get(path)
45
+ admin_request(Net::HTTP::Get, path)
46
+ end
47
+
48
+ def admin_post(path, body = {})
49
+ admin_request(Net::HTTP::Post, path, body)
50
+ end
51
+
52
+ def store_get(path)
53
+ store_request(Net::HTTP::Get, path)
54
+ end
55
+
56
+ def store_post(path, body = {})
57
+ store_request(Net::HTTP::Post, path, body)
58
+ end
59
+
60
+ def store_delete(path)
61
+ store_request(Net::HTTP::Delete, path)
62
+ end
63
+
64
+ private
65
+
66
+ def admin_request(http_method, path, body = nil)
67
+ json_request(http_method, "#{@site_url}/wp-json/wc/v3#{path}",
68
+ body: body, basic_auth: [@consumer_key, @consumer_secret])
69
+ end
70
+
71
+ # Threads `Cart-Token` on every call (once one's been seen) and
72
+ # `Nonce` on writes — see the class-level comment. Both are
73
+ # refreshed from whatever the response hands back, since WooCommerce
74
+ # can rotate the nonce between requests, which is why this reads the
75
+ # raw response before parsing it.
76
+ def store_request(http_method, path, body = nil)
77
+ headers = {}
78
+ headers["Cart-Token"] = @cart_token if @cart_token
79
+ headers["Nonce"] = @nonce if @nonce && http_method != Net::HTTP::Get
80
+ response = json_request(http_method, "#{@site_url}/wp-json/wc/store/v1#{path}",
81
+ body: body, headers: headers, raw: true)
82
+
83
+ @cart_token = response["Cart-Token"] if response["Cart-Token"]
84
+ @nonce = response["Nonce"] if response["Nonce"]
85
+ parse!(response)
86
+ end
87
+
88
+ def api_error_class = Portage::Ucp::WooCommerce::ApiError
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,22 @@
1
+ module Portage
2
+ module Ucp
3
+ module WooCommerce
4
+ class Error < StandardError; end
5
+
6
+ # Raised for any non-2xx response from either the Admin REST API (v3,
7
+ # Basic Auth) or the Store API (v1, cart-token session) — both surface
8
+ # errors the same way: a non-2xx status with a JSON `{code, message}`
9
+ # body (`rest_*` codes on the Admin side, `woocommerce_rest_*`/
10
+ # `woocommerce_store_api_*` on the Store side).
11
+ class ApiError < Error
12
+ include Portage::Ucp::Support::ApiError
13
+
14
+ private
15
+
16
+ def detail(body)
17
+ body["message"] || body
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,170 @@
1
+ module Portage
2
+ module Ucp
3
+ module WooCommerce
4
+ # Converts WooCommerce Admin REST v3 (products, orders) and Store API
5
+ # v1 (cart, checkout) response bodies into the protocol-layer value
6
+ # objects from Portage::Ucp::ValueObjects — nothing WooCommerce-shaped
7
+ # is allowed to leak past this file.
8
+ #
9
+ # `currency` is threaded in by the caller everywhere it's needed: the
10
+ # Admin product resource doesn't carry a currency field at all (it's a
11
+ # site-wide setting, not per-product), unlike the Store API's cart/
12
+ # checkout responses, which do include one (`totals.currency_code`).
13
+ module Mapper
14
+ module_function
15
+
16
+ def money(amount, currency)
17
+ Portage::Ucp::Support::Amounts.money(amount, currency)
18
+ end
19
+
20
+ def minor_units(amount)
21
+ Portage::Ucp::Support::Amounts.decimal_to_minor(amount)
22
+ end
23
+
24
+ # `node["variations_detail"]` is adapter-populated, not a real WC
25
+ # field: the Admin product resource only lists variation *ids* for a
26
+ # variable product (`variations: [123, 124]`), so fetching full
27
+ # variation objects is a second request the Adapter makes and merges
28
+ # in before calling this — Mapper stays pure translation, no I/O.
29
+ def product(node, currency:)
30
+ Portage::Ucp::Product.new(
31
+ id: node["id"].to_s,
32
+ title: node["name"],
33
+ description: node["description"],
34
+ price: money(node["price"], currency),
35
+ available: node["stock_status"] != "outofstock",
36
+ variants: variants(node, currency),
37
+ url: node["permalink"]
38
+ )
39
+ end
40
+
41
+ # A simple (non-variable) product has no real variants — it's its
42
+ # own single implicit variant, same as a single-variant Shopify
43
+ # product using that variant's own id.
44
+ def variants(node, currency)
45
+ detail = node["variations_detail"]
46
+ unless detail
47
+ return [{ id: node["id"].to_s, title: node["name"],
48
+ available: node["stock_status"] != "outofstock", price: money(node["price"], currency) }]
49
+ end
50
+
51
+ detail.map { |v| variant(v, currency) }
52
+ end
53
+
54
+ def variant(node, currency)
55
+ title = (node["attributes"] || []).map { |a| a["option"] }.join(" / ")
56
+ { id: node["id"].to_s, title: title, available: node["stock_status"] != "outofstock",
57
+ price: money(node["price"], currency) }
58
+ end
59
+
60
+ # `id:` is caller-supplied rather than read off the response body:
61
+ # the Store API cart has no in-body resource id, only a `cart_hash`
62
+ # change-detection fingerprint. What actually identifies "this cart"
63
+ # across calls is the opaque `Cart-Token` the session is keyed by
64
+ # (see Portage::Ucp::WooCommerce::Client) — the Adapter passes that
65
+ # token through as `id:`.
66
+ def cart(node, id:)
67
+ currency = node.dig("totals", "currency_code")
68
+ Portage::Ucp::Cart.new(
69
+ id: id,
70
+ line_items: (node["items"] || []).map { |n| cart_line_item(n, currency) },
71
+ currency: currency,
72
+ totals: totals(node["totals"] || {})
73
+ )
74
+ end
75
+
76
+ # `status` isn't a Store API cart field either — the Store API's
77
+ # cart *is* the checkout, same as Shopify's Cart-as-Checkout, so the
78
+ # adapter tracks status itself and passes it in here, same rationale
79
+ # as `id:` above.
80
+ def checkout(node, id:, status:, order: nil)
81
+ currency = node.dig("totals", "currency_code")
82
+ Portage::Ucp::Checkout.new(
83
+ id: id,
84
+ status: status,
85
+ line_items: (node["items"] || []).map { |n| cart_line_item(n, currency) },
86
+ currency: currency,
87
+ totals: totals(node["totals"] || {}),
88
+ links: [],
89
+ order: order
90
+ )
91
+ end
92
+
93
+ def cart_line_item(node, _currency)
94
+ unit_price = minor_units_from_subunits(node.dig("prices", "price"), node.dig("prices", "currency_minor_unit"))
95
+ line_total = unit_price * node["quantity"]
96
+ Portage::Ucp::LineItem.new(
97
+ id: node["key"],
98
+ item: Portage::Ucp::Item.new(id: node["id"].to_s, title: node["name"], price: unit_price,
99
+ image_url: node.dig("images", 0, "thumbnail")),
100
+ quantity: node["quantity"],
101
+ totals: Portage::Ucp::Support::Totals.line(line_total)
102
+ )
103
+ end
104
+
105
+ # Store API money fields are already minor-unit integers as strings
106
+ # (e.g. "500" for $5.00), tagged with `currency_minor_unit` (usually
107
+ # 2) — unlike the Admin API's decimal strings, there's no BigDecimal
108
+ # conversion needed here, just an integer parse.
109
+ def minor_units_from_subunits(amount, _minor_unit)
110
+ Portage::Ucp::Support::Amounts.subunits_to_minor(amount)
111
+ end
112
+
113
+ def totals(node)
114
+ Portage::Ucp::Support::Totals.summary(
115
+ subtotal: minor_units_from_subunits(node["total_items"], nil),
116
+ tax: minor_units_from_subunits(node["total_tax"], nil),
117
+ total: minor_units_from_subunits(node["total_price"], nil)
118
+ )
119
+ end
120
+
121
+ # WooCommerce core has no per-line-item fulfillment tracking (that's
122
+ # a shipment-tracking-plugin concern) — every line is given the same
123
+ # coarse status derived from the order's own top-level `status`,
124
+ # rather than a real per-line signal.
125
+ ORDER_LINE_ITEM_STATUS = {
126
+ "completed" => "fulfilled", "processing" => "processing", "pending" => "processing",
127
+ "on-hold" => "processing", "cancelled" => "removed", "refunded" => "removed", "failed" => "removed"
128
+ }.freeze
129
+
130
+ # `permalink_url` is built from WooCommerce's documented order-
131
+ # received URL pattern (`/checkout/order-received/{id}/?key={key}`)
132
+ # — the Admin order resource doesn't return a ready-made link the
133
+ # way Shopify's `statusPageUrl` does. `checkout_id` isn't a WC order
134
+ # field at all — nothing on Order links back to the Cart-Token that
135
+ # produced it, so the Adapter resolves it itself (tracked in-process
136
+ # from the #complete_checkout call that created the order, since the
137
+ # Store API's checkout response hands back the new order id right
138
+ # then) and passes it in here, same as Shopify's Mapper.order.
139
+ def order(node, site_url:, checkout_id: "")
140
+ currency = node["currency"]
141
+ subtotal = minor_units(node["total"]) - minor_units(node["total_tax"])
142
+ status = Portage::Ucp::Support::LineItemStatus.from_table(ORDER_LINE_ITEM_STATUS, node["status"])
143
+ Portage::Ucp::Order.new(
144
+ id: node["id"].to_s,
145
+ checkout_id: checkout_id,
146
+ permalink_url: "#{site_url}/checkout/order-received/#{node['id']}/?key=#{node['order_key']}",
147
+ line_items: (node["line_items"] || []).map { |n| order_line_item(n, currency, status) },
148
+ fulfillment: Portage::Ucp::Fulfillment.new,
149
+ currency: currency,
150
+ totals: Portage::Ucp::Support::Totals.summary(subtotal: subtotal, total: minor_units(node["total"]))
151
+ )
152
+ end
153
+
154
+ def order_line_item(node, currency, status)
155
+ quantity = node["quantity"]
156
+ fulfilled = Portage::Ucp::Support::LineItemStatus.fulfilled_quantity(status, quantity)
157
+ line_total = minor_units(node["total"])
158
+ Portage::Ucp::OrderLineItem.new(
159
+ id: node["id"].to_s,
160
+ item: Portage::Ucp::Item.new(id: (node["variation_id"] || node["product_id"]).to_s, title: node["name"],
161
+ price: money(node["price"], currency).amount_minor),
162
+ quantity: { original: quantity, total: quantity, fulfilled: fulfilled },
163
+ totals: Portage::Ucp::Support::Totals.line(line_total),
164
+ status: status
165
+ )
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,7 @@
1
+ module Portage
2
+ module Ucp
3
+ module WooCommerce
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ require "portage/ucp"
2
+ require_relative "woocommerce/version"
3
+ require_relative "woocommerce/errors"
4
+ require_relative "woocommerce/client"
5
+ require_relative "woocommerce/mapper"
6
+ require_relative "woocommerce/adapter"
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-ucp-woocommerce
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 WooCommerce site's Admin REST
84
+ API v3 (catalog, order) and Store API v1 (cart, checkout). Generic only — no merchant-specific
85
+ business logic. Plain Net::HTTP, no woocommerce-api runtime dependency.
86
+ email:
87
+ executables:
88
+ - portage-ucp-woocommerce
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - CHANGELOG.md
93
+ - LICENSE
94
+ - README.md
95
+ - exe/portage-ucp-woocommerce
96
+ - lib/portage/ucp/woocommerce.rb
97
+ - lib/portage/ucp/woocommerce/adapter.rb
98
+ - lib/portage/ucp/woocommerce/client.rb
99
+ - lib/portage/ucp/woocommerce/errors.rb
100
+ - lib/portage/ucp/woocommerce/mapper.rb
101
+ - lib/portage/ucp/woocommerce/version.rb
102
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-woocommerce
103
+ licenses:
104
+ - MIT
105
+ metadata:
106
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-woocommerce
107
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-woocommerce/CHANGELOG.md
108
+ rubygems_mfa_required: 'true'
109
+ post_install_message:
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '3.2'
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.5.22
125
+ signing_key:
126
+ specification_version: 4
127
+ summary: WooCommerce adapter for portage-ucp — standard catalog/cart/checkout/order
128
+ over MCP and UCP
129
+ test_files: []