portage-ucp 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9198c70ed7abc380268adc344d7496c049a3c78c0ed28b16944e3b27d9d01ba
4
- data.tar.gz: c076c1b4e544825d0db160bf2453fed3e256b2024c7aafb8f431227ec22364ef
3
+ metadata.gz: 8ce343c7abccfbe4c1a8cf2ed0db6340d588c770e959fef42aec644ef6a69800
4
+ data.tar.gz: 7d1a96bb2e150b822c1dedbe95b8b3f14fbced7d6be9b5287e120e3e8152059f
5
5
  SHA512:
6
- metadata.gz: 80aac639014575e4416c3e477f451bf9cb25b3cd69ab8a3cda512bd19243d51a95b54c5086b738fc9140be4e0318cb17b81a30767f2ba6b18546b6cd22e9a33b
7
- data.tar.gz: dc0f578ee8d379f9f12c4605dcaf37757d5518f2764087186669a4809773b5bd869192328a0027ef61fd36f5a4dbc629697841e57f9065c96a9a5d5d7796c11a
6
+ metadata.gz: 0cd44b69ef19d804c91696b6477e3616eb9dd64c326754f5ff7f142ff494bdaf42b1bd4686888dcb1d7c6e6198b737cc501384278a9a6d2441173b6191029eab
7
+ data.tar.gz: 22a096bb7ab8cfaa6285ad12b8c5dfd8098c40f4f538115688e03ad5eac59273f16eca247d1409e28f8f30c730d9ef7cb7c7a5d19586bdacb0c0f002f0b61e9a
data/CHANGELOG.md CHANGED
@@ -4,7 +4,81 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
- ## [0.1.0] - Unreleased
7
+ ## [0.3.0] - 2026-08-27
8
+
9
+ - `Portage::Ucp::Support::Retry` (`lib/portage/ucp/support/retry.rb`) —
10
+ bounded retry with backoff for adapters, plus normalized conflict/throttle
11
+ errors on `Support::ApiError` so a caller can distinguish "retry this" from
12
+ "don't."
13
+ - `Portage::Ucp::Support::SessionLock` (`lib/portage/ucp/support/session_lock.rb`)
14
+ — serializes per-cart/checkout mutations against a single upstream session,
15
+ used by the Shopify and Wix adapters to stop concurrent cart writes from
16
+ racing the same checkout.
17
+ - `Support::Idempotency` is now thread-safe under concurrent duplicate calls
18
+ — the dedup table write was not atomic, so two requests with the same
19
+ idempotency key arriving together could both miss the cache and both hit
20
+ the adapter.
21
+ - Conformance kit: the repeated-idempotency-key example no longer passes on
22
+ output equality alone. An adapter wired to a fixed-response test double
23
+ returns identical output whether or not it deduped, so the example now also
24
+ asserts the key reached `Support::Idempotency`'s dedup table when the
25
+ adapter includes that module, and `warn`s (rather than silently passing)
26
+ when it doesn't.
27
+
28
+ - `Portage::Ucp::ReferenceAdapter` (`lib/portage/ucp/reference_adapter.rb`) —
29
+ the in-memory `Adapter` roadmap §8 step 1 called for and design-log §17
30
+ flagged as missing outside `spec/support/fake_adapter.rb`, ships with the
31
+ gem now. Implements every capability including
32
+ `discount_codes_supported?`/`fulfillment_supported?`/`link_identity` — the
33
+ first adapter in this repo to back identity linking at all.
34
+ - `Portage::Ucp::RSpec`/`portage/ucp/rspec.rb` — the adapter conformance kit
35
+ design-log §17 called "the missing piece that turns 'any backend that
36
+ implements Adapter' from a README claim into something checked": an
37
+ `it_behaves_like "a portage adapter"` shared-examples suite checking the
38
+ contract's behavioral guarantees (idempotency dedup, the PAN guard,
39
+ schema-conformant wire output, `OutOfStockError` on a stale-stock line) —
40
+ not loaded by `require "portage/ucp"`, opt-in via `require
41
+ "portage/ucp/rspec"` since it pulls in RSpec itself. Exercised against
42
+ `ReferenceAdapter` in this gem's own suite
43
+ (`spec/reference_adapter_conformance_spec.rb`); wired into each adapter
44
+ gem's own spec suite (`spec/portage/ucp/<platform>/conformance_spec.rb`)
45
+ as follow-up.
46
+ - Conformance kit: `existing_variant_id` alongside `existing_product_id`, for
47
+ adapters (Shopify) where a catalog lookup id and a cart line-item id are
48
+ different GIDs. Defaults to `existing_product_id`, so every other adapter
49
+ is unaffected.
50
+ - `search_catalog`/`get_product` output is schema-wrapped like every other
51
+ capability now — previously returned a bare array/`Product` with no
52
+ `to_wire_h`, so the dispatcher's schema-wrap never touched it and nothing
53
+ caught it drifting from `catalog_search.json`/`catalog_lookup.json`.
54
+
55
+ ## [0.2.0] - 2026-08-21
56
+
57
+ - `Portage::Ucp::OutOfStockError` — the contract for `#complete_checkout`
58
+ (design-log §16 "Stock/availability going stale") now documents that
59
+ adapters should raise it when the platform rejects completion over a
60
+ no-longer-available line item, instead of re-checking with a separate call
61
+ agents could forget to make.
62
+ - `Adapter#cancel_order`, `#request_return`, `#refund_order` — a gem-side
63
+ extension of `dev.ucp.shopping.order` (design-log §16 "Order changes"),
64
+ since the real UCP spec's order lifecycle is get-only. Each returns the
65
+ updated `Order`, with the change recorded as an appended
66
+ `Portage::Ucp::Adjustment`.
67
+ - `Capability#predicate` — a minimal escape hatch for extensions that add
68
+ fields rather than actions (`dev.ucp.shopping.discount`,
69
+ `dev.ucp.shopping.fulfillment`): a capability can name an adapter method
70
+ instead of an action set, and `#advertised_for?` asks it directly.
71
+ `create_cart`/`update_cart`/`create_checkout`/`update_checkout` gain an
72
+ optional `discount_codes:` param defaulting to `nil` (not `[]`), so "not
73
+ mentioned" and "clear the codes" stay distinguishable.
74
+ - `dev.ucp.shopping.fulfillment` — the vendored extension for picking a
75
+ shipping method/rate or pickup location during checkout. New value objects
76
+ `PostalAddress`, `ShippingDestination`, `RetailLocation`,
77
+ `FulfillmentOption`, `FulfillmentGroup`, `FulfillmentMethod`, and the
78
+ `CheckoutFulfillment` container (named apart from `Fulfillment`, which
79
+ Order's post-purchase container already owns — see design-log).
80
+
81
+ ## [0.1.0] - 2026-08-14
8
82
 
9
83
  - Initial pre-release. Protocol-only core: `Adapter` contract, capability
10
84
  registry, manifest builder, MCP server wrapper, offline `SchemaValidator`,
@@ -4,9 +4,11 @@ module Portage
4
4
  # Unoverridden methods leave that capability out of the manifest.
5
5
  class Adapter
6
6
  # --- Catalog (dev.ucp.shopping.catalog) ---
7
- # @return [Array<Portage::Ucp::Product>]
7
+ # @return [Portage::Ucp::CatalogSearchResult]
8
8
  def search_catalog(query:, limit:) = not_implemented
9
- # @return [Portage::Ucp::Product, nil]
9
+ # nil when the product isn't found, same not-found posture as
10
+ # get_cart/get_checkout/get_order.
11
+ # @return [Portage::Ucp::ProductDetail, nil]
10
12
  def get_product(product_id:) = not_implemented
11
13
 
12
14
  # --- Cart (dev.ucp.shopping.cart) ---
@@ -17,24 +19,53 @@ module Portage
17
19
  # builds the response's Item/Total/LineItem itself.
18
20
  # @return [Portage::Ucp::Cart]
19
21
  def get_cart(cart_id:) = not_implemented
22
+ # `discount_codes:` is the dev.ucp.shopping.discount extension — nil
23
+ # (the default) means the request didn't touch discounts at all; an
24
+ # adapter that doesn't override #discount_codes_supported? never sees
25
+ # anything but nil here (see below). Full-replacement like line_items:
26
+ # once codes are involved, [] clears them, same as UCP's own
27
+ # discounts_object semantics.
20
28
  # @return [Portage::Ucp::Cart]
21
- def create_cart(line_items:, idempotency_key:) = not_implemented
29
+ def create_cart(line_items:, idempotency_key:, discount_codes: nil) = not_implemented
22
30
  # @return [Portage::Ucp::Cart]
23
- def update_cart(cart_id:, line_items:, idempotency_key:) = not_implemented
31
+ def update_cart(cart_id:, line_items:, idempotency_key:, discount_codes: nil) = not_implemented
24
32
  # @return [Portage::Ucp::Cart]
25
33
  def cancel_cart(cart_id:, idempotency_key:) = not_implemented
26
34
 
27
35
  # --- Checkout (dev.ucp.shopping.checkout) ---
36
+ # `discount_codes:` carries the same dev.ucp.shopping.discount
37
+ # semantics as create_cart/update_cart above.
38
+ # `fulfillment:` is the dev.ucp.shopping.fulfillment extension — nil
39
+ # (the default) means the request didn't touch fulfillment at all; an
40
+ # adapter that doesn't override #fulfillment_supported? never sees
41
+ # anything but nil here (see below). On create it carries the agent's
42
+ # desired methods (type + line_item_ids per shipping/pickup group —
43
+ # `Portage::Ucp::FulfillmentMethod#id`/`#destinations`/`#groups` are
44
+ # omitted since the merchant generates those); on update it carries the
45
+ # agent's `selected_destination_id`/`selected_option_id` choices against
46
+ # the methods/groups the merchant already returned.
28
47
  # @return [Portage::Ucp::Checkout]
29
- def create_checkout(line_items:, idempotency_key:) = not_implemented
48
+ def create_checkout(line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil) = not_implemented
30
49
  # @return [Portage::Ucp::Checkout]
31
50
  def get_checkout(checkout_id:) = not_implemented
51
+
32
52
  # Full-replacement, same as update_cart — line_items is required on
33
53
  # checkout update per the real spec.
34
54
  # @return [Portage::Ucp::Checkout]
35
- def update_checkout(checkout_id:, line_items:, idempotency_key:) = not_implemented
55
+ def update_checkout(checkout_id:, line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
56
+ not_implemented
57
+ end
58
+
36
59
  # @param payment_token [String] single-use token from a UCP payment handler / AP2
37
60
  # exchange — NEVER a raw PAN.
61
+ # Re-checks stock at the point of committing money, since search_catalog/
62
+ # get_product (dev.ucp.shopping.catalog) don't promise live inventory and
63
+ # nothing else re-checks between browsing and buying. An adapter whose
64
+ # platform rejects completion because a line item is out of stock or
65
+ # otherwise unavailable should raise Portage::Ucp::OutOfStockError rather
66
+ # than a generic/platform error, so callers can distinguish a stale-stock
67
+ # failure from e.g. a declined payment.
68
+ # @raise [Portage::Ucp::OutOfStockError] if a line item is no longer available
38
69
  # @return [Portage::Ucp::Checkout]
39
70
  def complete_checkout(checkout_id:, payment_token:, idempotency_key:) = not_implemented
40
71
  # @return [Portage::Ucp::Checkout]
@@ -43,6 +74,37 @@ module Portage
43
74
  # --- Order (dev.ucp.shopping.order) ---
44
75
  # @return [Portage::Ucp::Order, nil]
45
76
  def get_order(order_id:) = not_implemented
77
+ # Cancels a placed order. `reason` is an optional human-readable note,
78
+ # not a closed enum — the platform-specific enum mapping (if any) is an
79
+ # adapter concern.
80
+ # @return [Portage::Ucp::Order]
81
+ def cancel_order(order_id:, idempotency_key:, reason: nil) = not_implemented
82
+ # Requests a return for one or more order line items. `line_items` is an
83
+ # array of request-shaped hashes (`{id:, quantity:}`, unsigned) — same
84
+ # request/response asymmetry as `create_cart`'s `line_items:`. A return
85
+ # is a request the merchant still has to process; it shows up as a
86
+ # `pending` `Portage::Ucp::Adjustment` until they do.
87
+ # @return [Portage::Ucp::Order]
88
+ def request_return(order_id:, line_items:, idempotency_key:, reason: nil) = not_implemented
89
+ # Refunds one or more order line items.
90
+ # @return [Portage::Ucp::Order]
91
+ def refund_order(order_id:, line_items:, idempotency_key:, reason: nil) = not_implemented
92
+
93
+ # --- Discount (dev.ucp.shopping.discount) ---
94
+ # Extends Cart/Checkout with the `discount_codes:` param above rather
95
+ # than adding actions of its own — Capability::DISCOUNT advertises off
96
+ # this predicate instead of an overridden action method, since there's
97
+ # no dedicated method for #advertised_for? to detect an override on.
98
+ # @return [Boolean]
99
+ def discount_codes_supported? = false
100
+
101
+ # --- Fulfillment (dev.ucp.shopping.fulfillment) ---
102
+ # Extends Checkout with the `fulfillment:` param above rather than
103
+ # adding actions of its own — Capability::FULFILLMENT advertises off
104
+ # this predicate instead of an overridden action method, same rationale
105
+ # as #discount_codes_supported? above.
106
+ # @return [Boolean]
107
+ def fulfillment_supported? = false
46
108
 
47
109
  # --- Identity Linking (dev.ucp.shopping.identity, OAuth 2.0) ---
48
110
  # @return [Portage::Ucp::Identity] linked profile for an exchanged OAuth token
@@ -0,0 +1,16 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # schemas/shopping/discount.json — extends Cart/Checkout with a
5
+ # `discounts` field rather than adding actions of its own, so `actions`
6
+ # is empty and advertisement runs off Adapter#discount_codes_supported?
7
+ # instead (see Capability#advertised_for?).
8
+ DISCOUNT = Portage::Ucp::Capability.new(
9
+ name: "dev.ucp.shopping.discount",
10
+ version: "1",
11
+ actions: {},
12
+ predicate: :discount_codes_supported?
13
+ )
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # schemas/shopping/fulfillment.json — extends Checkout with a
5
+ # `fulfillment` field (shipping/pickup methods, groups, destinations)
6
+ # rather than adding actions of its own, same predicate-based
7
+ # advertisement as DISCOUNT above.
8
+ FULFILLMENT = Portage::Ucp::Capability.new(
9
+ name: "dev.ucp.shopping.fulfillment",
10
+ version: "1",
11
+ actions: {},
12
+ predicate: :fulfillment_supported?
13
+ )
14
+ end
15
+ end
16
+ end
@@ -7,7 +7,7 @@ module Portage
7
7
  actions: { "link_identity" => :link_identity }
8
8
  )
9
9
 
10
- ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING].freeze
10
+ ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT].freeze
11
11
  end
