portage-ucp-bigcommerce 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 825aa48c9aba12a11801d6512c4400019a727fc6f0d76c4dd6073f16e7c81482
4
+ data.tar.gz: 8a710386fdfe7d542f6a22d8c5c3a518541f8a19a68149283eac96d77ddf440d
5
+ SHA512:
6
+ metadata.gz: cd5bd467d2c454680e20e943b2a7095df5ede07fa6a9c6fbbd1a7641e7319faf2bc8386b902e7d6c2cbe0238dc858beb6aa1af7bfb7df346c46016afc96c4d73
7
+ data.tar.gz: a6f1a7a5cab4ba465b5602669646cbb74c86f55ed1bc0a4c8a9de4ee0f2c47fc8352f5ea7e6145ad5659e4b2c1827429815b180948fda66473873367b64fff4f
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format loosely follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
+ pre-1.0, so APIs may still shift between minor versions.
6
+
7
+ ## [0.1.0] - Unreleased
8
+
9
+ - Initial pre-release. BigCommerce adapter against the v3 Catalog/Carts/
10
+ Checkouts APIs and v2 Orders API.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Whitbread
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # portage-ucp-bigcommerce
2
+
3
+ BigCommerce adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against a BigCommerce store's v3 Catalog/Carts/Checkouts APIs and v2 Orders API. Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no `bigcommerce_api` runtime dependency.
4
+
5
+ ## What it covers
6
+
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.
18
+
19
+ ## ⚠️ Unverified against a live store
20
+
21
+ Built from BigCommerce's documented REST API shapes, not run against a real store yet. Before relying on this in production, confirm:
22
+
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
+
28
+ ## Installation
29
+
30
+ ```ruby
31
+ # Gemfile
32
+ gem "portage-ucp-bigcommerce"
33
+ ```
34
+
35
+ ```bash
36
+ bundle install
37
+ ```
38
+
39
+ ## Setup
40
+
41
+ You need your store hash (from the BigCommerce control panel URL or API path), and a client_id/access_token pair from an API account (Settings → API → Create API Account — grant the scopes you need for Products/Carts/Checkouts/Orders), your store's currency (the Catalog product resource doesn't return one), and — only if you'll call `complete_checkout` — the payment gateway id you want to submit orders through.
42
+
43
+ ```ruby
44
+ require "portage/ucp/bigcommerce"
45
+
46
+ client = Portage::Ucp::BigCommerce::Client.new(
47
+ store_hash: ENV.fetch("BIGCOMMERCE_STORE_HASH"),
48
+ client_id: ENV.fetch("BIGCOMMERCE_CLIENT_ID"),
49
+ access_token: ENV.fetch("BIGCOMMERCE_ACCESS_TOKEN")
50
+ )
51
+
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
+ )
58
+ ```
59
+
60
+ ## Usage
61
+
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
73
+
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
+ )
84
+
85
+ # Order
86
+ order = adapter.get_order(order_id: checkout_order_id) # only once linked post-completion
87
+ ```
88
+
89
+ ## Wiring into portage-ucp
90
+
91
+ Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
92
+
93
+ ```ruby
94
+ dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
95
+
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
+ )
101
+ ```
102
+
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.
104
+
105
+ ## Errors
106
+
107
+ ```ruby
108
+ Portage::Ucp::BigCommerce::Error # base class
109
+ Portage::Ucp::BigCommerce::ApiError # any non-2xx response from the Admin or Payments API
110
+ ```
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ bundle exec rspec # tests (WebMock-stubbed, no live store needed)
116
+ bundle exec rubocop # lint
117
+
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
122
+ ```
123
+
124
+ ## License
125
+
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,224 @@
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
+ @client.v3_get(path)["data"].map { |node| Mapper.product(node, currency: @currency, site_url: @site_url) }
57
+ end
58
+
59
+ def get_product(product_id:)
60
+ nil_on_not_found do
61
+ node = @client.v3_get("/catalog/products/#{product_id}?include=variants")["data"]
62
+ node && Mapper.product(node, currency: @currency, site_url: @site_url)
63
+ end
64
+ end
65
+
66
+ def get_cart(cart_id:)
67
+ nil_on_not_found do
68
+ node = fetch_cart_node(cart_id)
69
+ node && Mapper.cart(node, id: cart_id)
70
+ end
71
+ end
72
+
73
+ def create_cart(line_items:, idempotency_key:)
74
+ dedup(idempotency_key) do
75
+ node = @client.v3_post("/carts", { line_items: cart_lines(line_items) })["data"]
76
+ Mapper.cart(node, id: node["id"])
77
+ end
78
+ end
79
+
80
+ # Full replacement, matching UCP's real cart semantics: the Carts API
81
+ # has no atomic "replace all lines" endpoint either. New lines are
82
+ # added *before* the old ones are removed — BigCommerce auto-deletes
83
+ # the whole cart when its last remaining line item is removed, so
84
+ # removing old lines first (when the desired list is non-empty)
85
+ # would destroy the cart this call is supposed to be updating.
86
+ def update_cart(cart_id:, line_items:, idempotency_key:)
87
+ dedup(idempotency_key) { Mapper.cart(replace_cart_lines(cart_id, line_items), id: cart_id) }
88
+ end
89
+
90
+ # Unlike Shopify (no cancellation mutation at all) and WooCommerce
91
+ # (only a session cart to clear), BigCommerce's Carts API supports
92
+ # deleting the cart resource outright — the closest real equivalent
93
+ # to cancellation available on any of the three. The returned Cart
94
+ # reflects that the resource is now gone (empty line_items, no
95
+ # currency) rather than raising: schema-valid, just not information-
96
+ # complete, same posture as
97
+ # Portage::Ucp::Shopify::Adapter#link_cart_to_order's best-effort miss.
98
+ def cancel_cart(cart_id:, idempotency_key:)
99
+ dedup(idempotency_key) do
100
+ delete_cart(cart_id)
101
+ Mapper.cart({}, id: cart_id)
102
+ end
103
+ end
104
+
105
+ def create_checkout(line_items:, idempotency_key:)
106
+ dedup(idempotency_key) do
107
+ cart_node = @client.v3_post("/carts", { line_items: cart_lines(line_items) })["data"]
108
+ record_checkout_status(cart_node["id"], "incomplete")
109
+ Mapper.checkout(fetch_checkout_node(cart_node["id"]), id: cart_node["id"], status: "incomplete")
110
+ end
111
+ end
112
+
113
+ def get_checkout(checkout_id:)
114
+ nil_on_not_found do
115
+ node = fetch_checkout_node(checkout_id)
116
+ node && Mapper.checkout(node, id: checkout_id, status: checkout_status(checkout_id))
117
+ end
118
+ end
119
+
120
+ # Full replacement, same rationale as #update_cart — BigCommerce's
121
+ # Checkout shares its underlying cart with #update_cart.
122
+ def update_checkout(checkout_id:, line_items:, idempotency_key:)
123
+ dedup(idempotency_key) do
124
+ replace_cart_lines(checkout_id, line_items)
125
+ record_checkout_status(checkout_id, "incomplete")
126
+ Mapper.checkout(fetch_checkout_node(checkout_id), id: checkout_id, status: "incomplete")
127
+ end
128
+ end
129
+
130
+ def complete_checkout(checkout_id:, payment_token:, idempotency_key:)
131
+ dedup(idempotency_key) { submit_checkout(checkout_id, payment_token) }
132
+ end
133
+
134
+ # The Checkouts API has no cancellation endpoint for an in-progress
135
+ # checkout either (see #cancel_cart's contrast) — this just marks the
136
+ # tracked status canceled without touching the underlying cart.
137
+ def cancel_checkout(checkout_id:, idempotency_key:)
138
+ dedup(idempotency_key) do
139
+ record_checkout_status(checkout_id, "canceled")
140
+ Mapper.checkout(fetch_checkout_node(checkout_id), id: checkout_id, status: "canceled")
141
+ end
142
+ end
143
+
144
+ def get_order(order_id:)
145
+ nil_on_not_found do
146
+ node = @client.v2_get("/orders/#{order_id}")
147
+ next nil unless node["id"]
148
+
149
+ products = @client.v2_get("/orders/#{order_id}/products")
150
+ Mapper.order(node, products: products, site_url: @site_url,
151
+ checkout_id: checkout_id_for(order_id))
152
+ end
153
+ end
154
+
155
+ private
156
+
157
+ def fetch_cart_node(cart_id)
158
+ include_param = "line_items.physical_items.options,line_items.digital_items.options"
159
+ @client.v3_get("/carts/#{cart_id}?include=#{include_param}")["data"]
160
+ end
161
+
162
+ def fetch_checkout_node(checkout_id)
163
+ @client.v3_get("/checkouts/#{checkout_id}")["data"]
164
+ end
165
+
166
+ def delete_cart(cart_id)
167
+ nil_on_not_found { @client.v3_delete("/carts/#{cart_id}") }
168
+ end
169
+
170
+ # Maps a UCP line item request straight to `{product_id, quantity}`.
171
+ # A real variant-level add needs `{product_id, variant_id}` together
172
+ # — this generic adapter, same as
173
+ # Portage::Ucp::WooCommerce::Adapter#cart_lines, assumes the given id
174
+ # already identifies the exact sellable unit and doesn't attempt to
175
+ # split it back into a product/variant pair.
176
+ def cart_lines(line_items)
177
+ line_items.map { |li| { product_id: li[:product_id].to_i, quantity: li[:quantity] } }
178
+ end
179
+
180
+ def cart_item_ids(node)
181
+ ((node.dig("line_items", "physical_items") || []) + (node.dig("line_items", "digital_items") || []))
182
+ .map { |item| item["id"] }
183
+ end
184
+
185
+ def replace_cart_lines(cart_id, line_items)
186
+ current_item_ids = cart_item_ids(fetch_cart_node(cart_id))
187
+ @client.v3_post("/carts/#{cart_id}/items", { line_items: cart_lines(line_items) }) unless line_items.empty?
188
+ current_item_ids.each { |item_id| @client.v3_delete("/carts/#{cart_id}/items/#{item_id}") }
189
+ return {} if line_items.empty?
190
+
191
+ fetch_cart_node(cart_id)
192
+ end
193
+
194
+ # See the class-level CAVEAT: `payment_instrument` uses a single
195
+ # configurable gateway id (`payment_gateway_id:`) rather than a
196
+ # confirmed gateway-specific instrument shape.
197
+ def submit_checkout(checkout_id, payment_token)
198
+ unless @payment_gateway_id
199
+ raise Portage::Ucp::BigCommerce::Error,
200
+ "no payment_gateway_id configured on this Adapter"
201
+ end
202
+
203
+ checkout_node = fetch_checkout_node(checkout_id)
204
+ order_id = @client.v3_post("/checkouts/#{checkout_id}/orders", {}).dig("data", "id")
205
+ access_token = @client.v3_post("/payments/access_tokens", { order: { id: order_id } }).dig("data", "id")
206
+ @client.process_payment(payment_access_token: access_token, order_id: order_id,
207
+ payment_instrument: { type: "tokenized_instrument", token: payment_token,
208
+ gateway: @payment_gateway_id })
209
+
210
+ record_checkout_status(checkout_id, "completed")
211
+ record_order_checkout(order_id, checkout_id)
212
+ Mapper.checkout(checkout_node, id: checkout_id, status: "completed", order: order_confirmation(order_id))
213
+ end
214
+
215
+ # Same order-status URL pattern as Mapper.order's `permalink_url`.
216
+ def order_confirmation(order_id)
217
+ Portage::Ucp::OrderConfirmation.new(
218
+ id: order_id.to_s, permalink_url: "#{@site_url}/account.php?action=order_status&order_id=#{order_id}"
219
+ )
220
+ end
221
+ end
222
+ end
223
+ end
224
+ 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,175 @@
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
+ # `site_url` is only used to turn `custom_url.url` (always store-
25
+ # relative, e.g. "/cold-brew/") into an absolute product URL — the v3
26
+ # Catalog resource never returns an absolute one itself.
27
+ def product(node, currency:, site_url:)
28
+ Portage::Ucp::Product.new(
29
+ id: node["id"].to_s,
30
+ title: node["name"],
31
+ description: node["description"],
32
+ price: money(node["price"], currency),
33
+ available: node["availability"] != "disabled",
34
+ variants: variants(node, currency),
35
+ url: node.dig("custom_url", "url") ? "#{site_url}#{node.dig('custom_url', 'url')}" : nil
36
+ )
37
+ end
38
+
39
+ # A product with no configured variants has no real Catalog
40
+ # "variants" entries at all — it's its own single implicit variant,
41
+ # same as Portage::Ucp::WooCommerce::Mapper.variants for a simple product.
42
+ def variants(node, currency)
43
+ nodes = node["variants"]
44
+ unless nodes&.any?
45
+ return [{ id: node["id"].to_s, title: node["name"], available: node["availability"] != "disabled",
46
+ price: money(node["price"], currency) }]
47
+ end
48
+
49
+ nodes.map { |v| variant(v, node, currency) }
50
+ end
51
+
52
+ # A variant's own `price` is 0/null when it doesn't override the
53
+ # base product price — falls back to the parent product's price in
54
+ # that case, same "inherits unless overridden" rule BigCommerce
55
+ # documents for variant pricing.
56
+ def variant(node, parent_node, currency)
57
+ title = (node["option_values"] || []).map { |ov| ov["label"] }.join(" / ")
58
+ price = node["price"].to_f.zero? ? parent_node["price"] : node["price"]
59
+ { id: node["id"].to_s, title: title, available: !node["purchasing_disabled"], price: money(price, currency) }
60
+ end
61
+
62
+ # `id:` is caller-supplied rather than read off the response body's
63
+ # own top-level id in every call site: a v3 Cart's `id` field is a
64
+ # real, stable resource id (unlike WooCommerce's headerless Cart-
65
+ # Token), but callers pass it explicitly anyway so Adapter can reuse
66
+ # this same method for both the cart-creation response (id known
67
+ # from the body) and a checkout's embedded `cart` sub-object (no
68
+ # top-level id of its own).
69
+ def cart(node, id:)
70
+ currency = node.dig("currency", "code")
71
+ items = (node.dig("line_items", "physical_items") || []) + (node.dig("line_items", "digital_items") || [])
72
+ Portage::Ucp::Cart.new(
73
+ id: id,
74
+ line_items: items.map { |n| cart_line_item(n) },
75
+ currency: currency,
76
+ totals: Portage::Ucp::Support::Totals.summary(subtotal: minor_units(node["base_amount"]),
77
+ total: minor_units(node["cart_amount"]))
78
+ )
79
+ end
80
+
81
+ # `status` isn't a v3 Checkout field either — BigCommerce's Checkout
82
+ # is the cart plus billing/consignment/order-linkage data, not a
83
+ # resource with its own lifecycle enum, so the Adapter tracks status
84
+ # itself and passes it in, same rationale as
85
+ # Portage::Ucp::Shopify::Mapper.checkout and Portage::Ucp::WooCommerce::Mapper.checkout.
86
+ def checkout(node, id:, status:, order: nil)
87
+ cart_node = node["cart"] || node
88
+
89
+ Portage::Ucp::Checkout.new(
90
+ id: id,
91
+ status: status,
92
+ line_items: cart(cart_node, id: id).line_items,
93
+ currency: cart_node.dig("currency", "code"),
94
+ totals: checkout_totals(node, cart_node),
95
+ links: [],
96
+ order: order
97
+ )
98
+ end
99
+
100
+ def checkout_totals(node, cart_node)
101
+ Portage::Ucp::Support::Totals.summary(
102
+ subtotal: minor_units(node["subtotal"] || cart_node["base_amount"]),
103
+ tax: minor_units(node["tax_total"]),
104
+ total: minor_units(node["grand_total"] || cart_node["cart_amount"])
105
+ )
106
+ end
107
+
108
+ # CAVEAT: assumes physical/digital cart line items carry plain
109
+ # decimal price fields (`sale_price`, `extended_sale_price`), the
110
+ # shape BigCommerce's own docs show — not confirmed against a live
111
+ # cart response, which some BigCommerce API surfaces instead nest
112
+ # under a `{value:, currency:}` sub-object.
113
+ def cart_line_item(node)
114
+ unit_price = minor_units(node["sale_price"])
115
+ line_total = minor_units(node["extended_sale_price"])
116
+ Portage::Ucp::LineItem.new(
117
+ id: node["id"],
118
+ item: Portage::Ucp::Item.new(id: (node["variant_id"] || node["product_id"]).to_s, title: node["name"],
119
+ price: unit_price, image_url: node["image_url"]),
120
+ quantity: node["quantity"],
121
+ totals: Portage::Ucp::Support::Totals.line(minor_units(node["extended_list_price"]), line_total)
122
+ )
123
+ end
124
+
125
+ # BigCommerce's fixed, store-independent order status_id enum — see
126
+ # https://developer.bigcommerce.com/docs/rest-management/order-statuses.
127
+ # Core has no per-line-item fulfillment tracking (that's a shipping-
128
+ # app concern), so every order line is given the same coarse status
129
+ # derived from the order's own top-level status, same posture as
130
+ # Portage::Ucp::WooCommerce::Mapper::ORDER_LINE_ITEM_STATUS.
131
+ ORDER_STATUS = {
132
+ 2 => "fulfilled", 8 => "fulfilled", 10 => "fulfilled",
133
+ 4 => "removed", 5 => "removed", 6 => "removed", 14 => "removed"
134
+ }.freeze
135
+
136
+ # `permalink_url` is built from BigCommerce's documented storefront
137
+ # order-status route — not confirmed against a live storefront theme,
138
+ # same "needs confirming" posture as
139
+ # Portage::Ucp::WooCommerce::Mapper.order's order-received URL.
140
+ #
141
+ # `products` is the separate v2 `/orders/{id}/products` response —
142
+ # unlike WooCommerce/Shopify, a v2 Order's own resource doesn't embed
143
+ # its line items, so the Adapter fetches and merges them in.
144
+ def order(node, products:, site_url:, checkout_id: "")
145
+ currency = node["currency_code"]
146
+ status = Portage::Ucp::Support::LineItemStatus.from_table(ORDER_STATUS, node["status_id"])
147
+ Portage::Ucp::Order.new(
148
+ id: node["id"].to_s,
149
+ checkout_id: checkout_id,
150
+ permalink_url: "#{site_url}/account.php?action=order_status&order_id=#{node['id']}",
151
+ line_items: products.map { |n| order_line_item(n, status) },
152
+ fulfillment: Portage::Ucp::Fulfillment.new,
153
+ currency: currency,
154
+ totals: Portage::Ucp::Support::Totals.summary(subtotal: minor_units(node["subtotal_ex_tax"]),
155
+ total: minor_units(node["total_inc_tax"]))
156
+ )
157
+ end
158
+
159
+ def order_line_item(node, status)
160
+ quantity = node["quantity"]
161
+ fulfilled = Portage::Ucp::Support::LineItemStatus.fulfilled_quantity(status, quantity)
162
+ Portage::Ucp::OrderLineItem.new(
163
+ id: node["id"].to_s,
164
+ item: Portage::Ucp::Item.new(id: node["product_id"].to_s, title: node["name"],
165
+ price: minor_units(node["price_ex_tax"])),
166
+ quantity: { original: quantity, total: quantity, fulfilled: fulfilled },
167
+ totals: Portage::Ucp::Support::Totals.line(minor_units(node["total_ex_tax"]),
168
+ minor_units(node["total_inc_tax"])),
169
+ status: status
170
+ )
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,7 @@
1
+ module Portage
2
+ module Ucp
3
+ module BigCommerce
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ require "portage/ucp"
2
+ require_relative "bigcommerce/version"
3
+ require_relative "bigcommerce/errors"
4
+ require_relative "bigcommerce/client"
5
+ require_relative "bigcommerce/mapper"
6
+ require_relative "bigcommerce/adapter"
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-ucp-bigcommerce
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tom Whitbread
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: portage-ucp
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.88'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.88'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webmock
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.24'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.24'
69
+ - !ruby/object:Gem::Dependency
70
+ name: yard
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.9'
83
+ description: Implements Portage::Ucp::Adapter against a BigCommerce store's v3 Catalog/Carts/Checkouts
84
+ APIs and v2 Orders API. Generic only — no merchant-specific business logic. Plain
85
+ Net::HTTP, no bigcommerce/api runtime dependency.
86
+ email:
87
+ executables:
88
+ - portage-ucp-bigcommerce
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - CHANGELOG.md
93
+ - LICENSE
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
102
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-bigcommerce
103
+ licenses:
104
+ - MIT
105
+ metadata:
106
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-bigcommerce
107
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-bigcommerce/CHANGELOG.md
108
+ rubygems_mfa_required: 'true'
109
+ post_install_message:
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '3.2'
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.5.22
125
+ signing_key:
126
+ specification_version: 4
127
+ summary: BigCommerce adapter for portage-ucp — standard catalog/cart/checkout/order
128
+ over MCP and UCP
129
+ test_files: []