portage-ucp-etsy 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: 3e24ea2159a17a36dacb77e235330db44249daa453f86a81ae149604167760cf
4
+ data.tar.gz: d1a0f696169cfe5e5872732154e2e59faedfcad982c333b9e0cc35cf2c7e5140
5
+ SHA512:
6
+ metadata.gz: 5133bf3834abd51fd2f2e14780a4d2798712195bc4bb55879e7fa5479770ae794c74d1cee41121d7b8ed34fdcd508674c31a944109f79ff93141e48a35007a9b
7
+ data.tar.gz: ea611876c6565b1c16ff2a85635f13d42cf37b5292a654ad550c6554142b9bdb0a711e745b4dc011fc89ddcf7cfaae304a41d6a720dfc01cfa802a5475ca0195
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
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. Etsy adapter against Open API v3 — catalog and order
10
+ real, checkout redirect-link only (no cart/checkout endpoint in Etsy's
11
+ public 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,114 @@
1
+ # portage-ucp-etsy
2
+
3
+ Etsy adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against Etsy's Open API v3. Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no Etsy SDK runtime dependency.
4
+
5
+ ## What it covers — and what it deliberately doesn't
6
+
7
+ Unlike the other adapters in this project, this is **catalog + redirect-link checkout + order**, not a full transactional adapter. Etsy's public API has no cart, checkout, or add-to-cart endpoint at all — buying only ever happens on etsy.com itself.
8
+
9
+ | UCP capability | Backing Etsy API | Notes |
10
+ |---|---|---|
11
+ | `dev.ucp.shopping.catalog` | Open API v3 | `search_catalog` (client-side title filter, see below), `get_product` |
12
+ | `dev.ucp.shopping.checkout` | — | `create_checkout`/`get_checkout` only. `update_checkout`/`complete_checkout`/`cancel_checkout` are left unoverridden — calling them raises `Portage::Ucp::NotImplementedError` rather than pretending to do something. `dev.ucp.shopping.checkout` is still advertised (one overridden method is enough), so an agent discovers what's actually backed by trying it. |
13
+ | `dev.ucp.shopping.order` | Open API v3 (shop receipts) | `get_order` |
14
+ | `dev.ucp.shopping.cart` | — | not implemented at all; there's no Etsy cart resource to back it |
15
+ | `dev.ucp.shopping.identity` | — | not implemented; Etsy buyer/seller OAuth identity is a separate concern from the shop-owner token used here |
16
+
17
+ **`create_checkout`'s `Checkout#links` point at each requested listing's own etsy.com page** — the closest real equivalent to "add to cart and check out" this API allows. These Checkout objects are **not real Etsy resources** — nothing on Etsy's side tracks them. They live only in the `Adapter` instance's memory (`get_checkout` just reads back what `create_checkout` stored), so they don't survive a process restart or a different `Adapter` instance. `get_order`'s `checkout_id` is always blank for the same underlying reason: there's no real checkout for a receipt to link back to.
18
+
19
+ **`search_catalog` is weaker than every other adapter's.** Etsy's shop-listings endpoint supports no keyword filter (only limit/offset/sort) — this fetches a page of active listings and filters by title client-side. Fine for a small shop, misleading for a large one with more listings than fit in one page.
20
+
21
+ ## Installation
22
+
23
+ ```ruby
24
+ # Gemfile
25
+ gem "portage-ucp-etsy"
26
+ ```
27
+
28
+ ```bash
29
+ bundle install
30
+ ```
31
+
32
+ ## Setup
33
+
34
+ You need an OAuth access_token (from the shop owner's one-time consent — Etsy's authorization-code+PKCE flow, outside this gem's scope) and your app's `x-api-key` (the OAuth client's keystring, required on every request in addition to the bearer token).
35
+
36
+ ```ruby
37
+ require "portage/ucp/etsy"
38
+
39
+ client = Portage::Ucp::Etsy::Client.new(
40
+ access_token: ENV.fetch("ETSY_ACCESS_TOKEN"),
41
+ api_key: ENV.fetch("ETSY_API_KEY")
42
+ )
43
+
44
+ adapter = Portage::Ucp::Etsy::Adapter.new(client: client, shop_id: ENV.fetch("ETSY_SHOP_ID"))
45
+ ```
46
+
47
+ ### Refreshing the access token
48
+
49
+ Etsy access tokens expire quickly and **rotate the refresh_token on every use** — persist the new one each time, the old one stops working immediately.
50
+
51
+ ```ruby
52
+ fetcher = Portage::Ucp::Etsy::AccessTokenFetcher.new(
53
+ client_id: ENV.fetch("ETSY_CLIENT_ID"),
54
+ refresh_token: ENV.fetch("ETSY_REFRESH_TOKEN")
55
+ )
56
+
57
+ result = fetcher.fetch
58
+ result.access_token # => pass into Client.new
59
+ result.refresh_token # => save this — the old one is now invalid
60
+ result.expires_in # => seconds until it needs refreshing again
61
+ ```
62
+
63
+ ## Usage
64
+
65
+ ```ruby
66
+ # Catalog — product_id is Etsy's listing_id
67
+ products = adapter.search_catalog(query: "mug", limit: 10)
68
+ product = adapter.get_product(product_id: products.first.id)
69
+
70
+ # Checkout — a redirect, not a real transaction
71
+ checkout = adapter.create_checkout(
72
+ line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
73
+ idempotency_key: SecureRandom.uuid
74
+ )
75
+ checkout.links.first.url # => hand this to the shopper/agent to complete the purchase on etsy.com
76
+
77
+ # Order
78
+ order = adapter.get_order(order_id: some_receipt_id)
79
+ ```
80
+
81
+ ## Wiring into portage-ucp
82
+
83
+ Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
84
+
85
+ ```ruby
86
+ dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
87
+
88
+ dispatcher.call(
89
+ capability: "dev.ucp.shopping.checkout",
90
+ action: "create_checkout",
91
+ arguments: { line_items: [{ product_id: listing_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
92
+ )
93
+ ```
94
+
95
+ ## Errors
96
+
97
+ ```ruby
98
+ Portage::Ucp::Etsy::Error # base class
99
+ Portage::Ucp::Etsy::ApiError # any non-2xx response from Etsy's Open API v3
100
+ ```
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ bundle exec rspec # tests (WebMock-stubbed, no live Etsy account needed)
106
+ bundle exec rubocop # lint
107
+
108
+ # refresh a real access_token for a connected shop
109
+ ETSY_CLIENT_ID=... ETSY_REFRESH_TOKEN=... bundle exec rake etsy_access_token
110
+ ```
111
+
112
+ ## License
113
+
114
+ [MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
@@ -0,0 +1,40 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Etsy
7
+ # Exchanges a refresh_token for a new access_token via Etsy's OAuth2
8
+ # `/v3/public/oauth/token` endpoint.
9
+ #
10
+ # Doesn't handle the *initial* authorization-code exchange (that needs
11
+ # a PKCE code_verifier from an interactive consent redirect, which is
12
+ # a one-time setup step outside this gem's scope) — only the refresh
13
+ # step a long-running server needs repeatedly. Unlike Shopify/Wix/
14
+ # Magento, Etsy **rotates** the refresh_token on every use: the
15
+ # response's `refresh_token` is a new value, and the one passed in
16
+ # becomes invalid immediately, so callers must persist the returned
17
+ # one before the next refresh, not just the access_token.
18
+ class AccessTokenFetcher
19
+ include Portage::Ucp::Support::TokenExchange
20
+
21
+ Result = Struct.new(:access_token, :refresh_token, :expires_in, keyword_init: true)
22
+
23
+ ENDPOINT = "https://api.etsy.com/v3/public/oauth/token".freeze
24
+
25
+ def initialize(client_id:, refresh_token:)
26
+ @client_id = client_id
27
+ @refresh_token = refresh_token
28
+ end
29
+
30
+ def fetch
31
+ body = exchange(ENDPOINT,
32
+ { grant_type: "refresh_token", client_id: @client_id, refresh_token: @refresh_token },
33
+ error_class: Portage::Ucp::Etsy::Error, description: "token refresh failed")
34
+ Result.new(access_token: body["access_token"], refresh_token: body["refresh_token"],
35
+ expires_in: body["expires_in"])
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,107 @@
1
+ module Portage
2
+ module Ucp
3
+ module Etsy
4
+ # Generic Portage::Ucp::Adapter over Etsy's Open API v3 — no
5
+ # merchant-specific business logic, same posture as the other
6
+ # adapters in this project.
7
+ #
8
+ # IMPORTANT: unlike Shopify/Wix/WooCommerce/BigCommerce/Magento, this
9
+ # is deliberately a **catalog + redirect-link checkout + order**
10
+ # adapter, not a full transactional one — Etsy's public API has no
11
+ # cart, checkout, or add-to-cart endpoint at all. Buying only ever
12
+ # happens on etsy.com itself. This adapter therefore:
13
+ #
14
+ # - Doesn't override `get_cart`/`create_cart`/`update_cart`/
15
+ # `cancel_cart` at all — `dev.ucp.shopping.cart` stays unadvertised.
16
+ # - Overrides only `create_checkout`/`get_checkout`. `Checkout#links`
17
+ # carries one link per requested listing, pointing straight at that
18
+ # listing's own etsy.com page — the closest real equivalent to
19
+ # "add this to your cart and check out" this API allows. `update_
20
+ # checkout`/`complete_checkout`/`cancel_checkout` are left
21
+ # unoverridden on purpose: there's nothing to update or complete
22
+ # programmatically, and calling them raises
23
+ # Portage::Ucp::NotImplementedError rather than silently pretending
24
+ # to do something. `dev.ucp.shopping.checkout` still gets advertised
25
+ # (Capability#advertised_for? only needs one overridden method), so
26
+ # an agent sees the capability and can discover which actions are
27
+ # actually backed by trying them.
28
+ # - The Checkout objects `create_checkout` returns are **not real
29
+ # Etsy resources** — nothing on Etsy's side tracks them. They live
30
+ # only in this Adapter instance's memory (`get_checkout` reads back
31
+ # what `create_checkout` stored), so they don't survive a process
32
+ # restart or a different Adapter instance.
33
+ # - `get_order`'s `checkout_id` is always blank — there's no real
34
+ # checkout for a receipt to link back to in the first place (see
35
+ # above), a more fundamental gap than WooCommerce/Magento needing
36
+ # adapter-side tracking for a *real* checkout.
37
+ #
38
+ # Deliberately doesn't override `link_identity` either — Etsy buyer/
39
+ # seller OAuth identity is a separate concern from the shop-owner
40
+ # access_token this gem uses for catalog/order reads.
41
+ class Adapter < Portage::Ucp::Adapter
42
+ # §9a dedup via Support::Idempotency, so an agent's retry on a
43
+ # dropped connection doesn't build a second, different in-memory
44
+ # Checkout for the same intent.
45
+ include Portage::Ucp::Support::Idempotency
46
+ # Etsy answers a missing listing or receipt with a 404 rather than
47
+ # an empty body, which UCP's reads report as nil.
48
+ include Portage::Ucp::Support::NotFound
49
+
50
+ def initialize(client:, shop_id:)
51
+ super()
52
+ @client = client
53
+ @shop_id = shop_id
54
+ @checkouts = {}
55
+ end
56
+
57
+ # Etsy's shop-listings endpoint supports no keyword filter at all
58
+ # (only limit/offset/sort) — this fetches a page of active listings
59
+ # and filters by title client-side, so it's a much weaker "search"
60
+ # than every other adapter's real server-side query. Fine for a
61
+ # small shop, misleading for a large one.
62
+ def search_catalog(query:, limit:)
63
+ data = @client.get("/shops/#{@shop_id}/listings/active?limit=100")
64
+ matches = (data["results"] || []).select { |l| l["title"].to_s.downcase.include?(query.downcase) }
65
+ matches.first(limit).map { |node| Mapper.product(node) }
66
+ end
67
+
68
+ def get_product(product_id:)
69
+ nil_on_not_found do
70
+ node = @client.get("/listings/#{product_id}")
71
+ next nil unless node["listing_id"]
72
+
73
+ inventory = @client.get("/listings/#{product_id}/inventory")
74
+ Mapper.product(node.merge("variants_detail" => inventory))
75
+ end
76
+ end
77
+
78
+ def create_checkout(line_items:, idempotency_key:)
79
+ dedup(idempotency_key) do
80
+ listings = line_items.map { |li| listing_with_quantity(li) }
81
+ checkout_id = "etsy-checkout-#{idempotency_key}"
82
+ checkout = Mapper.checkout(listings, id: checkout_id, status: "incomplete")
83
+ @checkouts[checkout_id] = checkout
84
+ checkout
85
+ end
86
+ end
87
+
88
+ def get_checkout(checkout_id:)
89
+ @checkouts[checkout_id]
90
+ end
91
+
92
+ def get_order(order_id:)
93
+ nil_on_not_found do
94
+ node = @client.get("/shops/#{@shop_id}/receipts/#{order_id}")
95
+ node["receipt_id"] ? Mapper.order(node) : nil
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def listing_with_quantity(line_item)
102
+ @client.get("/listings/#{line_item[:product_id]}").merge("quantity" => line_item[:quantity])
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,44 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Etsy
7
+ # Minimal REST client over Etsy's Open API v3
8
+ # (`api.etsy.com/v3/application`).
9
+ #
10
+ # Deliberately plain Net::HTTP, not an Etsy SDK (there isn't an
11
+ # official Ruby one) — trivially stubbable with WebMock.
12
+ #
13
+ # Every call needs *two* credentials, not one: a Bearer access_token
14
+ # from the shop owner's OAuth consent (see
15
+ # Portage::Ucp::Etsy::AccessTokenFetcher) **and** the app's own
16
+ # `x-api-key` (the OAuth client's keystring) on every single request —
17
+ # Etsy checks both independently, unlike Shopify/Wix/WooCommerce/
18
+ # Magento, where the bearer token alone is sufficient.
19
+ class Client
20
+ include Portage::Ucp::Support::HttpClient
21
+
22
+ BASE_URL = "https://api.etsy.com/v3/application".freeze
23
+
24
+ def initialize(access_token:, api_key:)
25
+ @access_token = access_token
26
+ @api_key = api_key
27
+ end
28
+
29
+ def get(path)
30
+ request(Net::HTTP::Get, path)
31
+ end
32
+
33
+ private
34
+
35
+ def request(http_method, path)
36
+ json_request(http_method, "#{BASE_URL}#{path}",
37
+ headers: { "Authorization" => "Bearer #{@access_token}", "x-api-key" => @api_key })
38
+ end
39
+
40
+ def api_error_class = Portage::Ucp::Etsy::ApiError
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,20 @@
1
+ module Portage
2
+ module Ucp
3
+ module Etsy
4
+ class Error < StandardError; end
5
+
6
+ # Raised for any non-2xx response from Etsy's Open API v3
7
+ # (`api.etsy.com/v3/application`) — a non-2xx status with a JSON
8
+ # `{error, error_description}` (OAuth) or `{error}` (REST) body.
9
+ class ApiError < Error
10
+ include Portage::Ucp::Support::ApiError
11
+
12
+ private
13
+
14
+ def detail(body)
15
+ body["error_description"] || body["error"] || body
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,147 @@
1
+ module Portage
2
+ module Ucp
3
+ module Etsy
4
+ # Converts Etsy Open API v3 response bodies into the protocol-layer
5
+ # value objects from Portage::Ucp::ValueObjects — nothing Etsy-shaped
6
+ # is allowed to leak past this file.
7
+ #
8
+ # Etsy's `Money` type (`{amount, divisor, currency_code}`) is already
9
+ # an integer, unlike every other adapter in this project — `amount /
10
+ # divisor` gives the decimal value, but `amount` itself is already in
11
+ # the currency's minor units when `divisor` is 100 (the only value
12
+ # Etsy documents ever returning), so no BigDecimal conversion is
13
+ # needed here at all.
14
+ module Mapper
15
+ module_function
16
+
17
+ def money(node)
18
+ Portage::Ucp::Money.new(amount_minor: node["amount"], currency: node["currency_code"])
19
+ end
20
+
21
+ # `node["variants_detail"]` is adapter-populated, not a real Etsy
22
+ # field: a listing's own resource has no variant/SKU breakdown at
23
+ # all — that's a second call to the separate Inventory endpoint
24
+ # (`/listings/{id}/inventory`), made only for #get_product's
25
+ # single-listing path, same reasoning as every other adapter's N+1
26
+ # variant fetch.
27
+ def product(node)
28
+ Portage::Ucp::Product.new(
29
+ id: node["listing_id"].to_s,
30
+ title: node["title"],
31
+ description: node["description"],
32
+ price: money(node["price"]),
33
+ available: node["state"] == "active" && node["quantity"].to_i.positive?,
34
+ variants: variants(node),
35
+ url: node["url"]
36
+ )
37
+ end
38
+
39
+ # A listing with no inventory-level variation is its own single
40
+ # implicit variant, same as a single-variant Shopify product using
41
+ # that variant's own id.
42
+ def variants(node)
43
+ detail = node["variants_detail"]
44
+ unless detail
45
+ return [{ id: node["listing_id"].to_s, title: node["title"],
46
+ available: node["state"] == "active" && node["quantity"].to_i.positive?,
47
+ price: money(node["price"]) }]
48
+ end
49
+
50
+ (detail["products"] || []).reject { |p| p["is_deleted"] }.map { |p| variant(p) }
51
+ end
52
+
53
+ def variant(node)
54
+ title = (node["property_values"] || []).flat_map { |p| p["values"] }.join(" / ")
55
+ offering = (node["offerings"] || []).find { |o| o["is_enabled"] } || {}
56
+ { id: node["product_id"].to_s, title: title, available: offering["quantity"].to_i.positive?,
57
+ price: money(offering["price"] || {}) }
58
+ end
59
+
60
+ # `id:` is caller-supplied: there's no real Etsy checkout resource
61
+ # behind this at all — see Portage::Ucp::Etsy::Adapter's class-level
62
+ # comment. `links` points at each listing's own public Etsy URL
63
+ # rather than a cart, since Etsy's public API has no way to deep-
64
+ # link a multi-item add-to-cart flow.
65
+ def checkout(listings, id:, status:)
66
+ line_items = listings.map { |l| checkout_line_item(l) }
67
+ currency = listings.first&.dig("price", "currency_code")
68
+ Portage::Ucp::Checkout.new(
69
+ id: id,
70
+ status: status,
71
+ line_items: line_items,
72
+ currency: currency,
73
+ totals: totals(listings),
74
+ links: listings.map { |l| Portage::Ucp::Link.new(type: "checkout", url: l["url"], title: l["title"]) }
75
+ )
76
+ end
77
+
78
+ def checkout_line_item(node)
79
+ Portage::Ucp::LineItem.new(
80
+ id: node["listing_id"].to_s,
81
+ item: Portage::Ucp::Item.new(id: node["listing_id"].to_s, title: node["title"],
82
+ price: node.dig("price", "amount")),
83
+ quantity: node["quantity"] || 1,
84
+ totals: Portage::Ucp::Support::Totals.line(line_total(node))
85
+ )
86
+ end
87
+
88
+ def line_total(node)
89
+ node.dig("price", "amount") * (node["quantity"] || 1)
90
+ end
91
+
92
+ def totals(listings)
93
+ subtotal = listings.sum { |l| line_total(l) }
94
+ Portage::Ucp::Support::Totals.summary(subtotal: subtotal, total: subtotal)
95
+ end
96
+
97
+ # Etsy core has no per-transaction fulfillment tracking on the
98
+ # Receipt resource beyond a single receipt-wide `is_shipped` flag —
99
+ # every line gets the same coarse status, same simplification as
100
+ # Portage::Ucp::WooCommerce::Mapper/Portage::Ucp::Magento::Mapper.
101
+ #
102
+ # `permalink_url` is left blank — Etsy's API doesn't return a
103
+ # public, shareable receipt URL (buyers see receipts inside their
104
+ # own account, not via a link). `checkout_id` is always blank too,
105
+ # for a more fundamental reason than the other adapters: this
106
+ # gem's Checkout is never a real Etsy resource (see
107
+ # Portage::Ucp::Etsy::Adapter), so there is no "checkout that led
108
+ # to this receipt" for the adapter to have recorded in the first
109
+ # place.
110
+ def receipt_status(node)
111
+ return "fulfilled" if node["is_shipped"]
112
+ return "removed" if node["status"] == "canceled"
113
+
114
+ "processing"
115
+ end
116
+
117
+ def order(node)
118
+ status = receipt_status(node)
119
+ Portage::Ucp::Order.new(
120
+ id: node["receipt_id"].to_s,
121
+ checkout_id: "",
122
+ permalink_url: "",
123
+ line_items: (node["transactions"] || []).map { |n| order_line_item(n, status) },
124
+ fulfillment: Portage::Ucp::Fulfillment.new,
125
+ currency: node.dig("total_price", "currency_code"),
126
+ totals: Portage::Ucp::Support::Totals.summary(subtotal: money(node["subtotal"]).amount_minor,
127
+ total: money(node["total_price"]).amount_minor)
128
+ )
129
+ end
130
+
131
+ def order_line_item(node, status)
132
+ quantity = node["quantity"]
133
+ fulfilled = Portage::Ucp::Support::LineItemStatus.fulfilled_quantity(status, quantity)
134
+ line_total = node.dig("price", "amount") * quantity
135
+ Portage::Ucp::OrderLineItem.new(
136
+ id: node["transaction_id"].to_s,
137
+ item: Portage::Ucp::Item.new(id: node["listing_id"].to_s, title: node["title"],
138
+ price: node.dig("price", "amount")),
139
+ quantity: { original: quantity, total: quantity, fulfilled: fulfilled },
140
+ totals: Portage::Ucp::Support::Totals.line(line_total),
141
+ status: status
142
+ )
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,7 @@
1
+ module Portage
2
+ module Ucp
3
+ module Etsy
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ require "portage/ucp"
2
+ require_relative "etsy/version"
3
+ require_relative "etsy/errors"
4
+ require_relative "etsy/client"
5
+ require_relative "etsy/access_token_fetcher"
6
+ require_relative "etsy/mapper"
7
+ require_relative "etsy/adapter"
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-ucp-etsy
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 Etsy's Open API v3. Catalog
84
+ and order are real; checkout is redirect-link only (links to etsy.com) since Etsy's
85
+ public API has no cart/checkout endpoint. Generic only — no merchant-specific business
86
+ logic. Plain Net::HTTP, no Etsy SDK runtime dependency.
87
+ email:
88
+ executables: []
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - CHANGELOG.md
93
+ - LICENSE
94
+ - README.md
95
+ - lib/portage/ucp/etsy.rb
96
+ - lib/portage/ucp/etsy/access_token_fetcher.rb
97
+ - lib/portage/ucp/etsy/adapter.rb
98
+ - lib/portage/ucp/etsy/client.rb
99
+ - lib/portage/ucp/etsy/errors.rb
100
+ - lib/portage/ucp/etsy/mapper.rb
101
+ - lib/portage/ucp/etsy/version.rb
102
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-etsy
103
+ licenses:
104
+ - MIT
105
+ metadata:
106
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-etsy
107
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-etsy/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: Etsy adapter for portage-ucp — catalog/order over MCP and UCP, redirect-link
128
+ checkout
129
+ test_files: []