12
12
  end
13
13
  end
@@ -4,7 +4,12 @@ module Portage
4
4
  ORDER = Portage::Ucp::Capability.new(
5
5
  name: "dev.ucp.shopping.order",
6
6
  version: "1",
7
- actions: { "get_order" => :get_order }
7
+ actions: {
8
+ "get_order" => :get_order,
9
+ "cancel_order" => :cancel_order,
10
+ "request_return" => :request_return,
11
+ "refund_order" => :refund_order
12
+ }
8
13
  )
9
14
  end
10
15
  end
@@ -5,15 +5,25 @@ module Portage
5
5
  class Capability
6
6
  attr_reader :name, :version, :actions
7
7
 
8
- def initialize(name:, version:, actions:)
8
+ # `predicate:` is for extension capabilities like
9
+ # dev.ucp.shopping.discount and dev.ucp.shopping.fulfillment that add a
10
+ # param to an existing action rather than an action of their own —
11
+ # there's no dedicated method whose override signals support, so the
12
+ # adapter exposes a boolean method instead and the capability asks it
13
+ # directly rather than inspecting `actions`.
14
+ def initialize(name:, version:, actions:, predicate: nil)
9
15
  @name = name
10
16
  @version = version
11
17
  @actions = actions
18
+ @predicate = predicate
12
19
  end
