portage-ucp-bigcommerce 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2d038db87d369f2765631c62a1929dd4a8a40b6947c0329c0f9b314664bae89
4
- data.tar.gz: e18f831f7b9e8938a82f59a385a0a163ebe09cbac6609a3085a3c7d6425f411b
3
+ metadata.gz: d4f9361bb29e57f06919220ed2a975c5c36ab6a1777cb0e664dafb9b67c45587
4
+ data.tar.gz: a0af7c70e6319a3e153ab7e978bad7b19de828c0c764b0376f993df256aecb3e
5
5
  SHA512:
6
- metadata.gz: 57dd6285329eaad7a827d9dd5138b78bb38d945f62694a809e4f6e1bb26143a9e6f7397144795455fa50f95597b181da9de01246aef4429fe4ce07fb44e97c65
7
- data.tar.gz: b5fe9cc8949d4874ab03077ed581eff78a83ef0afd55b15c1a4ca9bf38e975f0e0d400866fd5c3ed37e1b073e79218d287a3ec2f2e4ef2d4c629a3b41d0c3246
6
+ metadata.gz: 3d191617b9d74d7d1026b6dfa9999d17dae90dae35ea745e20ba96801a973e787b38a51b7bae03c7fa5689aaf42206e3e94466d92578b7957ecaf76b78113c67
7
+ data.tar.gz: df8a085c93246e691314ba9c0eef253f6f8c2232bc15a27bdef112b28096a16830c7c9156f2704f516ecaa7dee98d106b615b854f27e9536ad960e97d00c7301
data/CHANGELOG.md CHANGED
@@ -1,26 +1,203 @@
1
1
  # Changelog
