portage-ucp-instagram 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: f6de3c1bfd185403eb82d68015bea9a509ffe1611fcf438db56b66b40848d8c3
4
+ data.tar.gz: 859258fcf4198304fcc7a4fbd31c98e72f20db77711c8000f0ee14a13d40b3e9
5
+ SHA512:
6
+ metadata.gz: ad5a5195f6e873cca743a6eb9d5227fe799f35ca3a140389d37bbcc9726201f0a8bf39728599f31b611144ee7f41f135aaa3c713fe6214704cbfe561233ef2bf
7
+ data.tar.gz: 2ed79e836dd1227ec848a529a8e2fca6c7f2558e35eaaa149945675db8d12e6c63252d1d6bc55ac1b51771dd9e4309e84d73bccc4ae85015d8cf00699c65a47a
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. Instagram/Facebook Shops adapter against Meta's Graph
10
+ API Commerce Catalog — catalog real, checkout redirect-link only, order
11
+ lookup limited to "checkout on Instagram/Facebook" merchants.
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,112 @@
1
+ # portage-ucp-instagram
2
+
3
+ Instagram/Facebook Shops adapter for [`portage-ucp`](../portage-ucp). Implements `Portage::Ucp::Adapter` against Meta's Graph API Commerce Catalog. Generic only — no merchant-specific business logic. Plain `Net::HTTP`, no Facebook SDK runtime dependency.
4
+
5
+ ## What it covers — and what it deliberately doesn't
6
+
7
+ Like [`portage-ucp-etsy`](../portage-ucp-etsy), this is **catalog + redirect-link checkout + order**, not a full transactional adapter — for a more fundamental reason than Etsy. Instagram/Facebook Shops splits into two merchant populations:
8
+
9
+ - **"Checkout on your website"** — each catalog product carries its own merchant-hosted `url`. Buying happens entirely on the merchant's own site, never through Meta. This is the population `create_checkout` is built for: it redirects to that `url`, same posture as Etsy's listing-page redirect.
10
+ - **"Checkout on Instagram/Facebook"** — buying happens natively inside the Meta app, with **no exposed URL or API to drive it at all** — not even a redirect is possible here. Meta Commerce Orders from *this* population are the only ones `get_order` can ever see; this adapter can't originate a purchase for them, only read one back after the fact.
11
+
12
+ | UCP capability | Backing Graph API | Notes |
13
+ |---|---|---|
14
+ | `dev.ucp.shopping.catalog` | Commerce Catalog | `search_catalog`, `get_product` |
15
+ | `dev.ucp.shopping.checkout` | — | `create_checkout`/`get_checkout` only, redirect-link, "checkout on your website" catalogs only. `update_checkout`/`complete_checkout`/`cancel_checkout` raise `Portage::Ucp::NotImplementedError` — nothing to call. |
16
+ | `dev.ucp.shopping.order` | Commerce Orders | `get_order` — only returns data for "checkout on Instagram/Facebook" merchants; 403/404s for everyone else, since their orders live entirely in their own system |
17
+ | `dev.ucp.shopping.cart` | — | not implemented; no cart resource exists |
18
+ | `dev.ucp.shopping.identity` | — | not implemented; Instagram/Facebook user login is a separate concern from the Page/catalog token used here |
19
+
20
+ Same as Etsy: `create_checkout`'s Checkout objects are **not real Meta resources** — they live only in the `Adapter` instance's memory (`get_checkout` reads back what `create_checkout` stored), not surviving a process restart. `get_order`'s `checkout_id` is always blank for the same reason.
21
+
22
+ ## Installation
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem "portage-ucp-instagram"
27
+ ```
28
+
29
+ ```bash
30
+ bundle install
31
+ ```
32
+
33
+ ## Setup
34
+
35
+ You need a long-lived Page/catalog access token and your Commerce Catalog id.
36
+
37
+ ```ruby
38
+ require "portage/ucp/instagram"
39
+
40
+ client = Portage::Ucp::Instagram::Client.new(access_token: ENV.fetch("INSTAGRAM_ACCESS_TOKEN"))
41
+ adapter = Portage::Ucp::Instagram::Adapter.new(client: client, catalog_id: ENV.fetch("INSTAGRAM_CATALOG_ID"))
42
+ ```
43
+
44
+ ### Getting a long-lived access token
45
+
46
+ The initial short-lived token comes from Meta's interactive Business Login consent flow (outside this gem's scope). Exchange it for a long-lived one (~60 days):
47
+
48
+ ```ruby
49
+ fetcher = Portage::Ucp::Instagram::AccessTokenFetcher.new(
50
+ client_id: ENV.fetch("INSTAGRAM_CLIENT_ID"),
51
+ client_secret: ENV.fetch("INSTAGRAM_CLIENT_SECRET"),
52
+ short_lived_token: ENV.fetch("INSTAGRAM_SHORT_LIVED_TOKEN")
53
+ )
54
+
55
+ result = fetcher.fetch
56
+ result.access_token # => pass into Client.new
57
+ result.expires_in # => ~5,184,000 seconds (60 days) — re-run Business Login after that, no refresh grant exists
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ```ruby
63
+ # Catalog — product_id is the Graph API product node id
64
+ products = adapter.search_catalog(query: "mug", limit: 10)
65
+ product = adapter.get_product(product_id: products.first.id)
66
+
67
+ # Checkout — a redirect, not a real transaction
68
+ checkout = adapter.create_checkout(
69
+ line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
70
+ idempotency_key: SecureRandom.uuid
71
+ )
72
+ checkout.links.first.url # => hand this to the shopper/agent to complete the purchase on the merchant's site
73
+
74
+ # Order — only works for "checkout on Instagram/Facebook" merchants
75
+ order = adapter.get_order(order_id: some_commerce_order_id)
76
+ ```
77
+
78
+ ## Wiring into portage-ucp
79
+
80
+ Drop the adapter into a `Dispatcher` (or the MCP server) the same as any other backend:
81
+
82
+ ```ruby
83
+ dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)
84
+
85
+ dispatcher.call(
86
+ capability: "dev.ucp.shopping.checkout",
87
+ action: "create_checkout",
88
+ arguments: { line_items: [{ product_id: product_node_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
89
+ )
90
+ ```
91
+
92
+ ## Errors
93
+
94
+ ```ruby
95
+ Portage::Ucp::Instagram::Error # base class
96
+ Portage::Ucp::Instagram::ApiError # any non-2xx response from Meta's Graph API
97
+ ```
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ bundle exec rspec # tests (WebMock-stubbed, no live Meta account needed)
103
+ bundle exec rubocop # lint
104
+
105
+ # exchange a real short-lived token for a long-lived one
106
+ INSTAGRAM_CLIENT_ID=... INSTAGRAM_CLIENT_SECRET=... INSTAGRAM_SHORT_LIVED_TOKEN=... \
107
+ bundle exec rake instagram_access_token
108
+ ```
109
+
110
+ ## License
111
+
112
+ [MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
@@ -0,0 +1,41 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Instagram
7
+ # Exchanges a short-lived user/page access token for a long-lived one
8
+ # (~60 days) via Graph API's `fb_exchange_token` grant.
9
+ #
10
+ # Doesn't handle the *initial* Business Login consent redirect that
11
+ # produces the short-lived token in the first place (that's an
12
+ # interactive, one-time setup step outside this gem's scope) — only
13
+ # the long-lived exchange a server needs to avoid re-running consent
14
+ # every few hours. Meta doesn't support refreshing past the ~60-day
15
+ # long-lived token's own expiry — re-running consent is the only way
16
+ # past that, there's no refresh_token here the way Etsy/Shopify have.
17
+ class AccessTokenFetcher
18
+ Result = Struct.new(:access_token, :expires_in, keyword_init: true)
19
+
20
+ def initialize(client_id:, client_secret:, short_lived_token:, api_version: Client::DEFAULT_API_VERSION)
21
+ @client_id = client_id
22
+ @client_secret = client_secret
23
+ @short_lived_token = short_lived_token
24
+ @api_version = api_version
25
+ end
26
+
27
+ def fetch
28
+ params = { grant_type: "fb_exchange_token", client_id: @client_id, client_secret: @client_secret,
29
+ fb_exchange_token: @short_lived_token }
30
+ uri = URI("https://graph.facebook.com/#{@api_version}/oauth/access_token?#{URI.encode_www_form(params)}")
31
+
32
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(Net::HTTP::Get.new(uri)) }
33
+ body = JSON.parse(response.body)
34
+ raise Portage::Ucp::Instagram::Error, "token exchange failed: #{body}" unless response.is_a?(Net::HTTPSuccess)
35
+
36
+ Result.new(access_token: body["access_token"], expires_in: body["expires_in"])
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,118 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Instagram
7
+ # Generic Portage::Ucp::Adapter over Meta's Graph API Commerce Catalog
8
+ # — no merchant-specific business logic, same posture as the other
9
+ # adapters in this project.
10
+ #
11
+ # IMPORTANT: like Portage::Ucp::Etsy::Adapter, this is deliberately a
12
+ # **catalog + redirect-link checkout + order** adapter, not a full
13
+ # transactional one — and for a more fundamental reason than Etsy.
14
+ # Instagram/Facebook Shops split into two populations:
15
+ #
16
+ # - "Checkout on your website" catalogs: each product carries its own
17
+ # merchant-hosted `url`. Buying happens entirely on the merchant's
18
+ # own site, not through Meta at all — this is the case this
19
+ # adapter's `create_checkout` is built for, redirecting to that
20
+ # `url` per product, same posture as Etsy's listing-page redirect.
21
+ # - "Checkout on Instagram/Facebook" catalogs: buying happens
22
+ # natively inside the Meta app, with **no exposed URL or API to
23
+ # drive it** at all — not even a redirect is possible here. Meta
24
+ # orders from *this* population are the only ones `get_order` can
25
+ # ever see (see Portage::Ucp::Instagram::Mapper.order's comment);
26
+ # this adapter can't originate a purchase for them, only read one
27
+ # back after the fact.
28
+ #
29
+ # Same as Etsy: doesn't override `get_cart`/`create_cart`/
30
+ # `update_cart`/`cancel_cart` (no cart resource exists),
31
+ # `update_checkout`/`complete_checkout`/`cancel_checkout` (nothing to
32
+ # update/complete/cancel programmatically — calling them raises
33
+ # Portage::Ucp::NotImplementedError), or `link_identity` (Instagram/
34
+ # Facebook user login is a separate concern from the Page/catalog
35
+ # token used here). `create_checkout`'s Checkout objects are **not
36
+ # real Meta resources** — they live only in this Adapter instance's
37
+ # memory, same as Etsy's.
38
+ class Adapter < Portage::Ucp::Adapter
39
+ PRODUCT_FIELDS = "id,name,description,price,availability,url,item_group_id".freeze
40
+
41
+ # §9a dedup via Support::Idempotency, so an agent's retry on a
42
+ # dropped connection doesn't build a second, different in-memory
43
+ # Checkout for the same intent.
44
+ include Portage::Ucp::Support::Idempotency
45
+
46
+ def initialize(client:, catalog_id:)
47
+ super()
48
+ @client = client
49
+ @catalog_id = catalog_id
50
+ @checkouts = {}
51
+ end
52
+
53
+ def search_catalog(query:, limit:)
54
+ filter = URI.encode_www_form_component(JSON.generate(name: { i_contains: query }))
55
+ data = @client.get("/#{@catalog_id}/products?fields=#{PRODUCT_FIELDS}&limit=#{limit}&filter=#{filter}")
56
+ (data["data"] || []).map { |node| Mapper.product(node) }
57
+ end
58
+
59
+ def get_product(product_id:)
60
+ node = @client.get("/#{product_id}?fields=#{PRODUCT_FIELDS}")
61
+ return nil unless node["id"]
62
+
63
+ Mapper.product(with_variants(node))
64
+ rescue Portage::Ucp::Instagram::ApiError => e
65
+ raise unless [400, 404].include?(e.status)
66
+
67
+ nil
68
+ end
69
+
70
+ def create_checkout(line_items:, idempotency_key:)
71
+ dedup(idempotency_key) do
72
+ products = line_items.map { |li| product_with_quantity(li) }
73
+ checkout_id = "instagram-checkout-#{idempotency_key}"
74
+ checkout = Mapper.checkout(products, id: checkout_id, status: "incomplete")
75
+ @checkouts[checkout_id] = checkout
76
+ checkout
77
+ end
78
+ end
79
+
80
+ def get_checkout(checkout_id:)
81
+ @checkouts[checkout_id]
82
+ end
83
+
84
+ # See the class-level comment: this only ever returns data for
85
+ # "Checkout on Instagram/Facebook" merchants — everyone else's
86
+ # orders live entirely outside Meta's system.
87
+ def get_order(order_id:)
88
+ fields = "id,order_status,items{retailer_id,product_name,quantity,price_per_unit}," \
89
+ "estimated_payment_details"
90
+ node = @client.get("/#{order_id}?fields=#{fields}")
91
+ node["id"] ? Mapper.order(node) : nil
92
+ rescue Portage::Ucp::Instagram::ApiError => e
93
+ raise unless [400, 403, 404].include?(e.status)
94
+
95
+ nil
96
+ end
97
+
98
+ private
99
+
100
+ # A product's own resource only carries its `item_group_id`, not
101
+ # its siblings — fetching the rest of the variant group is a second
102
+ # call, only made for #get_product's single-product path, same N+1
103
+ # reasoning as every other adapter's variant fetch.
104
+ def with_variants(node)
105
+ return node unless node["item_group_id"]
106
+
107
+ filter = URI.encode_www_form_component(JSON.generate(item_group_id: { eq: node["item_group_id"] }))
108
+ data = @client.get("/#{@catalog_id}/products?fields=id,name,availability,price&filter=#{filter}")
109
+ node.merge("variants_detail" => data["data"])
110
+ end
111
+
112
+ def product_with_quantity(line_item)
113
+ @client.get("/#{line_item[:product_id]}?fields=id,name,price,url").merge("quantity" => line_item[:quantity])
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,35 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Instagram
7
+ # Minimal REST client over Meta's Graph API
8
+ # (`graph.facebook.com/{api_version}`), used for both Instagram and
9
+ # Facebook Shops — they share one Commerce Catalog underneath.
10
+ #
11
+ # Deliberately plain Net::HTTP, not the `koala`/`facebook-ads-sdk`
12
+ # gems — a generic adapter that any Ruby app can drop in only needs a
13
+ # base URL and a bearer token, trivially stubbable with WebMock.
14
+ class Client
15
+ include Portage::Ucp::Support::HttpClient
16
+
17
+ DEFAULT_API_VERSION = "v21.0".freeze
18
+
19
+ def initialize(access_token:, api_version: DEFAULT_API_VERSION)
20
+ @access_token = access_token
21
+ @api_version = api_version
22
+ end
23
+
24
+ def get(path)
25
+ json_request(Net::HTTP::Get, "https://graph.facebook.com/#{@api_version}#{path}",
26
+ headers: { "Authorization" => "Bearer #{@access_token}" })
27
+ end
28
+
29
+ private
30
+
31
+ def api_error_class = Portage::Ucp::Instagram::ApiError
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,24 @@
1
+ module Portage
2
+ module Ucp
3
+ module Instagram
4
+ class Error < StandardError; end
5
+
6
+ # Raised for any non-2xx response from Meta's Graph API
7
+ # (`graph.facebook.com`) — a non-2xx status with a JSON
8
+ # `{error: {message, type, code}}` body.
9
+ class ApiError < Error
10
+ include Portage::Ucp::Support::ApiError
11
+
12
+ private
13
+
14
+ # Not just "Instagram": the same client and errors cover Facebook
15
+ # Shops, since both sit on one Meta Graph API surface.
16
+ def api_label = "Instagram/Graph"
17
+
18
+ def detail(body)
19
+ body.dig("error", "message") || body
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,146 @@
1
+ module Portage
2
+ module Ucp
3
+ module Instagram
4
+ # Converts Meta Graph API Commerce Catalog/Orders response bodies
5
+ # into the protocol-layer value objects from
6
+ # Portage::Ucp::ValueObjects — nothing Meta-shaped is allowed to leak
7
+ # past this file.
8
+ module Mapper
9
+ module_function
10
+
11
+ AVAILABLE_STATES = ["in stock", "available for order", "preorder"].freeze
12
+
13
+ # Meta's Catalog product `price` field is a single string combining
14
+ # amount and currency (`"25.00 USD"`), unlike every REST-based
15
+ # adapter in this project splitting those into separate fields —
16
+ # this is the one place that string needs parsing.
17
+ def money(price_string)
18
+ return Portage::Ucp::Money.new(amount_minor: 0, currency: nil) unless price_string
19
+
20
+ amount, currency = price_string.split
21
+ money_from_parts(amount, currency)
22
+ end
23
+
24
+ # Unlike the catalog Product's combined `"25.00 USD"` price
25
+ # string, the Commerce Orders API's amounts come as separate
26
+ # `amount`/`currency` fields — this is the shape #order and
27
+ # #order_line_item work with.
28
+ def money_from_parts(amount, currency)
29
+ Portage::Ucp::Support::Amounts.money(amount, currency)
30
+ end
31
+
32
+ # `node["variants_detail"]` is adapter-populated, not a real Meta
33
+ # field: variants of a catalog product are just *other whole
34
+ # product nodes* sharing the same `item_group_id` — there's no
35
+ # nested variant array to read off a single product the way
36
+ # Shopify/BigCommerce have one. Fetching the group's other members
37
+ # is a second call, made only for #get_product's single-product
38
+ # path, same N+1 reasoning as every other adapter's variant fetch.
39
+ def product(node, site_url: nil)
40
+ Portage::Ucp::Product.new(
41
+ id: node["id"],
42
+ title: node["name"],
43
+ description: node["description"],
44
+ price: money(node["price"]),
45
+ available: AVAILABLE_STATES.include?(node["availability"]),
46
+ variants: variants(node),
47
+ url: node["url"] || site_url
48
+ )
49
+ end
50
+
51
+ # A product with no `item_group_id` siblings is its own single
52
+ # implicit variant, same as a single-variant Shopify product using
53
+ # that variant's own id.
54
+ def variants(node)
55
+ detail = node["variants_detail"]
56
+ return [variant(node)] unless detail
57
+
58
+ detail.map { |v| variant(v) }
59
+ end
60
+
61
+ def variant(node)
62
+ { id: node["id"], title: node["name"], available: AVAILABLE_STATES.include?(node["availability"]),
63
+ price: money(node["price"]) }
64
+ end
65
+
66
+ # `id:` is caller-supplied: there's no real Meta checkout resource
67
+ # behind this at all for "checkout on your website" catalogs — see
68
+ # Portage::Ucp::Instagram::Adapter's class-level comment. `links`
69
+ # points at each product's own `url` (the merchant's own product
70
+ # page) rather than a cart, since Instagram/Facebook's public API
71
+ # has no way to deep-link a multi-item add-to-cart flow outside
72
+ # Meta's own native checkout.
73
+ def checkout(products, id:, status:)
74
+ line_items = products.map { |p| checkout_line_item(p) }
75
+ Portage::Ucp::Checkout.new(
76
+ id: id,
77
+ status: status,
78
+ line_items: line_items,
79
+ currency: products.first && money(products.first["price"]).currency,
80
+ totals: totals(products),
81
+ links: products.map { |p| Portage::Ucp::Link.new(type: "checkout", url: p["url"], title: p["name"]) }
82
+ )
83
+ end
84
+
85
+ def checkout_line_item(node)
86
+ unit_price = money(node["price"]).amount_minor
87
+ quantity = node["quantity"] || 1
88
+ line_total = unit_price * quantity
89
+ Portage::Ucp::LineItem.new(
90
+ id: node["id"],
91
+ item: Portage::Ucp::Item.new(id: node["id"], title: node["name"], price: unit_price),
92
+ quantity: quantity,
93
+ totals: Portage::Ucp::Support::Totals.line(line_total)
94
+ )
95
+ end
96
+
97
+ def totals(products)
98
+ subtotal = products.sum { |p| money(p["price"]).amount_minor * (p["quantity"] || 1) }
99
+ Portage::Ucp::Support::Totals.summary(subtotal: subtotal, total: subtotal)
100
+ end
101
+
102
+ # Meta's Commerce Order resource only exists at all for "Checkout on
103
+ # Instagram/Facebook" merchants — merchants using "checkout on your
104
+ # website" (the population Checkout#links above is built for) never
105
+ # have a Meta-side order; their orders live entirely in their own
106
+ # system (e.g. via the Shopify/WooCommerce/etc adapter for that
107
+ # side), and this method will 403/404 for them. `permalink_url` is
108
+ # left blank — Meta doesn't return a buyer-facing order link via
109
+ # this API. `checkout_id` is always blank too, same fundamental
110
+ # reason as Portage::Ucp::Etsy::Mapper.order.
111
+ ORDER_STATUS = { "COMPLETED" => "fulfilled", "CANCELLED" => "removed" }.freeze
112
+
113
+ def order(node)
114
+ status = Portage::Ucp::Support::LineItemStatus.from_table(ORDER_STATUS, node.dig("order_status", "state"))
115
+ items = node.dig("items", "data") || []
116
+ subtotal = money_from_parts(node.dig("estimated_payment_details", "subtotal", "amount"), nil).amount_minor
117
+ total = money_from_parts(node.dig("estimated_payment_details", "total_amount", "amount"), nil).amount_minor
118
+ Portage::Ucp::Order.new(
119
+ id: node["id"],
120
+ checkout_id: "",
121
+ permalink_url: "",
122
+ line_items: items.map { |n| order_line_item(n, status) },
123
+ fulfillment: Portage::Ucp::Fulfillment.new,
124
+ currency: node.dig("estimated_payment_details", "total_amount", "currency"),
125
+ totals: Portage::Ucp::Support::Totals.summary(subtotal: subtotal, total: total)
126
+ )
127
+ end
128
+
129
+ def order_line_item(node, status)
130
+ quantity = node["quantity"]
131
+ fulfilled = Portage::Ucp::Support::LineItemStatus.fulfilled_quantity(status, quantity)
132
+ unit_price = money_from_parts(node.dig("price_per_unit", "amount"), nil).amount_minor
133
+ line_total = unit_price * quantity
134
+ Portage::Ucp::OrderLineItem.new(
135
+ id: node["id"].to_s,
136
+ item: Portage::Ucp::Item.new(id: node["retailer_id"].to_s, title: node["product_name"],
137
+ price: unit_price),
138
+ quantity: { original: quantity, total: quantity, fulfilled: fulfilled },
139
+ totals: Portage::Ucp::Support::Totals.line(line_total),
140
+ status: status
141
+ )
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,7 @@
1
+ module Portage
2
+ module Ucp
3
+ module Instagram
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ require "portage/ucp"
2
+ require_relative "instagram/version"
3
+ require_relative "instagram/errors"
4
+ require_relative "instagram/client"
5
+ require_relative "instagram/access_token_fetcher"
6
+ require_relative "instagram/mapper"
7
+ require_relative "instagram/adapter"
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-ucp-instagram
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 Meta's Graph API Commerce Catalog.
84
+ Catalog is real; checkout is redirect-link only (to each product's own merchant-hosted
85
+ URL) since Meta's public API has no cart/checkout endpoint for website-checkout
86
+ catalogs, and none at all for native Instagram/Facebook checkout. Generic only —
87
+ no merchant-specific business logic. Plain Net::HTTP, no Facebook SDK runtime dependency.
88
+ email:
89
+ executables: []
90
+ extensions: []
91
+ extra_rdoc_files: []
92
+ files:
93
+ - CHANGELOG.md
94
+ - LICENSE
95
+ - README.md
96
+ - lib/portage/ucp/instagram.rb
97
+ - lib/portage/ucp/instagram/access_token_fetcher.rb
98
+ - lib/portage/ucp/instagram/adapter.rb
99
+ - lib/portage/ucp/instagram/client.rb
100
+ - lib/portage/ucp/instagram/errors.rb
101
+ - lib/portage/ucp/instagram/mapper.rb
102
+ - lib/portage/ucp/instagram/version.rb
103
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-instagram
104
+ licenses:
105
+ - MIT
106
+ metadata:
107
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-instagram
108
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-instagram/CHANGELOG.md
109
+ rubygems_mfa_required: 'true'
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '3.2'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.5.22
126
+ signing_key:
127
+ specification_version: 4
128
+ summary: Instagram/Facebook Shops adapter for portage-ucp — catalog/order over MCP
129
+ and UCP, redirect-link checkout
130
+ test_files: []