13
20
 
14
- # Advertised only if at least one backing Adapter method is overridden —
21
+ # Advertised if the predicate says so, or (for ordinary action-based
22
+ # capabilities) if at least one backing Adapter method is overridden —
15
23
  # see Portage::Ucp::Adapter for why (contract can grow without breaking adapters).
16
24
  def advertised_for?(adapter)
25
+ return adapter.public_send(@predicate) if @predicate
26
+
17
27
  actions.values.any? { |method_name| overridden?(adapter, method_name) }
18
28
  end
19
29
 
@@ -8,5 +8,35 @@ module Portage
8
8
  class AuthenticationError < Error; end
9
9
  class RawPanRejectedError < Error; end
10
10
  class RateLimitExceededError < Error; end
11
+ # Raised by #complete_checkout when the platform rejects completion
12
+ # because a line item is out of stock or otherwise unavailable —
13
+ # design-log §16 "Stock/availability going stale": the gem re-checks by
14
+ # surfacing the platform's own rejection rather than adding a separate
15
+ # check_availability call agents could forget to make. Maps to UCP's
16
+ # standardized "out_of_stock"/"item_unavailable" error codes
17
+ # (schemas/shopping/types/error_code.json).
18
+ class OutOfStockError < Error; end
19
+ # Raised when a mutation collides with a concurrent write upstream (HTTP
20
+ # 409, or a platform's own optimistic-concurrency rejection) — the
21
+ # resource changed between this call's read and its write. Support::Retry
22
+ # deliberately never retries this itself: the state it read is already
23
+ # stale, so retrying blindly would just collide again. The caller is
24
+ # expected to re-read (get_cart/get_checkout) and resubmit against
25
+ # current state. Maps to UCP's freeform "conflict" error code —
26
+ # error_code.json's examples list isn't exhaustive ("freeform codes are
27
+ # permitted"); "conflict" follows the same snake_case convention as its
28
+ # "out_of_stock"/"payment_failed" examples, same justification
29
+ # OutOfStockError above relies on.
30
+ class ConflictError < Error; end
31
+ # Raised when Support::Retry exhausts its bounded backoff against a
32
+ # genuinely-retryable upstream throttle (Shopify GraphQL THROTTLED, HTTP
33
+ # 429, or cartSubmitForCompletion's SubmitThrottled pollAfter) and the
34
+ # platform still hasn't done the work. Named apart from
35
+ # RateLimitExceededError — that one is this gem's own pluggable
36
+ # RateLimiter rejecting a call before it ever reaches the network; this
37
+ # is the upstream platform itself refusing after every retry. Maps to
38
+ # UCP's freeform "rate_limited" error code, same convention as
39
+ # ConflictError above.
40
+ class UpstreamThrottledError < Error; end
11
41
  end
