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 +4 -4
- data/CHANGELOG.md +201 -24
- data/README.md +266 -79
- metadata +5 -13
- data/exe/portage-ucp-bigcommerce +0 -31
- data/lib/portage/ucp/bigcommerce/adapter.rb +0 -228
- data/lib/portage/ucp/bigcommerce/client.rb +0 -89
- data/lib/portage/ucp/bigcommerce/errors.rb +0 -23
- data/lib/portage/ucp/bigcommerce/mapper.rb +0 -191
- data/lib/portage/ucp/bigcommerce/version.rb +0 -7
- data/lib/portage/ucp/bigcommerce.rb +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4f9361bb29e57f06919220ed2a975c5c36ab6a1777cb0e664dafb9b67c45587
|
|
4
|
+
data.tar.gz: a0af7c70e6319a3e153ab7e978bad7b19de828c0c764b0376f993df256aecb3e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d191617b9d74d7d1026b6dfa9999d17dae90dae35ea745e20ba96801a973e787b38a51b7bae03c7fa5689aaf42206e3e94466d92578b7957ecaf76b78113c67
|
|
7
|
+
data.tar.gz: df8a085c93246e691314ba9c0eef253f6f8c2232bc15a27bdef112b28096a16830c7c9156f2704f516ecaa7dee98d106b615b854f27e9536ad960e97d00c7301
|
data/CHANGELOG.md
CHANGED
|
@@ -1,26 +1,203 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
#
|
|
1
|
+
# Portage::Ucp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://rubygems.org/gems/portage-ucp)
|
|
4
|
+

|
|
5
|
+

|
|
4
6
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
> **Status**: `0.7.0`. APIs may still shift before `1.0` — see the [design log](docs/design-log.md).
|
|
20
12
|
|
|
21
|
-
|
|
13
|
+
## Contents
|
|
22
14
|
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
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
|
|
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
|
-
##
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
95
|
+
tools/call get_product { product_id: "gid://shopify/Product/1" }
|
|
96
|
+
→ variant gid://shopify/ProductVariant/11, "158cm", available
|
|
90
97
|
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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
|
data/exe/portage-ucp-bigcommerce
DELETED
|
@@ -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
|