2
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.1] - 2026-09-15
8
-
9
- - **Fix:** `Mapper.product`/`.variants` built `Portage::Ucp::Product`/`Variant`
10
- with the pre-schema `price:`/`available:` keywords core dropped when
11
- `dev.ucp.shopping.catalog` became schema-conformant — every real
12
- `search_catalog`/`get_product` call raised `ArgumentError: missing keyword:
13
- :price_range`. Now builds `Description`/`Price`/`PriceRange` objects and a
14
- real `Variant` array, matching `Shopify`/`Wix`'s mappers.
15
- - **Fix:** `Adapter#search_catalog` returned a bare `Array<Product>` instead
16
- of the `CatalogSearchResult` the `Adapter` contract documents — its wire
17
- form has no `products` wrapper, so it never validated against UCP's
18
- `catalog_search.json` response schema.
19
- - **Fix:** `Adapter#get_product` returned a bare `Product` instead of
20
- `ProductDetail`, so its wire form had no `product` wrapper either, for the
21
- same reason as `search_catalog` above.
22
-
23
- ## [0.1.0] - Unreleased
24
-
25
- - Initial pre-release. BigCommerce adapter against the v3 Catalog/Carts/
26
- Checkouts APIs and v2 Orders API.
3
+ Repo-level changes — the workspace, its shared docs, and anything spanning more
4
+ than one gem. Each gem keeps its own `CHANGELOG.md` for its own API; look there
5
+ for changes to `portage-ucp`, an adapter, the client, or the CLI.
6
+
7
+ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/);
8
+ this project is pre-1.0, so APIs may still shift between minor versions.
9
+
10
+ ## [0.7.0] - 2026-09-16
11
+
12
+ - `portage-ucp` bumps to 0.7.0: cryptographic AP2 mandate signature
13
+ verification (`Ap2::MandateSignature`, real ECDSA against a JWK trust-anchor
14
+ set, plus a `require_signature:` fail-closed option on `MandateGuard`,
15
+ closing the gap 0.6.0's shape-only mandate validation left open), a
16
+ cross-process idempotency race fix (`FileStore#fetch_or_store`, atomic
17
+ temp-file-plus-rename persistence, a poisoned-file rescue, reaped per-key/
18
+ per-session locks, and a new `Configuration#idempotency_provider`), a
19
+ `Rails::Railtie` + `rails g portage:ucp:install` generator, opt-in
20
+ OpenTelemetry span emission alongside the existing JSON logging, and
21
+ `Mcp::Server.build(journal:)` — naming the seam `portage-cli` now wires a
22
+ real journal through (see below) — plus `#each_record`/`#all` on
23
+ `TransactionLog`/`OrderLedger`. See `portage-ucp`'s own `CHANGELOG.md`.
24
+ - `portage-cli` bumps to 0.5.0 for `portage-console` (a read-only IRB REPL
25
+ over the local transaction/order/journal stores), `portage generate
26
+ adapter` (scaffolds a new adapter gem), `portage doctor` (sanity-checks a
27
+ seller's `Portage::Ucp.configuration`), and wiring a real
28
+ `PurchaseJournal` into `portage buy`'s own-store loopback path — that path
29
+ previously left the journal empty even though `portage-console` above
30
+ could read one. New runtime dependency on `portage-ucp-journal` (`~> 0.1`,
31
+ still unpublished — see 0.6.0's entry below).
32
+ - `portage-ucp-shopify` bumps to 0.4.2 for a fix to `GraphqlError` crashing
33
+ instead of surfacing Shopify's real message when `errors` comes back as a
34
+ bare string, plus a new live-store buyer-journey spec.
35
+ - `portage-ucp-client`, `portage-ucp-wix`, `portage-ucp-bigcommerce`,
36
+ `-woocommerce`, `-etsy`, `-magento`, and `-instagram` all take pin-only
37
+ patch releases (no behavior change) so each can install alongside
38
+ `portage-ucp` 0.7.0: their previously-published `~> 0.6` pin is
39
+ pessimistic and excludes 0.7.x. `portage-ucp-journal`'s `portage-ucp`
40
+ dependency (development-only, not a runtime dependency) also widens to
41
+ `~> 0.7` in its gemspec, with no release of its own needed since it
42
+ remains unpublished.
43
+
44
+ ## [0.6.0] - 2026-09-15
45
+
46
+ - **Fix:** `portage-ucp-woocommerce`, `-bigcommerce`, `-magento`, `-etsy`, and
47
+ `-instagram` all bump to 0.1.1 for a standing bug, unrelated to the
48
+ `portage-ucp` work below and predating it: each `Mapper.product` built a
49
+ `Portage::Ucp::Product`/`Variant` with keywords (`price:`, `available:`)
50
+ `dev.ucp.shopping.catalog`'s schema-conformance work removed in favor of
51
+ `price_range:`/a real `variants:` array (see that work's own changelog
52
+ entries, back when only `portage-ucp-shopify` and `-wix` got their mappers
53
+ migrated) — every real `search_catalog`/`get_product` call against these
54
+ five adapters raised `ArgumentError: missing keyword: :price_range`. Each
55
+ adapter's `#search_catalog`/`#get_product` also returned a bare
56
+ `Array<Product>`/`Product` instead of the `CatalogSearchResult`/
57
+ `ProductDetail` wrapper the `Adapter` contract documents, which would have
58
+ failed UCP schema validation even once the first bug was fixed. Caught by
59
+ running the core gem's schema-validation conformance examples (added
60
+ earlier for the payment-enrollment slice below) against every adapter,
61
+ not just Shopify/Wix's specs, which asserted the same wrong shape their
62
+ mappers produced.
63
+
64
+ - `portage-ucp` bumps to 0.6.0 for the §22/§33-§35 payment-enrollment and
65
+ signature-verification slice: `PaymentEnrollmentGuard` (validates every
66
+ `create_payment_enrollment`/`get_payment_enrollment` response — breaking,
67
+ raises `InvalidPaymentEnrollmentError` on a malformed one), an AP2 mandate
68
+ shape (`Ap2::PaymentMandate`/`Ap2::MandateGuard`, shape-only — no
69
+ cryptographic verification), a `Store`/`FileStore` extraction under
70
+ `Support::TransactionLog`/`Support::OrderLedger` so either can be backed
71
+ by something other than a file, `app.portage-ucp.payment_method` /
72
+ `saved_address` / `shopper_data` extensions, `Dispatcher.new(journal:)`
73
+ wiring for the new `portage-ucp-journal` gem, RFC 9421 HTTP Message
74
+ Signature verification (`Security::Signature`,
75
+ `Rack::SignatureVerification`) for inbound requests, and
76
+ `Confirmer::Webhook` — an out-of-band approval path for the confirmation
77
+ gate (POST + poll, or a caller-supplied `wait:` callback), alongside the
78
+ existing `Terminal`/`AutoApprove` confirmers. See `portage-ucp`'s own
79
+ `CHANGELOG.md`.
80
+ - `portage-ucp-journal` is a new gem (0.1.0, not yet published to
81
+ RubyGems): a buyer-side purchase journal plus the injectable `Store`
82
+ abstraction `portage-ucp`'s own `Store`/`FileStore` split now mirrors.
83
+ - `portage-cli` bumps to 0.4.1, `portage-ucp-client` to 0.3.1,
84
+ `portage-ucp-shopify` to 0.4.1, and `portage-ucp-wix` to 0.1.1 — pin-only
85
+ releases (no behavior change) so each can install alongside `portage-ucp`
86
+ 0.6.0: their previously-published `~> 0.5` (`~> 0.5` for `-wix`'s 0.1.0)
87
+ pin is pessimistic and excludes 0.6.x. Every other gem's `portage-ucp` pin
88
+ also widens to `~> 0.6` in its gemspec, with no release of its own needed
89
+ beyond what's already covered above.
90
+
91
+ ## [0.5.0] - 2026-09-14
92
+
93
+ - `portage-ucp` bumps to 0.5.0 for the agentic-payments work (docs/plans/agentic-payments.md):
94
+ `Adapter#create_payment_enrollment`/`#get_payment_enrollment` (card-on-file
95
+ enrollment without the card touching this process), a durable
96
+ `Support::TransactionLog` wired into `complete_checkout` dispatch,
97
+ `PolicyGuard`/`Policy` (per-transaction/rolling caps, velocity, merchant
98
+ allowlist, per-token enrollment scopes), a `Confirmer` gate
99
+ (`Terminal`/`AutoApprove`) run just before dispatch, a durable
100
+ `Support::OrderLedger` snapshot written after settlement, `_meta`
101
+ `ucp-agent.profile` threading alongside the existing `traceparent`
102
+ correlation id, and `Adapter#lookup_catalog(ids:)` for batch product
103
+ fetch by id — see `portage-ucp`'s own `CHANGELOG.md`.
104
+ - `portage-cli` bumps to 0.4.0 for `portage payment list/enroll/set-default/
105
+ remove/freeze/revoke` (Keychain / Secret Service / env-var-only storage)
106
+ and `portage policy show/set`, both driving the new `portage-ucp` policy
107
+ and enrollment capabilities.
108
+ - `portage-ucp-client` bumps to 0.3.0 for `Session#create_payment_enrollment`/
109
+ `#get_payment_enrollment` and an optional `meta:` kwarg threaded through
110
+ every transport.
111
+ - `portage-ucp-shopify` bumps to 0.4.0 for `Adapter#lookup_catalog(ids:)`,
112
+ fetching several known product ids in one round trip via the Admin API's
113
+ `nodes(ids:)` field.
114
+ - Every adapter gemspec and `portage-cli` widen their `portage-ucp` pin to
115
+ `~> 0.5` (and `portage-cli`'s `portage-ucp-client` pin to `~> 0.3`) for the
116
+ capabilities above; only `portage-ucp`, `portage-ucp-shopify`,
117
+ `portage-ucp-client`, and `portage-cli` have ever been published to
118
+ RubyGems.
119
+
120
+ ## [0.4.0] - 2026-08-28
121
+
122
+ - `portage-cli` bumps to 0.3.0 for `portage compare` (§22's "find this same
123
+ item elsewhere" mode) and `portage history` (local purchase/search log),
124
+ plus a fix for a `search_catalog` envelope-unwrapping bug that had been
125
+ producing malformed offers against every real store since 0.2.0 — see
126
+ `portage-cli`'s own `CHANGELOG.md`.
127
+ - `portage-ucp` bumps to 0.4.0 for the `app.portage-ucp.reorder` capability,
128
+ per-request correlation ids threaded through `Dispatcher`/`Mcp::Server`/
129
+ `CheckoutState` for observability, and a widened `Observability::REDACTED_KEYS`
130
+ covering the PII fields that actually flow through logged events — see
131
+ `portage-ucp`'s own `CHANGELOG.md`.
132
+ - `portage-ucp-shopify` bumps to 0.3.1 to widen its `portage-ucp` dependency
133
+ pin from `~> 0.3` to `~> 0.4` — no code change of its own. Every other
134
+ adapter gemspec and `portage-ucp-client` got the same pin widened, but
135
+ none has published a version yet, so there's no install-breakage to fix
136
+ for them beyond keeping the constraint correct ahead of their first
137
+ release.
138
+ - `portage-cli` and `portage-ucp` are the only two gems in this release with
139
+ behavior changes; `portage-ucp` and `portage-ucp-shopify` remain the only
140
+ two gems ever published to RubyGems.
141
+
142
+ ## [0.3.0] - 2026-08-27
143
+
144
+ - All seven adapter gems now run the core gem's conformance kit against their
145
+ real `Adapter` through a real `Dispatcher`
146
+ (`spec/portage/ucp/<platform>/conformance_spec.rb`), closing the follow-up
147
+ design-log §17 left open when the kit shipped. No adapter needed
148
+ body-matching stubs: the kit's reachable surface stops at `create_checkout`.
149
+ - Core gem's `~> 0.2` dependency pin widened to `~> 0.3` in every adapter,
150
+ `portage-cli`, and `portage-ucp-client` gemspec.
151
+ - `portage-ucp` and `portage-ucp-shopify` both bump to 0.3.0 — see each
152
+ gem's own `CHANGELOG.md` for `Support::Retry`, `Support::SessionLock`, the
153
+ Shopify metadata_field config DSL, and the Shopify GraphQL shape fixes
154
+ driving the bump. `portage-cli` and `portage-ucp-client` are unchanged and
155
+ stay at 0.2.0.
156
+
157
+ ## [0.2.0] - 2026-08-21
158
+
159
+ ### Added
160
+
161
+ - `~/.portage/` — the first state anything here keeps outside the project
162
+ directory. `stores.yml` is the store allowlist you curate; the CLI writes
163
+ `discovery-cache.json` to remember which origins answered
164
+ `/.well-known/ucp`, so a URL-less search doesn't re-probe the same hosts on
165
+ every run. Both are the CLI's, and both are documented in
166
+ [`portage-cli`](portage-cli/README.md).
167
+ - Search-backend credentials as configuration that belongs to no adapter:
168
+ `BRAVE_SEARCH_API_KEY`, `GOOGLE_CSE_KEY` / `GOOGLE_CSE_CX`, and
169
+ `PORTAGE_STORES`. The per-adapter variables in the root README's
170
+ Requirements table decide which platforms the CLI can buy from; these decide
171
+ which stores it can propose in the first place, so they sit outside that
172
+ table.
173
+ - Root `CHANGELOG.md` (this file) — every gem already had one, the workspace
174
+ itself didn't.
175
+ - `docs/walkthrough.md` and `docs/well-known-ucp.md`: the agent-buys-a-snowboard
176
+ walkthrough and the `/.well-known/ucp` rationale, moved out of the root README
177
+ so it opens on how to install and run the thing.
178
+ - A `## License` section in every adapter gem's README — all seven shipped a
179
+ `LICENSE` file without pointing at it.
180
+ - `docs/design-log.md` §15: how the CLI finds a store when the user has no URL,
181
+ and why the search step uses documented APIs rather than a scraped results
182
+ page. Ships alongside `portage find` in `portage-cli`.
183
+
184
+ ### Changed
185
+
186
+ - Root README's gem table describes both ways into the CLI — `portage buy
187
+ <url>` when you know the shop, and `portage find --query "..."` when you
188
+ don't.
189
+ - Root README follows the `bundle gem` section order: Installation and Usage
190
+ come first, then the gem table, then everything else. The table of contents
191
+ now matches the rendered order (Requirements had been listed last and rendered
192
+ second).
193
+ - Root README's Requirements section is a per-adapter env-var table linking each
194
+ adapter's own README, instead of restating credential setup those READMEs
195
+ already own.
196
+ - Every README names its how-to-run section `## Usage`, replacing the mix of
197
+ `Quickstart` (core, client) and `Using the adapter directly` (adapters).
198
+
199
+ ## [0.1.0] - 2026-08-14
200
+
201
+ - Initial release: ten gems, published to RubyGems. See each gem's own
202
+ `CHANGELOG.md` for what it ships, and the [design log](docs/design-log.md)
203
+ for the rationale behind the split.
data/README.md CHANGED
@@ -1,126 +1,313 @@
1
- # portage-ucp-bigcommerce
1
+ # Portage::Ucp
2
2
 
3
- BigCommerce adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against a BigCommerce store's v3 Catalog/Carts/Checkouts APIs and v2 Orders API. Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no `bigcommerce_api` runtime dependency.
3
+ [![gem version](https://img.shields.io/gem/v/portage-ucp)](https://rubygems.org/gems/portage-ucp)
4
+ ![ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-red)
5
+ ![license](https://img.shields.io/badge/license-MIT-blue)
4
6
 
5
- ## What it covers
7
+ Ruby gems that expose a commerce backend to AI shopping agents over **MCP** ([Model Context Protocol](https://modelcontextprotocol.io)) and **UCP** ([Universal Commerce Protocol](https://ucp.dev)) at once. Open-source, for any Ruby app on any e-commerce stack — not tied to any one merchant's business logic.
6
8
 
7
- | UCP capability | Backing BigCommerce API | Notes |
8
- |---|---|---|
9
- | `dev.ucp.shopping.catalog` | v3 Catalog | `search_catalog`, `get_product` |
10
- | `dev.ucp.shopping.cart` | v3 Carts | `get_cart`, `create_cart`, `update_cart`, `cancel_cart` |
11
- | `dev.ucp.shopping.checkout` | v3 Checkouts (shares its cart's id), plus Orders + Payments to complete | `create_checkout`, `get_checkout`, `update_checkout`, `complete_checkout`, `cancel_checkout` |
12
- | `dev.ucp.shopping.order` | v2 Orders | `get_order` |
13
- | `dev.ucp.shopping.identity` | — | not implemented; BigCommerce's storefront Customer Login API is a separate concern from the store-owner API account used here |
14
-
15
- Unlike Shopify/WooCommerce, BigCommerce's Checkout **is** a distinct resource from its Cart — but they share the same id, created together by a single `POST /carts` call. `update_cart`/`update_checkout` are full-replacement: the Carts API has no atomic "replace all lines" endpoint, so the adapter adds the desired lines *before* removing the old ones — BigCommerce auto-deletes a cart when its last remaining line item is removed, so removing old lines first would destroy the cart the call is supposed to be updating. `cancel_cart` deletes the cart resource outright (BigCommerce is the only one of the three adapters whose Carts API actually supports this). Mutating methods dedup by `idempotency_key` in-process so a dropped-connection retry can't double-charge.
16
-
17
- Unlike Shopify/Wix, there's no `AccessTokenFetcher` — a BigCommerce API account (Settings → API → Create API Account) hands back a static `client_id`/`access_token` pair with no expiry, same posture as `portage-ucp-woocommerce`. The separate OAuth authorization-code flow BigCommerce uses for public Marketplace apps is a different concern, out of scope here.
9
+ "Portage" — carrying cargo overland between waterways it can't sail directly between — is what this does: carries commerce operations across platforms that don't natively speak UCP or speak to each other.
18
10
 
19
- ## ⚠️ Unverified against a live store
11
+ > **Status**: `0.7.0`. APIs may still shift before `1.0` — see the [design log](docs/design-log.md).
20
12
 
21
- Built from BigCommerce's documented REST API shapes, not run against a real store yet. Before relying on this in production, confirm:
13
+ ## Contents
22
14
 
23
- - **`#complete_checkout`** — creates an order from the checkout, mints a single-order Payment Access Token, then submits `payment_token` as a tokenized instrument to the separate Payments API (`payments.bigcommerce.com`). The exact `payment_instrument` shape a given gateway expects (`payment_gateway_id:`) is gateway-specific and hasn't been confirmed against a live store with a real gateway installed.
24
- - **Cart/Checkout line item price fields** — `Mapper.cart_line_item` assumes plain decimal fields (`sale_price`, `extended_sale_price`); some BigCommerce API surfaces instead nest money under a `{value:, currency:}` sub-object.
25
- - **`Order#permalink_url`** — built from BigCommerce's documented storefront order-status route, not confirmed against a live storefront theme.
26
- - **Order fulfillment** — BigCommerce core has no per-line-item fulfillment tracking, so every order line is given the same coarse status derived from the order's own `status_id`, not a real per-line signal.
15
+ - [Installation](#installation)
16
+ - [Usage](#usage)
17
+ - [The gems](#the-gems)
18
+ - [Security hooks](#security-hooks--nothing-is-permissive-by-default)
19
+ - [How the pieces fit together](#how-the-pieces-fit-together)
20
+ - [Writing your own adapter](#writing-your-own-adapter)
21
+ - [Checking any store](#checking-any-store)
22
+ - [Spec conformance](#spec-conformance)
23
+ - [Requirements](#requirements)
24
+ - [Development](#development)
25
+ - [Contributing](#contributing)
26
+ - [License](#license)
27
27
 
28
28
  ## Installation
29
29
 
30
30
  ```ruby
31
31
  # Gemfile
32
- gem "portage-ucp-bigcommerce"
32
+ gem "portage-ucp"
33
+ gem "portage-ucp-shopify" # or another adapter gem, or your own Adapter subclass
33
34
  ```
34
35
 
35
36
  ```bash
36
37
  bundle install
37
38
  ```
38
39
 
39
- ## Setup
40
-
41
- You need your store hash (from the BigCommerce control panel URL or API path), and a client_id/access_token pair from an API account (Settings → API → Create API Account — grant the scopes you need for Products/Carts/Checkouts/Orders), your store's currency (the Catalog product resource doesn't return one), and — only if you'll call `complete_checkout` — the payment gateway id you want to submit orders through.
40
+ ## Usage
42
41
 
43
42
  ```ruby
44
- require "portage/ucp/bigcommerce"
45
-
46
- client = Portage::Ucp::BigCommerce::Client.new(
47
- store_hash: ENV.fetch("BIGCOMMERCE_STORE_HASH"),
48
- client_id: ENV.fetch("BIGCOMMERCE_CLIENT_ID"),
49
- access_token: ENV.fetch("BIGCOMMERCE_ACCESS_TOKEN")
43
+ require "portage/ucp"
44
+ require "portage/ucp/shopify"
45
+
46
+ # 1. build an adapter
47
+ client = Portage::Ucp::Shopify::Client.new(
48
+ shop_domain: "your-shop.myshopify.com",
49
+ admin_access_token: ENV.fetch("SHOPIFY_ADMIN_ACCESS_TOKEN"),
50
+ storefront_access_token: ENV.fetch("SHOPIFY_STOREFRONT_ACCESS_TOKEN")
50
51
  )
52
+ adapter = Portage::Ucp::Shopify::Adapter.new(client: client)
53
+
54
+ # 2. configure defaults (once, e.g. in an initializer) — see "Security hooks" below,
55
+ # the unconfigured defaults reject every mutating call on purpose
56
+ Portage::Ucp.configure do |config|
57
+ config.authenticator = MyAuthenticator.new
58
+ config.rate_limiter = MyRateLimiter.new
59
+ config.business = { name: "Your Store", url: "https://your-shop.example" }
60
+ end
61
+
62
+ # 3. serve it over MCP
63
+ server = Portage::Ucp::Mcp::Server.build(adapter: adapter)
64
+ server.start # stdio, or mount as Streamable HTTP per the `mcp` gem's own docs
65
+ ```
51
66
 
52
- adapter = Portage::Ucp::BigCommerce::Adapter.new(
53
- client: client,
54
- site_url: "https://your-shop.example.com",
55
- currency: "USD",
56
- payment_gateway_id: "stripe" # only required for #complete_checkout
57
- )
67
+ That's a running MCP server, wired up inline. Every adapter gem also ships an
68
+ executable that does steps 1 and 3 for you, so you don't need a throwaway Ruby file
69
+ just to point an MCP client (Claude Desktop, etc.) at a `command`:
70
+
71
+ ```bash
72
+ bundle exec portage-ucp-shopify # stdio, reads SHOPIFY_SHOP_DOMAIN /
73
+ # SHOPIFY_ADMIN_ACCESS_TOKEN / SHOPIFY_STOREFRONT_ACCESS_TOKEN
58
74
  ```
59
75
 
60
- ## Usage
76
+ Step 2 (wiring a real `authenticator`/`rate_limiter`/`business`) still has to come from
77
+ you — the exe won't guess those — so point `PORTAGE_UCP_CONFIG` at a Ruby file that
78
+ calls `Portage::Ucp.configure`, the same `-r`-a-file pattern `rackup`/Sidekiq use.
79
+ [`portage-ucp-shopify/examples/portage_ucp.rb`](portage-ucp-shopify/examples/portage_ucp.rb)
80
+ is a copy-paste starting point (bearer-token authenticator, in-process rate limiter):
61
81
 
62
- ```ruby
63
- # Catalog
64
- products = adapter.search_catalog(query: "hoodie", limit: 10)
65
- product = adapter.get_product(product_id: products.first.id)
66
-
67
- # Cart — cart_id comes back from the create response
68
- cart = adapter.create_cart(
69
- line_items: [{ product_id: product.variants.first[:id], quantity: 2 }],
70
- idempotency_key: SecureRandom.uuid
71
- )
72
- cart = adapter.update_cart(cart_id: cart.id, line_items: [], idempotency_key: SecureRandom.uuid) # empties cart
82
+ ```bash
83
+ PORTAGE_UCP_CONFIG=./config/portage_ucp.rb bundle exec portage-ucp-shopify
84
+ ```
73
85
 
74
- # Checkout — shares the same id as the cart it was created from
75
- checkout = adapter.create_checkout(
76
- line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
77
- idempotency_key: SecureRandom.uuid
78
- )
79
- checkout = adapter.complete_checkout(
80
- checkout_id: checkout.id,
81
- payment_token: single_use_token_from_payment_handler,
82
- idempotency_key: SecureRandom.uuid
83
- )
86
+ Without it, the server still starts but rejects every mutating call — the
87
+ `UnconfiguredAuthenticator` default from [Security hooks](#security-hooks--nothing-is-permissive-by-default) below.
88
+
89
+ An agent connecting to it can now do this end to end — shown here as simplified `tools/call name { args }` shorthand, not the literal JSON-RPC envelope on the wire:
84
90
 
85
- # Order
86
- order = adapter.get_order(order_id: checkout_order_id) # only once linked post-completion
87
91
  ```
92
+ tools/call search_catalog { query: "snowboard", limit: 5 }
93
+ → Powder Chaser 158cm, $549.00, gid://shopify/Product/1
88
94
 
89
- ## Wiring into portage-ucp
95
+ tools/call get_product { product_id: "gid://shopify/Product/1" }
96
+ → variant gid://shopify/ProductVariant/11, "158cm", available
90
97
 
91
- Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
98
+ tools/call create_checkout { line_items: [{ product_id: "gid://shopify/ProductVariant/11", quantity: 1 }],
99
+ idempotency_key: "b3f1-..." }
100
+ → checkout gid://shopify/Cart/abc, status: incomplete
92
101
 
93
- ```ruby
94
- dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
102
+ tools/call complete_checkout { checkout_id: "gid://shopify/Cart/abc", payment_token: "spt_1a2b3c...",
103
+ idempotency_key: "b3f1-..." }
104
+ → status: completed
95
105
 
96
- dispatcher.call(
97
- capability: "dev.ucp.shopping.cart",
98
- action: "create",
99
- arguments: { line_items: [{ product_id: variant_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
100
- )
106
+ tools/call get_order { order_id: "gid://shopify/Order/9001" }
107
+ → checkout_id: gid://shopify/Cart/abc, permalink_url: https://your-shop.example/orders/9001, totals: [...]
101
108
  ```
102
109
 
103
- 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.
110
+ Five tool calls, one snowboard bought. [`docs/walkthrough.md`](docs/walkthrough.md) shows what's actually running behind each of those — auth checks, PAN rejection, idempotent retries — plus how to serve the discovery manifest and order webhooks, from the shopper agent's side.
111
+
112
+ ## The gems
113
+
114
+ Eleven gems, mirroring how Faraday/Devise split core-vs-adapter:
115
+
116
+ | Gem | Role |
117
+ |---|---|
118
+ | [`portage-ucp`](portage-ucp/) | Protocol-only core: `Adapter` contract, capability registry, manifest builder, MCP server wrapper. Zero commerce-backend deps — works with any backend that implements `Adapter`, Shopify or otherwise. |
119
+ | [`portage-ucp-client`](portage-ucp-client/) | Client-side SDK — the other direction from every gem below: connect to somebody else's manifest (or drive your own `Adapter` directly) and act as the shopper's agent. Loopback/stdio/HTTP transports behind one interface. |
120
+ | [`portage-ucp-journal`](portage-ucp-journal/) | Buyer-side purchase journal + the injectable `Store` abstraction it's built on (design-log §22) — an append-only record of every completed purchase, kept out of core so `portage-ucp` stays dependency-light. Wires in via `Dispatcher`'s optional `journal:` argument. |
121
+ | [`portage-cli`](portage-cli/) | Ships the `portage` command — `portage buy <url>` tries native UCP discovery first, falls back to a platform adapter only when you already have that platform's own credentials, and says so plainly otherwise. `portage find --query "..."` covers the no-URL case: search backends propose stores, `/.well-known/ucp` filters them, their catalogs answer. `portage history` browses the local log of past purchases and searches. `portage payment list/enroll/set-default/remove/freeze/revoke` stores card-on-file tokens (macOS Keychain / Linux Secret Service / a headless env-var-only tier) so `buy` can fall back to a stored default instead of demanding a fresh token every call; `portage policy show/set` manages the caps/velocity/allowlist `PolicyGuard` checks. |
122
+ | [`portage-ucp-shopify`](portage-ucp-shopify/) | Shopify adapter — implements `Adapter` against Shopify's Admin + Storefront GraphQL APIs. One consumer of the core gem, not a dependency of it. |
123
+ | [`portage-ucp-wix`](portage-ucp-wix/) | Wix adapter — implements `Adapter` against Wix's Stores Catalog and eCommerce REST APIs. |
124
+ | [`portage-ucp-woocommerce`](portage-ucp-woocommerce/) | WooCommerce adapter — implements `Adapter` against a WooCommerce site's Admin REST API and Store API. |
125
+ | [`portage-ucp-bigcommerce`](portage-ucp-bigcommerce/) | BigCommerce adapter — implements `Adapter` against a BigCommerce store's v3 Catalog/Carts/Checkouts APIs and v2 Orders API. |
126
+ | [`portage-ucp-magento`](portage-ucp-magento/) | Magento/Adobe Commerce adapter — implements `Adapter` against a Magento site's REST v1 API (admin-token catalog/order, anonymous guest-cart cart/checkout). |
127
+ | [`portage-ucp-etsy`](portage-ucp-etsy/) | Etsy adapter — real catalog/order against Etsy's Open API v3; checkout is redirect-link only (Etsy's public API has no cart/checkout endpoint). |
128
+ | [`portage-ucp-instagram`](portage-ucp-instagram/) | Instagram/Facebook Shops adapter — real catalog against Meta's Graph API Commerce Catalog; checkout is redirect-link only, and order lookup only works for "checkout on Instagram/Facebook" merchants. |
129
+
130
+ A backend on some other stack (a hand-rolled Rails store, another platform entirely) writes its own thin `Adapter` subclass against `portage-ucp` directly — the adapters above are just the ones that exist today. Not every backend has a real cart/checkout API to back — Etsy and Instagram/Facebook Shops don't, so those two adapters only implement catalog/order for real and fall back to a redirect-link `Checkout` instead of a full transactional one (see their READMEs).
131
+
132
+ Each adapter gem also ships the same `exe/` executable, `examples/portage_ucp.rb` starting point, and `PORTAGE_UCP_CONFIG` hook shown in [Usage](#usage) — see [Requirements](#requirements) for the env vars each one reads.
133
+
134
+ **Already on Shopify and wondering why you'd need this at all** — Shopify ships its own native Universal Commerce Agent app that auto-serves `/.well-known/ucp` with checkout+order capabilities, no code required. This gem is for the gap that app leaves: `cart`/`catalog` capabilities it doesn't advertise, a signed manifest it can't produce, and a self-hosted setup for backends other than Shopify. Full comparison in [`docs/well-known-ucp.md`](docs/well-known-ucp.md).
135
+
136
+ ### Capability coverage per adapter
137
+
138
+ A capability is advertised in the manifest as soon as an adapter overrides *any one* of its backing `Adapter` methods (`Capability#advertised_for?`, `portage-ucp/lib/portage/ucp/capability.rb`) — ✅ below means every backing method is overridden, 🟡 means only some are (the rest raise `Portage::Ucp::NotImplementedError` if called), and — means none are, so the capability isn't advertised at all. Derived by reading each adapter's actual method definitions, not its docs.
104
139
 
105
- ## Errors
140
+ | Capability | Shopify | Wix | WooCommerce | BigCommerce | Magento | Etsy | Instagram |
141
+ |---|---|---|---|---|---|---|---|
142
+ | `dev.ucp.shopping.catalog` | ✅ | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 |
143
+ | `dev.ucp.shopping.cart` | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
144
+ | `dev.ucp.shopping.checkout` | ✅ | ✅ | ✅ | ✅ | ✅ | 🟡 | 🟡 |
145
+ | `dev.ucp.shopping.order` | ✅ | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 |
146
+ | `dev.ucp.shopping.discount` | ✅ | — | — | — | — | — | — |
147
+ | `dev.ucp.shopping.fulfillment` | ✅ | — | — | — | — | — | — |
148
+ | `dev.ucp.shopping.identity` | — | — | — | — | — | — | — |
149
+ | `app.portage-ucp.reorder` | — | — | — | — | — | — | — |
150
+ | `app.portage-ucp.payment_enrollment` / `payment_method` / `shopper_data` | — | — | — | — | — | — | — |
151
+
152
+ - **catalog 🟡** (Wix/WooCommerce/BigCommerce/Magento/Etsy/Instagram): missing `lookup_catalog` — the batch-fetch-by-ids variant of `search_catalog`/`get_product`.
153
+ - **checkout 🟡** (Etsy, Instagram): only `create_checkout`/`get_checkout` — both platforms are redirect-link checkout only, so `update_checkout`/`complete_checkout`/`cancel_checkout` have nothing to back them.
154
+ - **order 🟡** (Wix/WooCommerce/BigCommerce/Magento/Etsy/Instagram): only `get_order` — `cancel_order`/`request_return`/`refund_order` aren't implemented yet.
155
+ - **identity, reorder, and the `app.portage-ucp.*` extensions** (payment enrollment, saved payment methods/addresses, shopper-data erasure) aren't implemented by any bundled adapter yet — a consumer needing one of these today writes it against their own `Adapter` subclass directly.
156
+
157
+ **Who needs credentials here, and who doesn't:** the merchant sets this up *once*, using their own store's credentials (Shopify Admin token, WooCommerce consumer key, whatever). That's the only credentialed step in the whole system. After that manifest is live at `/.well-known/ucp`, **any shopper's AI agent — on any harness, with zero credentials of its own — can discover it and buy from it.** The shopper never sees a Shopify token or a WooCommerce key; they authenticate (if at all) with their own payment handler, not with your store. This is the same trust model as a merchant integrating hosted checkout today — one-time setup by the merchant, open-ended public use by anyone who finds it. See [`docs/walkthrough.md`](docs/walkthrough.md) for the full "shopper agent → your live manifest → bought" path, credential-free the whole way.
158
+
159
+ ## Security hooks — nothing is permissive by default
160
+
161
+ Read this before wiring a server up to anything real — every default here is deliberately locked down, not permissive-by-omission:
162
+
163
+ - **Authentication**: `Portage::Ucp::UnconfiguredAuthenticator` (the default) rejects every mutating call until you configure a real one. Implement `#call(server_context)` to return a truthy auth context, or raise `Portage::Ucp::AuthenticationError`.
164
+ - **Rate limiting**: `Portage::Ucp::NullRateLimiter` (the default) never limits. Implement `#check!(key, capability)` and raise `Portage::Ucp::RateLimitExceededError` to block a call.
165
+ - **PAN guard**: `Portage::Ucp::PaymentTokenGuard` rejects any `payment_token` that looks like a raw card number (digits-only, Luhn-valid, 12–19 chars) before it ever reaches your `Adapter` — `complete_checkout` must receive a tokenized credential.
166
+ - **Idempotency**: every mutating capability action takes an `idempotency_key:` — your `Adapter` is responsible for deduping retries (see `portage-ucp-shopify`'s in-process dedup table for one approach).
167
+ - **Observability**: `Portage::Ucp::Observability.log` emits structured JSON log events through a consumer-injected logger, redacting `payment_token`/`oauth_token`/`authorization` automatically.
168
+ - **Manifest signing**: `Portage::Ucp::Manifest` never generates or stores keys — pass a `signer` (anything responding to `#kid` and `#sign(canonical_json)`) to produce a signed manifest; omit it to serve unsigned.
169
+ - **Policy caps**: `Portage::Ucp::PolicyGuard`, wired into `Dispatcher` just before `complete_checkout` dispatch, enforces `Portage::Ucp::Policy`'s per-transaction/rolling caps, velocity limits, and merchant allowlist — plus per-token enrollment scopes (merchant/max-amount/currency) bound at enrollment time and checked by the same `token_ref` at charge time. Configured via `portage-cli`'s `portage policy show/set` and `portage payment enroll --scope-*`.
170
+ - **Confirmation**: `Portage::Ucp::Confirmer`, run right after `PolicyGuard.check!` passes and before `complete_checkout` dispatch. `Confirmer::Terminal` blocks on stdin and fails closed on anything but an explicit `"y"`; `Confirmer::AutoApprove` is for specs/conformance kits that need a real `confirm!` without blocking; `Confirmer::Webhook` is for out-of-band approval — POSTs to a configured URL and polls (or calls a caller-supplied `wait:` callback) until approve/deny/timeout, failing closed on timeout like `Terminal` but with a longer default (900s vs. 120s).
171
+ - **Payment enrollment**: `Portage::Ucp::PaymentEnrollmentGuard`, run via `Dispatcher#call`, validates every `create_payment_enrollment`/`get_payment_enrollment` response an adapter returns — `"pending"` must carry a `setup_url` and no `payment_token`, `"complete"` the reverse — raising `InvalidPaymentEnrollmentError` on a violation. An optional `mandate:` kwarg on the same two methods is shape-checked by `Portage::Ucp::Ap2::MandateGuard` (AP2 mandate shape, not cryptographic verification).
172
+ - **Inbound request signatures**: `Portage::Ucp::Security::Signature` / `Rack::SignatureVerification` verify RFC 9421 HTTP Message Signatures on inbound requests per UCP's signature spec — the same verify-before-parse posture as `Rack::WebhookEndpoint`, trusting `Manifest#signing_keys`' current+next JWK array.
173
+ - **Durable records**: `Portage::Ucp::Support::TransactionLog` reserves a transaction before `complete_checkout` dispatch and marks it settled/failed after, recording `PolicyGuard`/`Confirmer` outcomes on the same record. `Portage::Ucp::Support::OrderLedger` writes a durable snapshot alongside it once settlement succeeds — a failed snapshot write surfaces without flipping an already-settled charge to failed.
174
+
175
+ ## How the pieces fit together
176
+
177
+ ```
178
+ Your backend
179
+ ↓ implements
180
+ Portage::Ucp::Adapter (catalog/cart/checkout/order/identity methods)
181
+ ↓ registered against
182
+ Portage::Ucp::CapabilityRegistry (which capabilities does this adapter actually support?)
183
+ ↓ used by
184
+ Portage::Ucp::Dispatcher (routes a capability+action call to the Adapter method)
185
+ ↓ wrapped by
186
+ Portage::Ucp::Mcp::Server.build (generates MCP::Tool objects, one per advertised action)
187
+ ↓ served over
188
+ stdio / Streamable HTTP (via the `mcp` gem)
189
+
190
+ Portage::Ucp::Manifest (builds the signed /.well-known/ucp discovery doc)
191
+ ↓ served by
192
+ Portage::Ucp::Rack::ManifestEndpoint
193
+
194
+ Portage::Ucp::Rack::WebhookEndpoint (HMAC-verified inbound order-lifecycle webhooks)
195
+ ```
196
+
197
+ A capability (e.g. `dev.ucp.shopping.cart`) is only advertised if your `Adapter` overrides at least one of its backing methods — an unconfigured method just means that capability doesn't show up in the manifest or the MCP tool list, not a 500.
198
+
199
+ ## Writing your own adapter
200
+
201
+ ```ruby
202
+ class MyAdapter < Portage::Ucp::Adapter
203
+ def search_catalog(query:, limit:) = ...
204
+ def get_product(product_id:) = ...
205
+ def create_cart(line_items:, idempotency_key:) = ...
206
+ # override only the capabilities you support — the rest stay unadvertised
207
+ end
208
+ ```
209
+
210
+ See `Portage::Ucp::Adapter` for the full method contract (catalog, cart, checkout, order, identity linking, reorder), `Portage::Ucp::ReferenceAdapter` (ships with the core gem, `lib/portage/ucp/reference_adapter.rb`) for a complete in-memory implementation of every capability including discount/fulfillment/identity/reorder, and `portage-ucp-shopify`'s `Adapter` for a real one against a live commerce API.
211
+
212
+ `reorder` (`app.portage-ucp.reorder`) is a Portage-owned extension, not part of the UCP spec: it hydrates a `Cart` from a past order's line items, re-checking each item's current availability rather than replaying historical prices, and reports anything no longer purchasable via `ReorderResult#unavailable_items` instead of failing outright.
213
+
214
+ `payment_method` / `saved_address` / `shopper_data` (`app.portage-ucp.payment_method`, `app.portage-ucp.saved_address`, `app.portage-ucp.shopper_data`) are Portage-owned extensions too, shipped together: saved payment references and addresses, plus the erasure path that removes them (and the shopper's linked identity) in one call. `oauth_token:` is the authorization boundary on every method here, including the two `list_*` reads — see `Portage::Ucp::Adapter`'s doc comments on `save_payment_method` for why a bare `subject:` string would be a lookup vulnerability. `save_payment_method`'s `payment_token:` runs through the same `PaymentTokenGuard` Luhn/format check as `complete_checkout`, and `delete_shopper_data` is idempotent — safe to call again on an already-erased subject.
215
+
216
+ ### Checking your adapter against the contract
217
+
218
+ `Portage::Ucp::SchemaValidator` (see [Spec conformance](#spec-conformance) below) checks that your `Adapter`'s output matches UCP's wire schemas, but schema-valid output can still violate the contract's behavioral guarantees — an idempotency key that isn't actually deduped, a raw PAN reaching your adapter, a capability that's advertised but doesn't round-trip through its own schema. The core gem ships a conformance kit, an RSpec shared-examples suite, for that:
106
219
 
107
220
  ```ruby
108
- Portage::Ucp::BigCommerce::Error # base class
109
- Portage::Ucp::BigCommerce::ApiError # any non-2xx response from the Admin or Payments API
221
+ # spec/spec_helper.rb
222
+ require "portage/ucp/rspec"
223
+
224
+ # spec/my_adapter_spec.rb
225
+ RSpec.describe MyAdapter do
226
+ it_behaves_like "a portage adapter" do
227
+ let(:adapter) { MyAdapter.new(client: my_test_client) }
228
+ let(:existing_product_id) { "known-good-product-id" } # real/stubbed, in-stock, purchasable
229
+ # optional — enables the out-of-stock example:
230
+ # let(:out_of_stock_product_id) { "known-sold-out-product-id" }
231
+ end
232
+ end
233
+ ```
234
+
235
+ Not loaded by `require "portage/ucp"` — it pulls in RSpec, which the core gem otherwise has zero runtime dependency on. Every example skips itself when your adapter doesn't advertise the capability it needs, so an adapter that only does catalog and checkout still runs it cleanly.
236
+
237
+ All seven bundled adapter gems run it against their real `Adapter` (`spec/portage/ucp/<platform>/conformance_spec.rb` in each), and `spec/reference_adapter_conformance_spec.rb` in the core gem runs it against `ReferenceAdapter` — so it's exercised by CI on every push, not just documented. One canned backend response per call the kit makes is enough for a stubbed adapter: the repeat-key example is answered from the in-process dedup table without a second HTTP call, and the PAN example is rejected inside `Dispatcher` before `complete_checkout` runs. Include `Portage::Ucp::Support::Idempotency` in your adapter (as every bundled one does) and the dedup example checks the table itself rather than just comparing the two calls' output — output equality alone is satisfied by any fixed-response test double, deduped or not.
238
+
239
+ ## Checking any store
240
+
241
+ `portage-ucp-check` is a small CLI, shipped with the core gem, for the question this whole README has been building toward: "does this store need portage-ucp at all, and if so, which adapter?"
242
+
243
+ ```bash
244
+ bundle exec portage-ucp-check your-shop.example
245
+ ```
246
+
247
+ It tries the cheap answer first — `GET /.well-known/ucp` on the URL you gave it. If that's already there (a Shopify store with the native Universal Commerce Agent app, say), it prints the manifest as-is and stops; no adapter needed.
248
+
249
+ If there's no native manifest, it looks at the homepage for platform tells (Shopify's `cdn.shopify.com`, WooCommerce's plugin path, BigCommerce's CDN host, etc.) and names the matching `portage-ucp-<adapter>` gem. When that adapter's env vars (the same ones its `exe/` reads — see [Requirements](#requirements) below) are already set, it goes one step further: requires the gem, builds a real `Client`/`Adapter`, and calls `search_catalog` against the live store, so the recommendation is a confirmed-working adapter rather than a guess from string-matching.
250
+
251
+ ```json
252
+ {
253
+ "url": "https://your-shop.example",
254
+ "native_ucp": null,
255
+ "platform": "WooCommerce",
256
+ "recommended_gem": "portage-ucp-woocommerce",
257
+ "live_probe": {
258
+ "status": "skipped",
259
+ "reason": "missing env vars: WOOCOMMERCE_SITE_URL, WOOCOMMERCE_CONSUMER_KEY, WOOCOMMERCE_CONSUMER_SECRET"
260
+ }
261
+ }
110
262
  ```
111
263
 
264
+ `live_probe.status` is one of `ok` (adapter built and fetched a real product), `skipped` (env vars absent, or the adapter gem isn't installed), or `error` (adapter built but the live call failed — bad credentials, wrong store, etc.). Exits `0` when it found something usable — a native manifest or a working live probe — `1` otherwise, so it's scriptable in CI ("does this store already speak UCP, yes or no").
265
+
266
+ ## Spec conformance
267
+
268
+ `Portage::Ucp::SchemaValidator` validates data against UCP's own vendored JSON Schemas/OpenRPC docs offline (no network calls, no reliance on ucpchecker.com as a CI gate) — useful in your own test suite if you want to assert your adapter's output is schema-conformant beyond what `to_wire_h` already guarantees.
269
+
270
+ ## Requirements
271
+
272
+ - Ruby >= 3.2
273
+ - `mcp` gem `~> 0.24` (pulled in by `portage-ucp`)
274
+
275
+ Each adapter gem needs its backend's own credentials, read from env by its executable. Its README has the full detail on obtaining them and on any capability it can't back for real:
276
+
277
+ | Gem | Executable | Required env vars |
278
+ |---|---|---|
279
+ | [`portage-ucp-shopify`](portage-ucp-shopify/) | `portage-ucp-shopify` | `SHOPIFY_SHOP_DOMAIN`, `SHOPIFY_ADMIN_ACCESS_TOKEN` and/or `SHOPIFY_STOREFRONT_ACCESS_TOKEN` — each capability family works independently if you only have one |
280
+ | [`portage-ucp-wix`](portage-ucp-wix/) | `portage-ucp-wix` | `WIX_ACCESS_TOKEN` (site-scoped, exchanged from an app client_id/client_secret plus the site's instance_id) |
281
+ | [`portage-ucp-woocommerce`](portage-ucp-woocommerce/) | `portage-ucp-woocommerce` | `WOOCOMMERCE_SITE_URL`, `WOOCOMMERCE_CONSUMER_KEY`, `WOOCOMMERCE_CONSUMER_SECRET`; optional `WOOCOMMERCE_CURRENCY` (default `USD`), `WOOCOMMERCE_PAYMENT_METHOD` (for `complete_checkout`) |
282
+ | [`portage-ucp-bigcommerce`](portage-ucp-bigcommerce/) | `portage-ucp-bigcommerce` | `BIGCOMMERCE_STORE_HASH`, `BIGCOMMERCE_CLIENT_ID`, `BIGCOMMERCE_ACCESS_TOKEN`, `BIGCOMMERCE_SITE_URL`; optional `BIGCOMMERCE_CURRENCY` (default `USD`), `BIGCOMMERCE_PAYMENT_GATEWAY_ID` (for `complete_checkout`) |
283
+ | [`portage-ucp-magento`](portage-ucp-magento/) | `portage-ucp-magento` | `MAGENTO_BASE_URL`, `MAGENTO_ADMIN_TOKEN`; optional `MAGENTO_CURRENCY` (default `USD`), `MAGENTO_SITE_URL`, `MAGENTO_PAYMENT_METHOD`/`MAGENTO_DEFAULT_ADDRESS` (a JSON object, for `complete_checkout`) |
284
+ | [`portage-ucp-etsy`](portage-ucp-etsy/) | `portage-ucp-etsy` | An Etsy OAuth access_token (shop-owner consent) plus your app's `x-api-key` — catalog/order only, checkout is redirect-link |
285
+ | [`portage-ucp-instagram`](portage-ucp-instagram/) | `portage-ucp-instagram` | A Meta Graph API long-lived access token plus your Commerce Catalog id — catalog only, checkout is redirect-link |
286
+
112
287
  ## Development
113
288
 
289
+ Each gem manages its own tests/lint independently — its own `Gemfile`/`Gemfile.lock`, own `spec/`, no shared state or run-order dependency between gems:
290
+
114
291
  ```bash
115
- bundle exec rspec # tests (WebMock-stubbed, no live store needed)
116
- bundle exec rubocop # lint
292
+ cd portage-ucp && bundle exec rspec && bundle exec rubocop
293
+ ```
294
+
295
+ Or run the full suite across every gem in one command, via the root `Rakefile` — it just shells into each gem dir in turn and stops at the first failure, no root-level bundle or cross-gem dependency involved:
117
296
 
118
- # verify a real store's client_id/access_token by listing one product
119
- BIGCOMMERCE_STORE_HASH=abc123 \
120
- BIGCOMMERCE_CLIENT_ID=... BIGCOMMERCE_ACCESS_TOKEN=... \
121
- bundle exec rake bigcommerce_smoke_test
297
+ ```bash
298
+ rake spec # == rake, spec is the default task
122
299
  ```
123
300
 
301
+ See the [design log](docs/design-log.md) for the rationale and decision history behind this project.
302
+
303
+ ## Contributing
304
+
305
+ Bug reports and pull requests welcome at [tomtom87/Portage](https://github.com/tomtom87/Portage). Since this is pre-`1.0` and still spec-tracking, open an issue to discuss any change bigger than a bugfix before sending a PR — the capability/adapter contract is still settling.
306
+
307
+ Run `rake spec` for the gem(s) you touched before opening a PR — CI (`.github/workflows/ci.yml`) runs `rspec`/`rubocop` for every gem on push and PR too, but running it locally first is faster than waiting on the matrix.
308
+
309
+ A new platform adapter is the most welcome kind of PR: subclass `Portage::Ucp::Adapter` in `portage-ucp` against the target platform's API, following the shape of an existing adapter gem (`portage-ucp-shopify` is the most complete reference), and add it to the gem table above.
310
+
124
311
  ## License
125
312
 
126
313
  [MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp-bigcommerce
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-15 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: portage-ucp
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.6'
19
+ version: '0.7'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.6'
26
+ version: '0.7'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rspec
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -84,21 +84,13 @@ description: Implements Portage::Ucp::Adapter against a BigCommerce store's v3 C
84
84
  APIs and v2 Orders API. Generic only — no merchant-specific business logic. Plain
85
85
  Net::HTTP, no bigcommerce/api runtime dependency.
86
86
  email:
87
- executables:
88
- - portage-ucp-bigcommerce
87
+ executables: []
89
88
  extensions: []
90
89
  extra_rdoc_files: []
91
90
  files:
92
91
  - CHANGELOG.md
93
92
  - LICENSE
94
93
  - README.md
95
- - exe/portage-ucp-bigcommerce
96
- - lib/portage/ucp/bigcommerce.rb
97
- - lib/portage/ucp/bigcommerce/adapter.rb
98
- - lib/portage/ucp/bigcommerce/client.rb
99
- - lib/portage/ucp/bigcommerce/errors.rb
100
- - lib/portage/ucp/bigcommerce/mapper.rb
101
- - lib/portage/ucp/bigcommerce/version.rb
102
94
  homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-bigcommerce
103
95
  licenses:
104
96
  - MIT
@@ -1,31 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Standalone MCP server over stdio. Configure the store 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
- # BIGCOMMERCE_PAYMENT_GATEWAY_ID is optional — only needed to call
12
- # #complete_checkout.
13
-
14
- require "portage/ucp"
15
- require "portage/ucp/bigcommerce"
16
-
17
- require ENV["PORTAGE_UCP_CONFIG"] if ENV["PORTAGE_UCP_CONFIG"]
18
-
19
- client = Portage::Ucp::BigCommerce::Client.new(
20
- store_hash: ENV.fetch("BIGCOMMERCE_STORE_HASH"),
21
- client_id: ENV.fetch("BIGCOMMERCE_CLIENT_ID"),
22
- access_token: ENV.fetch("BIGCOMMERCE_ACCESS_TOKEN")
23
- )
24
- adapter = Portage::Ucp::BigCommerce::Adapter.new(
25
- client: client,
26
- site_url: ENV.fetch("BIGCOMMERCE_SITE_URL"),
27
- currency: ENV.fetch("BIGCOMMERCE_CURRENCY", "USD"),
28
- payment_gateway_id: ENV.fetch("BIGCOMMERCE_PAYMENT_GATEWAY_ID", nil)
29
- )
30
-
31
- Portage::Ucp::Mcp::Server.build(adapter: adapter).start
@@ -1,228 +0,0 @@
1
- require "uri"
2
-
3
- module Portage
4
- module Ucp
5
- module BigCommerce
6
- # Generic Portage::Ucp::Adapter over a BigCommerce store's v3 Catalog/
7
- # Carts/Checkouts APIs and v2 Orders API — no merchant-specific
8
- # business logic, same posture as Portage::Ucp::Shopify::Adapter and
9
- # Portage::Ucp::WooCommerce::Adapter.
10
- #
11
- # Deliberately doesn't override `link_identity`: BigCommerce's storefront
12
- # customer login (JWT-based Customer Login API) is a separate concern
13
- # from the store-owner API account this gem uses, so this generic
14
- # adapter leaves it unimplemented — Capability#advertised_for? simply
15
- # won't advertise dev.ucp.shopping.identity for this adapter, not a 500.
16
- #
17
- # Catalog and Order are read through the Admin API (v3 and v2
18
- # respectively — Orders has never moved off v2). Cart and Checkout are
19
- # read/written through the v3 Carts/Checkouts APIs: unlike Shopify/
20
- # WooCommerce, BigCommerce's Checkout *is* a distinct resource from its
21
- # Cart, but shares the same id — creating a Cart implicitly creates its
22
- # matching Checkout.
23
- #
24
- # IMPORTANT CAVEAT: #complete_checkout creates an order from the
25
- # checkout, mints a single-order Payment Access Token, then submits
26
- # `payment_token` as a tokenized instrument to the separate Payments
27
- # API (`payments.bigcommerce.com`). The exact `payment_instrument`
28
- # shape a given payment gateway expects hasn't been confirmed against
29
- # a live store with a real gateway installed — same "needs confirming
30
- # against a live store" posture as Portage::Ucp::Shopify::Adapter and
31
- # Portage::Ucp::WooCommerce::Adapter's own payment steps.
32
- class Adapter < Portage::Ucp::Adapter
33
- # Neither the Carts nor the Checkouts API takes an idempotency key
34
- # natively, so §9a dedup comes from Support::Idempotency's in-process
35
- # table.
36
- include Portage::Ucp::Support::Idempotency
37
- # BigCommerce's Checkout has no native status field of its own, and
38
- # v2 Orders don't link back to the Checkout that produced them —
39
- # both are tracked adapter-side via Support::CheckoutState, keyed by
40
- # the shared cart/checkout id.
41
- include Portage::Ucp::Support::CheckoutState
42
- # The v3 and v2 APIs both answer a missing resource 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_gateway_id: nil)
47
- super()
48
- @client = client
49
- @site_url = site_url.chomp("/")
50
- @currency = currency
51
- @payment_gateway_id = payment_gateway_id
52
- end
53
-
54
- def search_catalog(query:, limit:)
55
- path = "/catalog/products?keyword=#{URI.encode_www_form_component(query)}&limit=#{limit}&include=variants"
56
- products = @client.v3_get(path)["data"].map do |node|
57
- Mapper.product(node, currency: @currency, site_url: @site_url)
58
- end
59
- Portage::Ucp::CatalogSearchResult.new(products: products)
60
- end
61
-
62
- def get_product(product_id:)
63
- nil_on_not_found do
64
- node = @client.v3_get("/catalog/products/#{product_id}?include=variants")["data"]
65
- node && Portage::Ucp::ProductDetail.new(product: Mapper.product(node, currency: @currency,
66
- site_url: @site_url))
67
- end
68
- end
69
-
70
- def get_cart(cart_id:)
71
- nil_on_not_found do
72
- node = fetch_cart_node(cart_id)
73
- node && Mapper.cart(node, id: cart_id)
74
- end
75
- end
76
-
77
- def create_cart(line_items:, idempotency_key:)
78
- dedup(idempotency_key) do
79
- node = @client.v3_post("/carts", { line_items: cart_lines(line_items) })["data"]
80
- Mapper.cart(node, id: node["id"])
81
- end
82
- end
83
-
84
- # Full replacement, matching UCP's real cart semantics: the Carts API
85
- # has no atomic "replace all lines" endpoint either. New lines are
86
- # added *before* the old ones are removed — BigCommerce auto-deletes
87
- # the whole cart when its last remaining line item is removed, so
88
- # removing old lines first (when the desired list is non-empty)
89
- # would destroy the cart this call is supposed to be updating.
90
- def update_cart(cart_id:, line_items:, idempotency_key:)
91
- dedup(idempotency_key) { Mapper.cart(replace_cart_lines(cart_id, line_items), id: cart_id) }
92
- end
93
-
94
- # Unlike Shopify (no cancellation mutation at all) and WooCommerce
95
- # (only a session cart to clear), BigCommerce's Carts API supports
96
- # deleting the cart resource outright — the closest real equivalent
97
- # to cancellation available on any of the three. The returned Cart
98
- # reflects that the resource is now gone (empty line_items, no
99
- # currency) rather than raising: schema-valid, just not information-
100
- # complete, same posture as
101
- # Portage::Ucp::Shopify::Adapter#link_cart_to_order's best-effort miss.
102
- def cancel_cart(cart_id:, idempotency_key:)
103
- dedup(idempotency_key) do
104
- delete_cart(cart_id)
105
- Mapper.cart({}, id: cart_id)
106
- end
107
- end
108
-
109
- def create_checkout(line_items:, idempotency_key:)
110
- dedup(idempotency_key) do
111
- cart_node = @client.v3_post("/carts", { line_items: cart_lines(line_items) })["data"]
112
- record_checkout_status(cart_node["id"], "incomplete")
113
- Mapper.checkout(fetch_checkout_node(cart_node["id"]), id: cart_node["id"], status: "incomplete")
114
- end
115
- end
116
-
117
- def get_checkout(checkout_id:)
118
- nil_on_not_found do
119
- node = fetch_checkout_node(checkout_id)
120
- node && Mapper.checkout(node, id: checkout_id, status: checkout_status(checkout_id))
121
- end
122
- end
123
-
124
- # Full replacement, same rationale as #update_cart — BigCommerce's
125
- # Checkout shares its underlying cart with #update_cart.
126
- def update_checkout(checkout_id:, line_items:, idempotency_key:)
127
- dedup(idempotency_key) do
128
- replace_cart_lines(checkout_id, line_items)
129
- record_checkout_status(checkout_id, "incomplete")
130
- Mapper.checkout(fetch_checkout_node(checkout_id), id: checkout_id, status: "incomplete")
131
- end
132
- end
133
-
134
- def complete_checkout(checkout_id:, payment_token:, idempotency_key:)
135
- dedup(idempotency_key) { submit_checkout(checkout_id, payment_token) }
136
- end
137
-
138
- # The Checkouts API has no cancellation endpoint for an in-progress
139
- # checkout either (see #cancel_cart's contrast) — this just marks the
140
- # tracked status canceled without touching the underlying cart.
141
- def cancel_checkout(checkout_id:, idempotency_key:)
142
- dedup(idempotency_key) do
143
- record_checkout_status(checkout_id, "canceled")
144
- Mapper.checkout(fetch_checkout_node(checkout_id), id: checkout_id, status: "canceled")
145
- end
146
- end
147
-
148
- def get_order(order_id:)
149
- nil_on_not_found do
150
- node = @client.v2_get("/orders/#{order_id}")
151
- next nil unless node["id"]
152
-
153
- products = @client.v2_get("/orders/#{order_id}/products")
154
- Mapper.order(node, products: products, site_url: @site_url,
155
- checkout_id: checkout_id_for(order_id))
156
- end
157
- end
158
-
159
- private
160
-
161
- def fetch_cart_node(cart_id)
162
- include_param = "line_items.physical_items.options,line_items.digital_items.options"
163
- @client.v3_get("/carts/#{cart_id}?include=#{include_param}")["data"]
164
- end
165
-
166
- def fetch_checkout_node(checkout_id)
167
- @client.v3_get("/checkouts/#{checkout_id}")["data"]
168
- end
169
-
170
- def delete_cart(cart_id)
171
- nil_on_not_found { @client.v3_delete("/carts/#{cart_id}") }
172
- end
173
-
174
- # Maps a UCP line item request straight to `{product_id, quantity}`.
175
- # A real variant-level add needs `{product_id, variant_id}` together
176
- # — this generic adapter, same as
177
- # Portage::Ucp::WooCommerce::Adapter#cart_lines, assumes the given id
178
- # already identifies the exact sellable unit and doesn't attempt to
179
- # split it back into a product/variant pair.
180
- def cart_lines(line_items)
181
- line_items.map { |li| { product_id: li[:product_id].to_i, quantity: li[:quantity] } }
182
- end
183
-
184
- def cart_item_ids(node)
185
- ((node.dig("line_items", "physical_items") || []) + (node.dig("line_items", "digital_items") || []))
186
- .map { |item| item["id"] }
187
- end
188
-
189
- def replace_cart_lines(cart_id, line_items)
190
- current_item_ids = cart_item_ids(fetch_cart_node(cart_id))
191
- @client.v3_post("/carts/#{cart_id}/items", { line_items: cart_lines(line_items) }) unless line_items.empty?
192
- current_item_ids.each { |item_id| @client.v3_delete("/carts/#{cart_id}/items/#{item_id}") }
193
- return {} if line_items.empty?
194
-
195
- fetch_cart_node(cart_id)
196
- end
197
-
198
- # See the class-level CAVEAT: `payment_instrument` uses a single
199
- # configurable gateway id (`payment_gateway_id:`) rather than a
200
- # confirmed gateway-specific instrument shape.
201
- def submit_checkout(checkout_id, payment_token)
202
- unless @payment_gateway_id
203
- raise Portage::Ucp::BigCommerce::Error,
204
- "no payment_gateway_id configured on this Adapter"
205
- end
206
-
207
- checkout_node = fetch_checkout_node(checkout_id)
208
- order_id = @client.v3_post("/checkouts/#{checkout_id}/orders", {}).dig("data", "id")
209
- access_token = @client.v3_post("/payments/access_tokens", { order: { id: order_id } }).dig("data", "id")
210
- @client.process_payment(payment_access_token: access_token, order_id: order_id,
211
- payment_instrument: { type: "tokenized_instrument", token: payment_token,
212
- gateway: @payment_gateway_id })
213
-
214
- record_checkout_status(checkout_id, "completed")
215
- record_order_checkout(order_id, checkout_id)
216
- Mapper.checkout(checkout_node, id: checkout_id, status: "completed", order: order_confirmation(order_id))
217
- end
218
-
219
- # Same order-status URL pattern as Mapper.order's `permalink_url`.
220
- def order_confirmation(order_id)
221
- Portage::Ucp::OrderConfirmation.new(
222
- id: order_id.to_s, permalink_url: "#{@site_url}/account.php?action=order_status&order_id=#{order_id}"
223
- )
224
- end
225
- end
226
- end
227
- end
228
- end
@@ -1,89 +0,0 @@
1
- require "net/http"
2
- require "json"
3
-
4
- module Portage
5
- module Ucp
6
- module BigCommerce
7
- # Minimal REST client over a BigCommerce store's Admin APIs.
8
- #
9
- # Deliberately plain Net::HTTP, not the `bigcommerce_api` gem — a
10
- # generic adapter that any Ruby app can drop in only needs a store hash
11
- # and a pair of static API account credentials, no framework coupling,
12
- # trivially stubbable with WebMock.
13
- #
14
- # Unlike Shopify/Wix, there's no Portage::Ucp::BigCommerce::AccessTokenFetcher:
15
- # a store-owner-created API account (Settings → API → Create API Account)
16
- # hands back a client_id/access_token pair with no expiry, same static-
17
- # credential posture as Portage::Ucp::WooCommerce::Client. The separate
18
- # OAuth authorization-code flow BigCommerce uses for public Marketplace
19
- # apps is a different concern, out of scope here.
20
- #
21
- # BigCommerce splits its Admin surface across two API generations under
22
- # the same host: catalog/carts/checkouts live on v3, Orders is still v2
23
- # only — both are exposed here rather than picking one.
24
- #
25
- # Completing a checkout also requires the separate Payments API, which
26
- # lives on a *different* host (`payments.bigcommerce.com`) and is
27
- # authorized with a short-lived payment access token rather than the
28
- # store's own API credentials — see #process_payment.
29
- class Client
30
- include Portage::Ucp::Support::HttpClient
31
-
32
- def initialize(store_hash:, client_id:, access_token:)
33
- @store_hash = store_hash
34
- @client_id = client_id
35
- @access_token = access_token
36
- end
37
-
38
- def v2_get(path)
39
- admin_request(Net::HTTP::Get, "v2", path)
40
- end
41
-
42
- def v2_post(path, body = {})
43
- admin_request(Net::HTTP::Post, "v2", path, body)
44
- end
45
-
46
- def v3_get(path)
47
- admin_request(Net::HTTP::Get, "v3", path)
48
- end
49
-
50
- def v3_post(path, body = {})
51
- admin_request(Net::HTTP::Post, "v3", path, body)
52
- end
53
-
54
- def v3_put(path, body = {})
55
- admin_request(Net::HTTP::Put, "v3", path, body)
56
- end
57
-
58
- def v3_delete(path)
59
- admin_request(Net::HTTP::Delete, "v3", path)
60
- end
61
-
62
- # Authorized with the payment access token minted by
63
- # POST /v3/payments/access_tokens (see Adapter#submit_checkout), not
64
- # the store's own client_id/access_token — BigCommerce scopes a
65
- # payment token to a single order for a single completion attempt.
66
- def process_payment(payment_access_token:, order_id:, payment_instrument:)
67
- json_request(
68
- Net::HTTP::Post, "https://payments.bigcommerce.com/stores/#{@store_hash}/payments",
69
- body: { payment: { instrument: payment_instrument }, order: { id: order_id } },
70
- headers: { "Authorization" => payment_access_token,
71
- "Content-Type" => "application/vnd.bc.payments.v1+json",
72
- "Accept" => "application/vnd.bc.payments.v1+json" }
73
- )
74
- end
75
-
76
- private
77
-
78
- def admin_request(http_method, version, path, body = nil)
79
- json_request(http_method, "https://api.bigcommerce.com/stores/#{@store_hash}/#{version}#{path}",
80
- body: body,
81
- headers: { "X-Auth-Client" => @client_id, "X-Auth-Token" => @access_token,
82
- "Accept" => "application/json" })
83
- end
84
-
85
- def api_error_class = Portage::Ucp::BigCommerce::ApiError
86
- end
87
- end
88
- end
89
- end
@@ -1,23 +0,0 @@
1
- module Portage
2
- module Ucp
3
- module BigCommerce
4
- class Error < StandardError; end
5
-
6
- # Raised for any non-2xx response from the Admin API (v2 Orders, v3
7
- # Catalog/Carts/Checkouts) or the Payments API. BigCommerce's error body
8
- # shape isn't uniform across API generations — v3 responses carry a
9
- # `title`, v2 responses (and some v3 validation failures) carry an
10
- # `errors` array/hash instead — so this tries both rather than assuming
11
- # one.
12
- class ApiError < Error
13
- include Portage::Ucp::Support::ApiError
14
-
15
- private
16
-
17
- def detail(body)
18
- body["title"] || body["errors"] || body
19
- end
20
- end
21
- end
22
- end
23
- end
@@ -1,191 +0,0 @@
1
- module Portage
2
- module Ucp
3
- module BigCommerce
4
- # Converts BigCommerce Admin API response bodies (v3 Catalog/Carts/
5
- # Checkouts, v2 Orders) into the protocol-layer value objects from
6
- # Portage::Ucp::ValueObjects — nothing BigCommerce-shaped is allowed to
7
- # leak past this file.
8
- #
9
- # `currency` is threaded in by the caller everywhere it's needed: the
10
- # v3 Catalog product resource doesn't carry a currency field at all
11
- # (it's a store-wide default currency setting), unlike Cart/Checkout
12
- # responses, which carry their own `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
- def description(node)
25
- Portage::Ucp::Description.new(plain: node["description"])
26
- end
27
-
28
- def price(amount, currency)
29
- Portage::Ucp::Price.new(amount: minor_units(amount), currency: currency)
30
- end
31
-
32
- def price_range(amount, currency)
33
- p = price(amount, currency)
34
- Portage::Ucp::PriceRange.new(min: p, max: p)
35
- end
36
-
37
- # `site_url` is only used to turn `custom_url.url` (always store-
38
- # relative, e.g. "/cold-brew/") into an absolute product URL — the v3
39
- # Catalog resource never returns an absolute one itself.
40
- def product(node, currency:, site_url:)
41
- Portage::Ucp::Product.new(
42
- id: node["id"].to_s,
43
- title: node["name"],
44
- description: description(node),
45
- price_range: price_range(node["price"], currency),
46
- variants: variants(node, currency),
47
- url: node.dig("custom_url", "url") ? "#{site_url}#{node.dig('custom_url', 'url')}" : nil
48
- )
49
- end
50
-
51
- # A product with no configured variants has no real Catalog
52
- # "variants" entries at all — it's its own single implicit variant,
53
- # same as Portage::Ucp::WooCommerce::Mapper.variants for a simple product.
54
- def variants(node, currency)
55
- nodes = node["variants"]
56
- unless nodes&.any?
57
- return [Portage::Ucp::Variant.new(id: node["id"].to_s, title: node["name"], description: description(node),
58
- price: price(node["price"], currency),
59
- availability: { "available" => node["availability"] != "disabled" })]
60
- end
61
-
62
- nodes.map { |v| variant(v, node, currency) }
63
- end
64
-
65
- # A variant's own `price` is 0/null when it doesn't override the
66
- # base product price — falls back to the parent product's price in
67
- # that case, same "inherits unless overridden" rule BigCommerce
68
- # documents for variant pricing.
69
- def variant(node, parent_node, currency)
70
- title = (node["option_values"] || []).map { |ov| ov["label"] }.join(" / ")
71
- amount = node["price"].to_f.zero? ? parent_node["price"] : node["price"]
72
- Portage::Ucp::Variant.new(
73
- id: node["id"].to_s, title: title, description: description(parent_node), price: price(amount, currency),
74
- availability: { "available" => !node["purchasing_disabled"] }
75
- )
76
- end
77
-
78
- # `id:` is caller-supplied rather than read off the response body's
79
- # own top-level id in every call site: a v3 Cart's `id` field is a
80
- # real, stable resource id (unlike WooCommerce's headerless Cart-
81
- # Token), but callers pass it explicitly anyway so Adapter can reuse
82
- # this same method for both the cart-creation response (id known
83
- # from the body) and a checkout's embedded `cart` sub-object (no
84
- # top-level id of its own).
85
- def cart(node, id:)
86
- currency = node.dig("currency", "code")
87
- items = (node.dig("line_items", "physical_items") || []) + (node.dig("line_items", "digital_items") || [])
88
- Portage::Ucp::Cart.new(
89
- id: id,
90
- line_items: items.map { |n| cart_line_item(n) },
91
- currency: currency,
92
- totals: Portage::Ucp::Support::Totals.summary(subtotal: minor_units(node["base_amount"]),
93
- total: minor_units(node["cart_amount"]))
94
- )
95
- end
96
-
97
- # `status` isn't a v3 Checkout field either — BigCommerce's Checkout
98
- # is the cart plus billing/consignment/order-linkage data, not a
99
- # resource with its own lifecycle enum, so the Adapter tracks status
100
- # itself and passes it in, same rationale as
101
- # Portage::Ucp::Shopify::Mapper.checkout and Portage::Ucp::WooCommerce::Mapper.checkout.
102
- def checkout(node, id:, status:, order: nil)
103
- cart_node = node["cart"] || node
104
-
105
- Portage::Ucp::Checkout.new(
106
- id: id,
107
- status: status,
108
- line_items: cart(cart_node, id: id).line_items,
109
- currency: cart_node.dig("currency", "code"),
110
- totals: checkout_totals(node, cart_node),
111
- links: [],
112
- order: order
113
- )
114
- end
115
-
116
- def checkout_totals(node, cart_node)
117
- Portage::Ucp::Support::Totals.summary(
118
- subtotal: minor_units(node["subtotal"] || cart_node["base_amount"]),
119
- tax: minor_units(node["tax_total"]),
120
- total: minor_units(node["grand_total"] || cart_node["cart_amount"])
121
- )
122
- end
123
-
124
- # CAVEAT: assumes physical/digital cart line items carry plain
125
- # decimal price fields (`sale_price`, `extended_sale_price`), the
126
- # shape BigCommerce's own docs show — not confirmed against a live
127
- # cart response, which some BigCommerce API surfaces instead nest
128
- # under a `{value:, currency:}` sub-object.
129
- def cart_line_item(node)
130
- unit_price = minor_units(node["sale_price"])
131
- line_total = minor_units(node["extended_sale_price"])
132
- Portage::Ucp::LineItem.new(
133
- id: node["id"],
134
- item: Portage::Ucp::Item.new(id: (node["variant_id"] || node["product_id"]).to_s, title: node["name"],
135
- price: unit_price, image_url: node["image_url"]),
136
- quantity: node["quantity"],
137
- totals: Portage::Ucp::Support::Totals.line(minor_units(node["extended_list_price"]), line_total)
138
- )
139
- end
140
-
141
- # BigCommerce's fixed, store-independent order status_id enum — see
142
- # https://developer.bigcommerce.com/docs/rest-management/order-statuses.
143
- # Core has no per-line-item fulfillment tracking (that's a shipping-
144
- # app concern), so every order line is given the same coarse status
145
- # derived from the order's own top-level status, same posture as
146
- # Portage::Ucp::WooCommerce::Mapper::ORDER_LINE_ITEM_STATUS.
147
- ORDER_STATUS = {
148
- 2 => "fulfilled", 8 => "fulfilled", 10 => "fulfilled",
149
- 4 => "removed", 5 => "removed", 6 => "removed", 14 => "removed"
150
- }.freeze
151
-
152
- # `permalink_url` is built from BigCommerce's documented storefront
153
- # order-status route — not confirmed against a live storefront theme,
154
- # same "needs confirming" posture as
155
- # Portage::Ucp::WooCommerce::Mapper.order's order-received URL.
156
- #
157
- # `products` is the separate v2 `/orders/{id}/products` response —
158
- # unlike WooCommerce/Shopify, a v2 Order's own resource doesn't embed
159
- # its line items, so the Adapter fetches and merges them in.
160
- def order(node, products:, site_url:, checkout_id: "")
161
- currency = node["currency_code"]
162
- status = Portage::Ucp::Support::LineItemStatus.from_table(ORDER_STATUS, node["status_id"])
163
- Portage::Ucp::Order.new(
164
- id: node["id"].to_s,
165
- checkout_id: checkout_id,
166
- permalink_url: "#{site_url}/account.php?action=order_status&order_id=#{node['id']}",
167
- line_items: products.map { |n| order_line_item(n, status) },
168
- fulfillment: Portage::Ucp::Fulfillment.new,
169
- currency: currency,
170
- totals: Portage::Ucp::Support::Totals.summary(subtotal: minor_units(node["subtotal_ex_tax"]),
171
- total: minor_units(node["total_inc_tax"]))
172
- )
173
- end
174
-
175
- def order_line_item(node, status)
176
- quantity = node["quantity"]
177
- fulfilled = Portage::Ucp::Support::LineItemStatus.fulfilled_quantity(status, quantity)
178
- Portage::Ucp::OrderLineItem.new(
179
- id: node["id"].to_s,
180
- item: Portage::Ucp::Item.new(id: node["product_id"].to_s, title: node["name"],
181
- price: minor_units(node["price_ex_tax"])),
182
- quantity: { original: quantity, total: quantity, fulfilled: fulfilled },
183
- totals: Portage::Ucp::Support::Totals.line(minor_units(node["total_ex_tax"]),
184
- minor_units(node["total_inc_tax"])),
185
- status: status
186
- )
187
- end
188
- end
189
- end
190
- end
191
- end
@@ -1,7 +0,0 @@
1
- module Portage
2
- module Ucp
3
- module BigCommerce
4
- VERSION = "0.1.1".freeze
5
- end
6
- end
7
- end
@@ -1,6 +0,0 @@
1
- require "portage/ucp"
2
- require_relative "bigcommerce/version"
3
- require_relative "bigcommerce/errors"
4
- require_relative "bigcommerce/client"
5
- require_relative "bigcommerce/mapper"
6
- require_relative "bigcommerce/adapter"