12
42
  end
@@ -0,0 +1,282 @@
1
+ require "digest"
2
+
3
+ module Portage
4
+ module Ucp
5
+ # In-memory Adapter implementing every capability in the contract,
6
+ # including dev.ucp.shopping.discount/fulfillment/identity — roadmap §8
7
+ # step 1 called for one of these ("no real backend required to prove the
8
+ # protocol layer works") and it never shipped; `spec/support/fake_adapter.rb`
9
+ # filled that gap for the core gem's own specs, but stayed test-only,
10
+ # catalog/cart/checkout/order only, and undocumented outside this repo.
11
+ #
12
+ # Two jobs: a copy-paste starting point for a third-party adapter author
13
+ # (see README "Writing your own adapter"), and the fixture the
14
+ # conformance kit (Portage::Ucp::RSpec, lib/portage/ucp/rspec.rb) runs its
15
+ # own shared examples against to prove the kit itself is correct.
16
+ #
17
+ # Not a mock — every mutating action does the real bookkeeping (line-item
18
+ # totals, checkout status transitions, order adjustments) an adapter over
19
+ # a live platform would, just against an in-process Hash instead of an
20
+ # HTTP API. `seed_product` is the one method with no `Adapter` contract
21
+ # counterpart — there is no real backend to seed real data into.
22
+ class ReferenceAdapter < Portage::Ucp::Adapter
23
+ include Portage::Ucp::Support::Idempotency
24
+ include Portage::Ucp::Support::CheckoutState
25
+
26
+ # Products whose id starts with this prefix are treated as sold out —
27
+ # #complete_checkout raises Portage::Ucp::OutOfStockError for them, the
28
+ # same way a real adapter's stock re-check (Adapter#complete_checkout's
29
+ # docs, design-log §16) would. Lets the conformance kit exercise that
30
+ # path without depending on adapter-specific seed data.
31
+ OUT_OF_STOCK_PREFIX = "oos_".freeze
32
+
33
+ def initialize
34
+ super
35
+ @products = {}
36
+ @carts = {}
37
+ @checkouts = {}
38
+ @orders = {}
39
+ @identities = {}
40
+ @next_id = 0
41
+ end
42
+
43
+ # Not part of the Adapter contract — this adapter has no backend to
44
+ # seed real data into, so callers (a spec, a README example) hand it
45
+ # catalog fixtures directly.
46
+ def seed_product(product)
47
+ @products[product.id] = product
48
+ end
49
+
50
+ def search_catalog(query:, limit:)
51
+ matches = @products.values.select { |p| p.title.downcase.include?(query.downcase) }.first(limit)
52
+ Portage::Ucp::CatalogSearchResult.new(products: matches)
53
+ end
54
+
55
+ def get_product(product_id:)
56
+ product = @products[product_id]
57
+ product && Portage::Ucp::ProductDetail.new(product: product)
58
+ end
59
+
60
+ def get_cart(cart_id:) = @carts[cart_id]
61
+
62
+ def create_cart(line_items:, idempotency_key:, discount_codes: nil)
63
+ dedup(idempotency_key) do
64
+ id = next_id("cart")
65
+ @carts[id] = build_cart(id, line_items, discount_codes)
66
+ end
67
+ end
68
+
69
+ def update_cart(cart_id:, line_items:, idempotency_key:, discount_codes: nil)
70
+ dedup(idempotency_key) do
71
+ @carts[cart_id] = build_cart(cart_id, line_items, discount_codes, previous: @carts[cart_id])
72
+ end
73
+ end
74
+
75
+ def cancel_cart(cart_id:, idempotency_key:)
76
+ dedup(idempotency_key) do
77
+ @carts.delete(cart_id)
78
+ Portage::Ucp::Cart.new(id: cart_id, line_items: [], currency: "USD", totals: zero_totals)
79
+ end
80
+ end
81
+
82
+ def create_checkout(line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
83
+ dedup(idempotency_key) do
84
+ id = next_id("chk")
85
+ record_checkout_status(id, "incomplete")
86
+ @checkouts[id] = build_checkout(id, line_items, discount_codes, fulfillment, status: "incomplete")
87
+ end
88
+ end
89
+
90
+ def get_checkout(checkout_id:) = @checkouts[checkout_id]
91
+
92
+ def update_checkout(checkout_id:, line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
93
+ dedup(idempotency_key) do
94
+ record_checkout_status(checkout_id, "incomplete")
95
+ @checkouts[checkout_id] = build_checkout(checkout_id, line_items, discount_codes, fulfillment,
96
+ status: "incomplete", previous: @checkouts[checkout_id])
97
+ end
98
+ end
99
+
100
+ # payment_token: is part of the Adapter contract's call signature (and
101
+ # already validated as non-PAN by PaymentTokenGuard before this ever
102
+ # runs, per §9) but this in-memory adapter has no payment processor to
103
+ # pass it on to — nothing here should hold onto it any longer than the
104
+ # single call needs to, so it's accepted and left unused rather than
105
+ # stored (see .rubocop.yml's Lint/UnusedMethodArgument exclude, same
106
+ # posture as the abstract Adapter#complete_checkout it overrides).
107
+ def complete_checkout(checkout_id:, payment_token:, idempotency_key:)
108
+ dedup(idempotency_key) do
109
+ checkout = @checkouts.fetch(checkout_id)
110
+ raise_if_any_line_out_of_stock!(checkout)
111
+
112
+ order = store_order(checkout)
113
+ confirmation = Portage::Ucp::OrderConfirmation.new(id: order.id, permalink_url: order.permalink_url)
114
+ record_checkout_status(checkout_id, "completed")
115
+ @checkouts[checkout_id] = Portage::Ucp::Checkout.new(**checkout.to_h, status: "completed",
116
+ order: confirmation)
117
+ end
118
+ end
119
+
120
+ def cancel_checkout(checkout_id:, idempotency_key:)
121
+ dedup(idempotency_key) do
122
+ record_checkout_status(checkout_id, "canceled")
123
+ checkout = @checkouts.fetch(checkout_id)
124
+ @checkouts[checkout_id] = Portage::Ucp::Checkout.new(**checkout.to_h, status: "canceled")
125
+ end
126
+ end
127
+
128
+ def get_order(order_id:) = @orders[order_id]
129
+
130
+ def cancel_order(order_id:, idempotency_key:, reason: nil)
131
+ dedup(idempotency_key) do
132
+ add_adjustment(order_id, type: "cancellation", status: "completed", description: reason)
133
+ end
134
+ end
135
+
136
+ def request_return(order_id:, line_items:, idempotency_key:, reason: nil)
137
+ dedup(idempotency_key) do
138
+ add_adjustment(order_id, type: "return", status: "pending", line_items: line_items, description: reason)
139
+ end
140
+ end
141
+
142
+ def refund_order(order_id:, line_items:, idempotency_key:, reason: nil)
143
+ dedup(idempotency_key) do
144
+ add_adjustment(order_id, type: "refund", status: "completed", line_items: line_items, description: reason)
145
+ end
146
+ end
147
+
148
+ def discount_codes_supported? = true
149
+ def fulfillment_supported? = true
150
+
151
+ # Accepts any non-blank token and mints a stable identity for it — real
152
+ # OAuth verification is a platform concern this in-memory adapter has
153
+ # no platform to defer to, same posture as PaymentTokenGuard drawing
154
+ # the line at "rejects the clearest misuse" rather than proving
155
+ # validity.
156
+ def link_identity(oauth_token:)
157
+ raise Portage::Ucp::AuthenticationError, "blank oauth_token" if oauth_token.to_s.empty?
158
+
159
+ @identities[oauth_token] ||= Portage::Ucp::Identity.new(
160
+ subject: "user_#{Digest::SHA256.hexdigest(oauth_token)[0, 12]}",
161
+ email: nil, linked_at: Time.now.utc.iso8601
162
+ )
163
+ end
164
+
165
+ private
166
+
167
+ def add_adjustment(order_id, type:, status:, line_items: [], description: nil)
168
+ order = @orders.fetch(order_id)
169
+ adjustment = Portage::Ucp::Adjustment.new(
170
+ id: next_id("adj"), type: type, occurred_at: Time.now.utc.iso8601, status: status,
171
+ line_items: adjustment_line_items(line_items), totals: adjustment_totals(order, line_items),
172
+ description: description
173
+ )
174
+ @orders[order_id] = Portage::Ucp::Order.new(**order.to_h, adjustments: order.adjustments + [adjustment])
175
+ end
176
+
177
+ def adjustment_line_items(line_items)
178
+ return nil if line_items.empty?
179
+
180
+ line_items.map { |li| { "id" => li[:id], "quantity" => -li[:quantity] } }
181
+ end
182
+
183
+ def adjustment_totals(order, line_items)
184
+ return nil if line_items.empty?
185
+
186
+ amount = line_items.sum do |li|
187
+ order_line = order.line_items.find { |oli| oli.id == li[:id] }
188
+ unit_price = order_line.totals.find { |t| t.type == "total" }.amount / order_line.quantity
189
+ unit_price * li[:quantity]
190
+ end
191
+ [Portage::Ucp::Total.new(type: "total", amount: -amount)]
192
+ end
193
+
194
+ def store_order(checkout)
195
+ id = next_id("ord")
196
+ order = Portage::Ucp::Order.new(
197
+ id: id, checkout_id: checkout.id, permalink_url: "https://example.com/orders/#{id}",
198
+ line_items: checkout.line_items, fulfillment: Portage::Ucp::Fulfillment.new,
199
+ currency: checkout.currency, totals: checkout.totals
200
+ )
201
+ @orders[id] = order
202
+ end
203
+
204
+ def build_cart(id, requested_line_items, discount_codes, previous: nil)
205
+ line_items = build_line_items(requested_line_items)
206
+ discounts = discounts_for(discount_codes, previous&.discounts)
207
+ Portage::Ucp::Cart.new(id: id, line_items: line_items, currency: "USD",
208
+ totals: totals_for(line_items, discounts), discounts: discounts)
209
+ end
210
+
211
+ def build_checkout(id, requested_line_items, discount_codes, fulfillment, status:, previous: nil)
212
+ line_items = build_line_items(requested_line_items)
213
+ discounts = discounts_for(discount_codes, previous&.discounts)
214
+ Portage::Ucp::Checkout.new(
215
+ id: id, status: status, line_items: line_items, currency: "USD",
216
+ totals: totals_for(line_items, discounts), links: [], discounts: discounts,
217
+ fulfillment: fulfillment || previous&.fulfillment || Portage::Ucp::CheckoutFulfillment.new
218
+ )
219
+ end
220
+
221
+ # Fixed 10% off, applied only for the well-known code "SAVE10" — enough
222
+ # for a conformance spec/adapter author to see a real AppliedDiscount
223
+ # round-trip without inventing a discount engine this adapter has no
224
+ # reason to model in full.
225
+ def discounts_for(discount_codes, previous)
226
+ return previous || Portage::Ucp::Discounts.new if discount_codes.nil?
227
+
228
+ applied = if discount_codes.include?("SAVE10")
229
+ [Portage::Ucp::AppliedDiscount.new(title: "10% off",
230
+ amount: 0, code: "SAVE10")]
231
+ else
232
+ []
233
+ end
234
+ Portage::Ucp::Discounts.new(codes: discount_codes, applied: applied)
235
+ end
236
+
237
+ # `req[:product_id]` is looked up against the featured (first) variant
238
+ # — the same variant #search_catalog/#get_product would show as the
239
+ # listing default — since this in-memory adapter's fixtures are
240
+ # single-variant products and Item#id is spec'd as a variant id
241
+ # (types/variant.json: "Used as item.id in checkout"), not a product
242
+ # id.
243
+ def build_line_items(requested)
244
+ requested.map do |req|
245
+ product = @products.fetch(req[:product_id])
246
+ variant = product.variants.first
247
+ total = variant.price.amount * req[:quantity]
248
+ Portage::Ucp::LineItem.new(
249
+ id: next_id("li"),
250
+ item: Portage::Ucp::Item.new(id: variant.id, title: product.title, price: variant.price.amount),
251
+ quantity: req[:quantity],
252
+ totals: [Portage::Ucp::Total.new(type: "subtotal", amount: total),
253
+ Portage::Ucp::Total.new(type: "total", amount: total)]
254
+ )
255
+ end
256
+ end
257
+
258
+ def totals_for(line_items, discounts)
259
+ subtotal = line_items.sum { |li| li.totals.find { |t| t.type == "total" }.amount }
260
+ discount_amount = discounts.applied.sum(&:amount)
261
+ [Portage::Ucp::Total.new(type: "subtotal", amount: subtotal),
262
+ Portage::Ucp::Total.new(type: "total", amount: subtotal - discount_amount)]
263
+ end
264
+
265
+ def zero_totals
266
+ [Portage::Ucp::Total.new(type: "subtotal", amount: 0), Portage::Ucp::Total.new(type: "total", amount: 0)]
267
+ end
268
+
269
+ def raise_if_any_line_out_of_stock!(checkout)
270
+ unavailable = checkout.line_items.select { |li| li.item.id.start_with?(OUT_OF_STOCK_PREFIX) }
271
+ return if unavailable.empty?
272
+
273
+ raise Portage::Ucp::OutOfStockError, "no longer available: #{unavailable.map { |li| li.item.title }.join(', ')}"
274
+ end
275
+
276
+ def next_id(prefix)
277
+ @next_id += 1
278
+ "#{prefix}_#{@next_id}"
279
+ end
280
+ end
281
+ end
282
+ end