portage-ucp-wix 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: b3982d49401e0956a0ab63673941a7b943868d1ce104224e78b057b62b5befde
4
+ data.tar.gz: 6a0e9017b011b1f3b1182857865ad926a3e3839c09afd7fdb215eec8d3cbf843
5
+ SHA512:
6
+ metadata.gz: 75d3d83ea3bc81837f7ada0d3513faa85b1a37f878e99af219b291d3a8fc83482379d6116863f54b17ae8f14e0fe049651eb641159bbb73c0c17a1bab2d4398c
7
+ data.tar.gz: '008f09ea6d0e5da325ba2aa847f0604ef662c672ed60dceb4a6d964eba2c655bcd54bc43a31b92177373a03e63493ef03f168a4c173b272afa50b779a078db65'
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. Wix adapter against the Stores (catalog) and eCommerce
10
+ (cart, checkout, order) REST APIs.
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-wix
2
+
3
+ Wix adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against Wix's Stores (catalog) and eCommerce (cart, checkout, order) REST APIs. Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no Wix SDK runtime dependency.
4
+
5
+ ## What it covers
6
+
7
+ | UCP capability | Backing Wix API | Notes |
8
+ |---|---|---|
9
+ | `dev.ucp.shopping.catalog` | Stores Catalog V1 | `search_catalog`, `get_product` |
10
+ | `dev.ucp.shopping.cart` | eCommerce Carts | `get_cart`, `create_cart`, `update_cart`, `cancel_cart` |
11
+ | `dev.ucp.shopping.checkout` | eCommerce Checkouts | `create_checkout`, `get_checkout`, `update_checkout`, `complete_checkout`, `cancel_checkout` |
12
+ | `dev.ucp.shopping.order` | eCommerce Orders | `get_order` |
13
+ | `dev.ucp.shopping.identity` | — | not implemented; Wix Members/visitor OAuth is a separate concern from the site-level app auth used here |
14
+
15
+ Unlike Shopify, Wix models Cart and Checkout as genuinely separate resources, and a Wix Order links back to its originating Checkout natively via `checkoutId` — no cart-token reconciliation search needed for `Order#checkout_id`.
16
+
17
+ Update/replace operations (`update_cart`, `update_checkout`) are full-replacement: Wix's add/remove-line-items endpoints aren't an 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
+ ## ⚠️ Unverified against a live site
20
+
21
+ This adapter is built from Wix's documented REST API shapes but hasn't been run against a real Wix site. Before relying on it in production, confirm:
22
+
23
+ - **Catalog shape** — targets Stores Catalog **V1** (`priceData`, `stock`, `productPageUrl`, variant `choices`), not V3. If your site's app only has V3 access, `Mapper.product`/`.variant` need rewriting against V3's shape.
24
+ - **`#complete_checkout`** — calls Wix's `create-order` endpoint bare. Wix's documented flow expects payment to already be authorized through a connected payment provider (Wix Payments or a PSP) before an order is created; there's no confirmed Wix equivalent of Shopify's `cartPaymentUpdate` that accepts an arbitrary single-use token. `payment_token` is accepted for interface parity with other `Portage::Ucp` adapters but currently isn't sent anywhere — wiring real payment capture needs confirming against a live site with a configured payment provider.
25
+ - **Order fulfillment** — Wix order line items only expose a coarse `fulfillmentStatus` (not/partially/fully fulfilled quantities); `Order#fulfillment` is left as `{}` since real per-shipment tracking would need Wix's separate Fulfillments API, which isn't wired up here.
26
+ - **`Order#permalink_url`** — left blank; Wix's Orders API doesn't return a public order-status page URL.
27
+
28
+ ## Installation
29
+
30
+ ```ruby
31
+ # Gemfile
32
+ gem "portage-ucp-wix"
33
+ ```
34
+
35
+ ```bash
36
+ bundle install
37
+ ```
38
+
39
+ ## Setup
40
+
41
+ You need a single site-scoped access token — unlike Shopify's split Admin/Storefront tokens, Wix's REST surface sits behind one token, already scoped to a site.
42
+
43
+ ```ruby
44
+ require "portage/ucp/wix"
45
+
46
+ client = Portage::Ucp::Wix::Client.new(access_token: ENV.fetch("WIX_ACCESS_TOKEN"))
47
+ adapter = Portage::Ucp::Wix::Adapter.new(client: client)
48
+ ```
49
+
50
+ ### Fetching an access token from an app's client credentials
51
+
52
+ ```ruby
53
+ fetcher = Portage::Ucp::Wix::AccessTokenFetcher.new(
54
+ client_id: ENV.fetch("WIX_CLIENT_ID"),
55
+ client_secret: ENV.fetch("WIX_CLIENT_SECRET"),
56
+ instance_id: ENV.fetch("WIX_INSTANCE_ID")
57
+ )
58
+
59
+ result = fetcher.fetch
60
+ result.access_token # => access_token to pass into Client.new
61
+ result.expires_in # => seconds until it needs refetching
62
+ ```
63
+
64
+ ## Usage
65
+
66
+ ```ruby
67
+ # Catalog
68
+ products = adapter.search_catalog(query: "hoodie", limit: 10)
69
+ product = adapter.get_product(product_id: products.first.id)
70
+
71
+ # Cart
72
+ cart = adapter.create_cart(
73
+ line_items: [{ product_id: product.variants.first[:id], quantity: 2 }],
74
+ idempotency_key: SecureRandom.uuid
75
+ )
76
+ cart = adapter.update_cart(cart_id: cart.id, line_items: [], idempotency_key: SecureRandom.uuid) # empties cart
77
+
78
+ # Checkout
79
+ checkout = adapter.create_checkout(
80
+ line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
81
+ idempotency_key: SecureRandom.uuid
82
+ )
83
+ checkout = adapter.complete_checkout(
84
+ checkout_id: checkout.id,
85
+ payment_token: single_use_token_from_payment_handler,
86
+ idempotency_key: SecureRandom.uuid
87
+ )
88
+
89
+ # Order
90
+ order = adapter.get_order(order_id: checkout.id) # only once linked post-completion
91
+ ```
92
+
93
+ ## Wiring into portage-ucp
94
+
95
+ Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
96
+
97
+ ```ruby
98
+ dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
99
+
100
+ dispatcher.call(
101
+ capability: "dev.ucp.shopping.cart",
102
+ action: "create",
103
+ arguments: { line_items: [{ product_id: variant_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
104
+ )
105
+ ```
106
+
107
+ 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.
108
+
109
+ ## Errors
110
+
111
+ ```ruby
112
+ Portage::Ucp::Wix::Error # base class
113
+ Portage::Ucp::Wix::ApiError # any non-2xx Wix REST response (bad auth, malformed request, business rejection)
114
+ ```
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ bundle exec rspec # tests (WebMock-stubbed, no live site needed)
120
+ bundle exec rubocop # lint
121
+
122
+ # fetch a real access_token for a dev site, via client_credentials
123
+ WIX_CLIENT_ID=... WIX_CLIENT_SECRET=... WIX_INSTANCE_ID=... \
124
+ bundle exec rake wix_access_token
125
+ ```
126
+
127
+ ## License
128
+
129
+ [MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
@@ -0,0 +1,24 @@
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 WIX_ACCESS_TOKEN is read here, not client_id/client_secret/
12
+ # instance_id: this exe expects a site-scoped token you've already fetched
13
+ # (e.g. via `bundle exec rake wix_access_token`) and refresh out-of-band,
14
+ # same as any other long-lived process holding a token with an expiry.
15
+
16
+ require "portage/ucp"
17
+ require "portage/ucp/wix"
18
+
19
+ require ENV["PORTAGE_UCP_CONFIG"] if ENV["PORTAGE_UCP_CONFIG"]
20
+
21
+ client = Portage::Ucp::Wix::Client.new(access_token: ENV.fetch("WIX_ACCESS_TOKEN"))
22
+ adapter = Portage::Ucp::Wix::Adapter.new(client: client)
23
+
24
+ Portage::Ucp::Mcp::Server.build(adapter: adapter).start
@@ -0,0 +1,42 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Wix
7
+ # Exchanges a Wix app's client_id/client_secret (plus the target site's
8
+ # instance_id) for a site-scoped access token via Wix's OAuth
9
+ # client_credentials grant.
10
+ #
11
+ # Wix apps authenticate server-to-server the same way Shopify custom
12
+ # apps now do: no static, copy-once token — client_id/client_secret get
13
+ # exchanged (and re-exchanged once expired) for the real access_token
14
+ # used by Portage::Ucp::Wix::Client. The one addition versus Shopify is
15
+ # `instance_id`: Wix's OAuth endpoint is one shared host
16
+ # (www.wixapis.com) serving every site, so the token has to be scoped
17
+ # to a specific site's app instance at exchange time rather than via a
18
+ # per-shop subdomain.
19
+ class AccessTokenFetcher
20
+ include Portage::Ucp::Support::TokenExchange
21
+
22
+ Result = Struct.new(:access_token, :expires_in, keyword_init: true)
23
+
24
+ ENDPOINT = "https://www.wixapis.com/oauth2/token".freeze
25
+
26
+ def initialize(client_id:, client_secret:, instance_id:)
27
+ @client_id = client_id
28
+ @client_secret = client_secret
29
+ @instance_id = instance_id
30
+ end
31
+
32
+ def fetch
33
+ body = exchange(ENDPOINT,
34
+ { grant_type: "client_credentials", client_id: @client_id,
35
+ client_secret: @client_secret, instance_id: @instance_id },
36
+ error_class: Portage::Ucp::Wix::Error)
37
+ Result.new(access_token: body["access_token"], expires_in: body["expires_in"])
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,194 @@
1
+ require "json"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Wix
6
+ # Generic Portage::Ucp::Adapter over Wix's Stores (catalog) and
7
+ # eCommerce (cart/checkout/order) REST APIs — no merchant-specific
8
+ # business logic, same posture as Portage::Ucp::Shopify::Adapter.
9
+ #
10
+ # Deliberately doesn't override `link_identity`: Wix's OAuth identity
11
+ # story (Wix Members/visitor auth) is a separate concern from the
12
+ # site-level app auth this gem uses for catalog/cart/checkout/order, so
13
+ # this generic adapter leaves it unimplemented — Capability#
14
+ # advertised_for? simply won't advertise dev.ucp.shopping.identity for
15
+ # this adapter, not a 500.
16
+ #
17
+ # Unlike Shopify, where Cart and Checkout are the same underlying
18
+ # object, Wix models them as genuinely separate resources with their
19
+ # own REST endpoints — so, unlike Shopify's Adapter, there's no shared
20
+ # create_cart_node/create_checkout_node plumbing between the two.
21
+ #
22
+ # IMPORTANT CAVEAT: #complete_checkout's call to Wix's "Create Order"
23
+ # endpoint hasn't been confirmed against a live site. Wix's documented
24
+ # checkout flow expects payment to already be authorized through a
25
+ # payment provider (Wix Payments or a connected PSP) before an order
26
+ # is created — there's no confirmed equivalent of Shopify's
27
+ # cartPaymentUpdate that accepts an arbitrary single-use payment_token
28
+ # directly. `payment_token` is accepted for interface parity with the
29
+ # other Portage::Ucp adapters but isn't currently sent anywhere; wiring
30
+ # real payment capture needs confirming against a live Wix site with a
31
+ # configured payment provider before this method is production-ready.
32
+ class Adapter < Portage::Ucp::Adapter
33
+ # Wix's fixed app id for its own Stores catalog, required on every
34
+ # cart/checkout line item's catalogReference so Wix knows which
35
+ # catalog `catalogItemId` belongs to.
36
+ STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e".freeze
37
+
38
+ # Wix's cart/checkout REST endpoints don't take an idempotency key
39
+ # natively, so §9a dedup comes from Support::Idempotency's in-process
40
+ # table.
41
+ include Portage::Ucp::Support::Idempotency
42
+ # Wix Checkout has no confirmed native status field, so status is
43
+ # tracked adapter-side via Support::CheckoutState. Only the status
44
+ # half is used here: unlike Shopify and WooCommerce, a Wix Order
45
+ # carries its originating `checkoutId` natively (see #get_order).
46
+ include Portage::Ucp::Support::CheckoutState
47
+
48
+ def initialize(client:)
49
+ super()
50
+ @client = client
51
+ end
52
+
53
+ def search_catalog(query:, limit:)
54
+ body = { query: { filter: JSON.generate({ name: { "$contains" => query } }), paging: { limit: limit } } }
55
+ data = @client.post("/stores/v1/products/query", body)
56
+ (data["products"] || []).map { |node| Mapper.product(node) }
57
+ end
58
+
59
+ def get_product(product_id:)
60
+ node = @client.get("/stores/v1/products/#{product_id}")["product"]
61
+ node && Mapper.product(node)
62
+ end
63
+
64
+ def get_cart(cart_id:)
65
+ node = fetch_cart_node(cart_id)
66
+ node && Mapper.cart(node)
67
+ end
68
+
69
+ def create_cart(line_items:, idempotency_key:)
70
+ dedup(idempotency_key) { Mapper.cart(create_cart_node(line_items)) }
71
+ end
72
+
73
+ # Full replacement, matching UCP's real cart semantics: this removes
74
+ # every current line then adds the desired ones back, same as
75
+ # Portage::Ucp::Shopify::Adapter#update_cart (Wix's add/remove-line-
76
+ # items endpoints aren't an atomic "replace all lines" either).
77
+ def update_cart(cart_id:, line_items:, idempotency_key:)
78
+ dedup(idempotency_key) { Mapper.cart(replace_cart_lines(cart_id, line_items)) }
79
+ end
80
+
81
+ # Wix carts can actually be deleted (unlike Shopify's, which just
82
+ # expire) — this deletes the cart, then returns its last-known state
83
+ # since there's nothing more to hand back once it's gone.
84
+ def cancel_cart(cart_id:, idempotency_key:)
85
+ dedup(idempotency_key) do
86
+ node = fetch_cart_node(cart_id)
87
+ @client.delete("/ecom/v1/carts/#{cart_id}")
88
+ Mapper.cart(node)
89
+ end
90
+ end
91
+
92
+ def create_checkout(line_items:, idempotency_key:)
93
+ dedup(idempotency_key) do
94
+ node = create_checkout_node(line_items)
95
+ record_checkout_status(node["id"], "incomplete")
96
+ Mapper.checkout(node, status: "incomplete")
97
+ end
98
+ end
99
+
100
+ def get_checkout(checkout_id:)
101
+ node = fetch_checkout_node(checkout_id)
102
+ node && Mapper.checkout(node, status: checkout_status(checkout_id))
103
+ end
104
+
105
+ # Full replacement, same rationale as #update_cart.
106
+ def update_checkout(checkout_id:, line_items:, idempotency_key:)
107
+ dedup(idempotency_key) do
108
+ node = @client.patch("/ecom/v1/checkouts/#{checkout_id}", { lineItems: cart_lines(line_items) })["checkout"]
109
+ record_checkout_status(checkout_id, "incomplete")
110
+ Mapper.checkout(node, status: "incomplete")
111
+ end
112
+ end
113
+
114
+ # See the class-level CAVEAT: payment_token isn't currently sent to
115
+ # Wix — see this method's implementation note below.
116
+ def complete_checkout(checkout_id:, payment_token:, idempotency_key:)
117
+ dedup(idempotency_key) { submit_order(checkout_id, payment_token) }
118
+ end
119
+
120
+ def cancel_checkout(checkout_id:, idempotency_key:)
121
+ dedup(idempotency_key) do
122
+ record_checkout_status(checkout_id, "canceled")
123
+ node = fetch_checkout_node(checkout_id)
124
+ Mapper.checkout(node, status: "canceled")
125
+ end
126
+ end
127
+
128
+ # `checkout_id` comes straight off the Wix Order's own `checkoutId`
129
+ # field — unlike Shopify, Wix's Order object links back to its
130
+ # originating Checkout natively, so there's no cart_token-style
131
+ # reconciliation search needed here.
132
+ def get_order(order_id:)
133
+ node = @client.get("/ecom/v1/orders/#{order_id}")["order"]
134
+ node && Mapper.order(node)
135
+ end
136
+
137
+ private
138
+
139
+ def fetch_cart_node(cart_id)
140
+ @client.get("/ecom/v1/carts/#{cart_id}")["cart"]
141
+ end
142
+
143
+ def fetch_checkout_node(checkout_id)
144
+ @client.get("/ecom/v1/checkouts/#{checkout_id}")["checkout"]
145
+ end
146
+
147
+ def cart_lines(line_items)
148
+ line_items.map do |li|
149
+ { catalogReference: { catalogItemId: li[:product_id], appId: STORES_APP_ID },
150
+ quantity: li[:quantity] }
151
+ end
152
+ end
153
+
154
+ def create_cart_node(line_items)
155
+ @client.post("/ecom/v1/carts", { lineItems: cart_lines(line_items) })["cart"]
156
+ end
157
+
158
+ def create_checkout_node(line_items)
159
+ @client.post("/ecom/v1/checkouts", { lineItems: cart_lines(line_items) })["checkout"]
160
+ end
161
+
162
+ def replace_cart_lines(cart_id, line_items)
163
+ current_ids = fetch_cart_node(cart_id)["lineItems"].map { |n| n["id"] }
164
+ unless current_ids.empty?
165
+ @client.post("/ecom/v1/carts/#{cart_id}/remove-line-items", { lineItemIds: current_ids })
166
+ end
167
+ return fetch_cart_node(cart_id) if line_items.empty?
168
+
169
+ @client.post("/ecom/v1/carts/#{cart_id}/add-line-items", { lineItems: cart_lines(line_items) })["cart"]
170
+ end
171
+
172
+ # `payment_token` is deliberately not sent anywhere yet — see the
173
+ # class-level CAVEAT. `create-order` is called bare; a checkout that
174
+ # hasn't actually been paid through a real Wix payment provider will
175
+ # be rejected by Wix itself here, surfacing as an ApiError rather
176
+ # than silently succeeding.
177
+ def submit_order(checkout_id, _payment_token)
178
+ node = fetch_checkout_node(checkout_id)
179
+ raise Portage::Ucp::Wix::Error, "checkout #{checkout_id} not found" unless node
180
+
181
+ data = @client.post("/ecom/v1/checkouts/#{checkout_id}/create-order", {})
182
+ order_id = data["orderId"]
183
+ status = order_id ? "completed" : "complete_in_progress"
184
+ record_checkout_status(checkout_id, status)
185
+ # Same "not information-complete but schema-valid" posture as
186
+ # Mapper.order's own blank permalink_url — Wix's create-order
187
+ # response has no storefront order-status link to hand back.
188
+ order = order_id && Portage::Ucp::OrderConfirmation.new(id: order_id, permalink_url: "")
189
+ Mapper.checkout(node, status: status, order: order)
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,56 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Wix
7
+ # Minimal REST client over the Wix APIs (www.wixapis.com).
8
+ #
9
+ # Deliberately plain Net::HTTP, not the `wix-ruby-sdk` (there isn't a
10
+ # first-party one) — a generic adapter that any Ruby app can drop in
11
+ # only needs one bearer-style access token and a handful of JSON
12
+ # endpoints, no framework coupling, trivially stubbable with WebMock.
13
+ #
14
+ # Unlike Shopify's split Admin/Storefront APIs with separate tokens,
15
+ # Wix's REST surface (Stores catalog, eCommerce carts/checkouts/orders)
16
+ # is a single API behind one access token — an app's client_credentials
17
+ # token is already scoped to one site via `instance_id` at fetch time
18
+ # (see Portage::Ucp::Wix::AccessTokenFetcher), so there's nothing here
19
+ # analogous to Shopify's per-call token selection.
20
+ class Client
21
+ include Portage::Ucp::Support::HttpClient
22
+
23
+ BASE_URL = "https://www.wixapis.com".freeze
24
+
25
+ def initialize(access_token:)
26
+ @access_token = access_token
27
+ end
28
+
29
+ def get(path)
30
+ request(Net::HTTP::Get, path)
31
+ end
32
+
33
+ def post(path, body = {})
34
+ request(Net::HTTP::Post, path, body)
35
+ end
36
+
37
+ def patch(path, body = {})
38
+ request(Net::HTTP::Patch, path, body)
39
+ end
40
+
41
+ def delete(path)
42
+ request(Net::HTTP::Delete, path)
43
+ end
44
+
45
+ private
46
+
47
+ def request(http_method, path, body = nil)
48
+ json_request(http_method, "#{BASE_URL}#{path}", body: body,
49
+ headers: { "Authorization" => @access_token })
50
+ end
51
+
52
+ def api_error_class = Portage::Ucp::Wix::ApiError
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,22 @@
1
+ module Portage
2
+ module Ucp
3
+ module Wix
4
+ class Error < StandardError; end
5
+
6
+ # Raised for any non-2xx response from a Wix REST call. Unlike Shopify's
7
+ # GraphQL/userErrors split, Wix's REST APIs report both transport-level
8
+ # rejections (bad auth, malformed request) and business rejections
9
+ # (e.g. "line item not found") the same way — a non-2xx status with a
10
+ # JSON error body — so one error class covers both.
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,152 @@
1
+ module Portage
2
+ module Ucp
3
+ module Wix
4
+ # Converts Wix Stores/eCommerce REST response bodies into the
5
+ # protocol-layer value objects from Portage::Ucp::ValueObjects
6
+ # (Product/Cart/LineItem/Checkout/Order/Money) — nothing Wix-shaped is
7
+ # allowed to leak past this file.
8
+ #
9
+ # Targets the Wix Stores Catalog V1 product shape (`priceData`,
10
+ # `stock`, `productPageUrl`) rather than V3 — V1's fields are the
11
+ # stable, longer-documented ones. A merchant on V3-only endpoints would
12
+ # need their own Mapper.
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
+ def product(node)
25
+ currency = node.dig("priceData", "currency")
26
+ Portage::Ucp::Product.new(
27
+ id: node["id"],
28
+ title: node["name"],
29
+ description: node["description"],
30
+ price: money(node.dig("priceData", "price"), currency),
31
+ available: node.dig("stock", "inStock") != false,
32
+ variants: (node["variants"] || []).map { |v| variant(v, node["name"], currency) },
33
+ url: product_url(node["productPageUrl"])
34
+ )
35
+ end
36
+
37
+ def product_url(page)
38
+ return nil unless page && page["base"]
39
+
40
+ "#{page['base']}#{page['path']}"
41
+ end
42
+
43
+ # A V1 variant's own identity is its `choices` (e.g. {"Size"=>"Large"})
44
+ # — there's no separate variant title field, so one is built from the
45
+ # choice values, falling back to the parent product's title for a
46
+ # single-variant (no options) product.
47
+ def variant(node, product_title, currency)
48
+ choices = node["choices"] || {}
49
+ title = choices.values.join(" / ")
50
+ title = product_title if title.empty?
51
+ { id: node["id"], title: title, available: node.dig("stock", "inStock") != false,
52
+ price: money(node.dig("variant", "priceData", "price"), currency) }
53
+ end
54
+
55
+ def cart(node)
56
+ Portage::Ucp::Cart.new(
57
+ id: node["id"],
58
+ line_items: (node["lineItems"] || []).map { |n| line_item(n) },
59
+ currency: node["currency"],
60
+ totals: totals(node["priceSummary"] || {})
61
+ )
62
+ end
63
+
64
+ # `status` isn't reliably present on a Wix Checkout the way it is on
65
+ # a Shopify Cart/Checkout hybrid — the adapter tracks it itself
66
+ # across the create/update/complete/cancel lifecycle and passes it
67
+ # in here, same rationale as Shopify's Mapper.checkout.
68
+ def checkout(node, status:, order: nil)
69
+ Portage::Ucp::Checkout.new(
70
+ id: node["id"],
71
+ status: status,
72
+ line_items: (node["lineItems"] || []).map { |n| line_item(n) },
73
+ currency: node["currency"],
74
+ totals: totals(node["priceSummary"] || {}),
75
+ links: [],
76
+ order: order
77
+ )
78
+ end
79
+
80
+ # Shared by Cart and Checkout line items — both are Wix `lineItem`
81
+ # shapes with the same fields. There's no documented per-line total
82
+ # field (only a per-cart/checkout `priceSummary` aggregate), so the
83
+ # line total is unit price * quantity, same as Shopify's variant
84
+ # price * quantity for a line with no discounts applied.
85
+ def line_item(node)
86
+ unit_price = minor_units(node.dig("price", "amount"))
87
+ quantity = node["quantity"]
88
+ line_total = unit_price * quantity
89
+ Portage::Ucp::LineItem.new(
90
+ id: node["id"],
91
+ item: Portage::Ucp::Item.new(id: node.dig("catalogReference", "catalogItemId"),
92
+ title: node.dig("productName", "original"), price: unit_price),
93
+ quantity: quantity,
94
+ totals: Portage::Ucp::Support::Totals.line(line_total)
95
+ )
96
+ end
97
+
98
+ # Builds the top-level totals array from Wix's priceSummary.
99
+ def totals(summary)
100
+ Portage::Ucp::Support::Totals.summary(subtotal: minor_units(summary.dig("subtotal", "amount")),
101
+ tax: minor_units(summary.dig("tax", "amount")),
102
+ total: minor_units(summary.dig("total", "amount")))
103
+ end
104
+
105
+ # Wix Order line items carry their own coarse fulfillmentStatus
106
+ # (NOT_FULFILLED/PARTIALLY_FULFILLED/FULFILLED/CANCELED) rather than
107
+ # Shopify's separate quantity/unfulfilledQuantity counters, so exact
108
+ # partial-fulfillment quantities aren't derivable from the Orders API
109
+ # alone (Wix's separate Fulfillments API has those, and isn't wired
110
+ # up here) — `fulfilled` is a best-effort all-or-nothing based on
111
+ # that status.
112
+ ORDER_LINE_ITEM_STATUS = {
113
+ "FULFILLED" => "fulfilled", "CANCELED" => "removed",
114
+ "PARTIALLY_FULFILLED" => "partial", "NOT_FULFILLED" => "processing"
115
+ }.freeze
116
+
117
+ # `permalink_url` is left blank — Wix's Orders API doesn't return a
118
+ # public order-status page URL (that's rendered on the buyer's Thank
119
+ # You page client-side, not exposed via this API).
120
+ def order(node)
121
+ subtotal_amount = minor_units(node.dig("priceSummary", "subtotal", "amount"))
122
+ total_amount = minor_units(node.dig("priceSummary", "total", "amount"))
123
+ Portage::Ucp::Order.new(
124
+ id: node["id"],
125
+ checkout_id: node["checkoutId"] || "",
126
+ permalink_url: "",
127
+ line_items: (node["lineItems"] || []).map { |n| order_line_item(n) },
128
+ fulfillment: Portage::Ucp::Fulfillment.new,
129
+ currency: node["currency"],
130
+ totals: Portage::Ucp::Support::Totals.summary(subtotal: subtotal_amount, total: total_amount)
131
+ )
132
+ end
133
+
134
+ def order_line_item(node)
135
+ quantity = node["quantity"]
136
+ status = Portage::Ucp::Support::LineItemStatus.from_table(ORDER_LINE_ITEM_STATUS, node["fulfillmentStatus"])
137
+ fulfilled = Portage::Ucp::Support::LineItemStatus.fulfilled_quantity(status, quantity)
138
+ unit_price = minor_units(node.dig("price", "amount"))
139
+ line_total = unit_price * quantity
140
+ Portage::Ucp::OrderLineItem.new(
141
+ id: node["id"],
142
+ item: Portage::Ucp::Item.new(id: node.dig("catalogReference", "catalogItemId"),
143
+ title: node.dig("productName", "original"), price: unit_price),
144
+ quantity: { original: quantity, total: quantity, fulfilled: fulfilled },
145
+ totals: Portage::Ucp::Support::Totals.line(line_total),
146
+ status: status
147
+ )
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,7 @@
1
+ module Portage
2
+ module Ucp
3
+ module Wix
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ require "portage/ucp"
2
+ require_relative "wix/version"
3
+ require_relative "wix/errors"
4
+ require_relative "wix/client"
5
+ require_relative "wix/access_token_fetcher"
6
+ require_relative "wix/mapper"
7
+ require_relative "wix/adapter"
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-ucp-wix
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 Wix's Stores (catalog) and eCommerce
84
+ (cart, checkout, order) REST APIs. Generic only — no merchant-specific business
85
+ logic. Plain Net::HTTP, no wix SDK runtime dependency.
86
+ email:
87
+ executables:
88
+ - portage-ucp-wix
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - CHANGELOG.md
93
+ - LICENSE
94
+ - README.md
95
+ - exe/portage-ucp-wix
96
+ - lib/portage/ucp/wix.rb
97
+ - lib/portage/ucp/wix/access_token_fetcher.rb
98
+ - lib/portage/ucp/wix/adapter.rb
99
+ - lib/portage/ucp/wix/client.rb
100
+ - lib/portage/ucp/wix/errors.rb
101
+ - lib/portage/ucp/wix/mapper.rb
102
+ - lib/portage/ucp/wix/version.rb
103
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-wix
104
+ licenses:
105
+ - MIT
106
+ metadata:
107
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-wix
108
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-wix/CHANGELOG.md
109
+ rubygems_mfa_required: 'true'
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '3.2'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.5.22
126
+ signing_key:
127
+ specification_version: 4
128
+ summary: Wix adapter for portage-ucp — standard catalog/cart/checkout/order over MCP
129
+ and UCP
130
+ test_files: []