portage-ucp-bigcommerce 0.1.2 → 0.1.3
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 +38 -201
- data/README.md +79 -266
- data/exe/portage-ucp-bigcommerce +31 -0
- data/lib/portage/ucp/bigcommerce/adapter.rb +228 -0
- data/lib/portage/ucp/bigcommerce/client.rb +89 -0
- data/lib/portage/ucp/bigcommerce/errors.rb +23 -0
- data/lib/portage/ucp/bigcommerce/mapper.rb +191 -0
- data/lib/portage/ucp/bigcommerce/version.rb +7 -0
- data/lib/portage/ucp/bigcommerce.rb +6 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eadb03a0589f956a4fd36b90d33841003b1c9c5d10af4a6baa4bb3912ca030fe
|
|
4
|
+
data.tar.gz: 50bd222cc936419a1f822df3efee63080664738373b8856beeac52230d01f35a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 756182c95291f1df29a71d5e5d6fe1b9c49d9b34ac86afd5784329d8e02a49466ef03ae29866fa4b04e9721f07a0ce465115d61997bc09040c83c1bccd33eed4
|
|
7
|
+
data.tar.gz: 9e4ca1adeca42d0c660bb8da77b896c5073950f449592466543cad8c6d570f21c0831b9236a7e7545c74ad84858a848c51fdb7bf5b26302d533a82f8689d1520
|
data/CHANGELOG.md
CHANGED
|
@@ -1,203 +1,40 @@
|
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
`
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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.3] - 2026-09-16
|
|
8
|
+
|
|
9
|
+
- No behavior change — 0.1.2 was built and pushed with `gem build` run from
|
|
10
|
+
the workspace root instead of this gem's own directory, so `spec.files =
|
|
11
|
+
Dir[...]` resolved against the wrong working directory and packaged an
|
|
12
|
+
empty gem. 0.1.2 has been yanked; 0.1.3 repackages the exact same 0.1.2
|
|
13
|
+
code correctly.
|
|
14
|
+
|
|
15
|
+
## [0.1.2] - 2026-09-16
|
|
16
|
+
|
|
17
|
+
- No behavior change — widens the `portage-ucp` dependency pin to `~> 0.7`
|
|
18
|
+
so this gem can install alongside `portage-ucp` 0.7.0 (the pessimistic
|
|
19
|
+
`~> 0.6` pin published with 0.1.1 excludes it).
|
|
20
|
+
|
|
21
|
+
## [0.1.1] - 2026-09-15
|
|
22
|
+
|
|
23
|
+
- **Fix:** `Mapper.product`/`.variants` built `Portage::Ucp::Product`/`Variant`
|
|
24
|
+
with the pre-schema `price:`/`available:` keywords core dropped when
|
|
25
|
+
`dev.ucp.shopping.catalog` became schema-conformant — every real
|
|
26
|
+
`search_catalog`/`get_product` call raised `ArgumentError: missing keyword:
|
|
27
|
+
:price_range`. Now builds `Description`/`Price`/`PriceRange` objects and a
|
|
28
|
+
real `Variant` array, matching `Shopify`/`Wix`'s mappers.
|
|
29
|
+
- **Fix:** `Adapter#search_catalog` returned a bare `Array<Product>` instead
|
|
30
|
+
of the `CatalogSearchResult` the `Adapter` contract documents — its wire
|
|
31
|
+
form has no `products` wrapper, so it never validated against UCP's
|
|
32
|
+
`catalog_search.json` response schema.
|
|
33
|
+
- **Fix:** `Adapter#get_product` returned a bare `Product` instead of
|
|
34
|
+
`ProductDetail`, so its wire form had no `product` wrapper either, for the
|
|
35
|
+
same reason as `search_catalog` above.
|
|
36
|
+
|
|
37
|
+
## [0.1.0] - Unreleased
|
|
38
|
+
|
|
39
|
+
- Initial pre-release. BigCommerce adapter against the v3 Catalog/Carts/
|
|
40
|
+
Checkouts APIs and v2 Orders API.
|
data/README.md
CHANGED
|
@@ -1,313 +1,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# portage-ucp-bigcommerce
|
|
2
2
|
|
|
3
|
-
[
|
|
4
|
-

|
|
5
|
-

|
|
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.
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
## What it covers
|
|
8
6
|
|
|
9
|
-
|
|
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.
|
|
10
18
|
|
|
11
|
-
|
|
19
|
+
## ⚠️ Unverified against a live store
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
Built from BigCommerce's documented REST API shapes, not run against a real store yet. Before relying on this in production, confirm:
|
|
14
22
|
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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)
|
|
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.
|
|
27
27
|
|
|
28
28
|
## Installation
|
|
29
29
|
|
|
30
30
|
```ruby
|
|
31
31
|
# Gemfile
|
|
32
|
-
gem "portage-ucp"
|
|
33
|
-
gem "portage-ucp-shopify" # or another adapter gem, or your own Adapter subclass
|
|
32
|
+
gem "portage-ucp-bigcommerce"
|
|
34
33
|
```
|
|
35
34
|
|
|
36
35
|
```bash
|
|
37
36
|
bundle install
|
|
38
37
|
```
|
|
39
38
|
|
|
40
|
-
##
|
|
41
|
-
|
|
42
|
-
```ruby
|
|
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")
|
|
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
|
-
```
|
|
66
|
-
|
|
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`:
|
|
39
|
+
## Setup
|
|
70
40
|
|
|
71
|
-
|
|
72
|
-
bundle exec portage-ucp-shopify # stdio, reads SHOPIFY_SHOP_DOMAIN /
|
|
73
|
-
# SHOPIFY_ADMIN_ACCESS_TOKEN / SHOPIFY_STOREFRONT_ACCESS_TOKEN
|
|
74
|
-
```
|
|
75
|
-
|
|
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):
|
|
81
|
-
|
|
82
|
-
```bash
|
|
83
|
-
PORTAGE_UCP_CONFIG=./config/portage_ucp.rb bundle exec portage-ucp-shopify
|
|
84
|
-
```
|
|
85
|
-
|
|
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:
|
|
90
|
-
|
|
91
|
-
```
|
|
92
|
-
tools/call search_catalog { query: "snowboard", limit: 5 }
|
|
93
|
-
→ Powder Chaser 158cm, $549.00, gid://shopify/Product/1
|
|
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.
|
|
94
42
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
43
|
+
```ruby
|
|
44
|
+
require "portage/ucp/bigcommerce"
|
|
101
45
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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")
|
|
50
|
+
)
|
|
105
51
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
)
|
|
108
58
|
```
|
|
109
59
|
|
|
110
|
-
|
|
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.
|
|
139
|
-
|
|
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:
|
|
60
|
+
## Usage
|
|
162
61
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
174
73
|
|
|
175
|
-
|
|
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
|
+
)
|
|
176
84
|
|
|
177
|
-
|
|
178
|
-
|
|
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)
|
|
85
|
+
# Order
|
|
86
|
+
order = adapter.get_order(order_id: checkout_order_id) # only once linked post-completion
|
|
195
87
|
```
|
|
196
88
|
|
|
197
|
-
|
|
89
|
+
## Wiring into portage-ucp
|
|
198
90
|
|
|
199
|
-
|
|
91
|
+
Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
|
|
200
92
|
|
|
201
93
|
```ruby
|
|
202
|
-
|
|
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.
|
|
94
|
+
dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
|
|
215
95
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
+
)
|
|
233
101
|
```
|
|
234
102
|
|
|
235
|
-
|
|
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.
|
|
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.
|
|
238
104
|
|
|
239
|
-
##
|
|
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
|
-
```
|
|
105
|
+
## Errors
|
|
246
106
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
}
|
|
107
|
+
```ruby
|
|
108
|
+
Portage::Ucp::BigCommerce::Error # base class
|
|
109
|
+
Portage::Ucp::BigCommerce::ApiError # any non-2xx response from the Admin or Payments API
|
|
262
110
|
```
|
|
263
111
|
|
|
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
|
-
|
|
287
112
|
## Development
|
|
288
113
|
|
|
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
|
-
|
|
291
114
|
```bash
|
|
292
|
-
|
|
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:
|
|
115
|
+
bundle exec rspec # tests (WebMock-stubbed, no live store needed)
|
|
116
|
+
bundle exec rubocop # lint
|
|
296
117
|
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
299
122
|
```
|
|
300
123
|
|
|
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
|
-
|
|
311
124
|
## License
|
|
312
125
|
|
|
313
126
|
[MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Standalone MCP server over stdio. Configure the 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
|
|
@@ -0,0 +1,228 @@
|
|
|
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
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
@@ -84,13 +84,21 @@ 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:
|
|
87
|
+
executables:
|
|
88
|
+
- portage-ucp-bigcommerce
|
|
88
89
|
extensions: []
|
|
89
90
|
extra_rdoc_files: []
|
|
90
91
|
files:
|
|
91
92
|
- CHANGELOG.md
|
|
92
93
|
- LICENSE
|
|
93
94
|
- 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
|
|
94
102
|
homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-bigcommerce
|
|
95
103
|
licenses:
|
|
96
104
|
- MIT
|