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 +4 -4
- data/CHANGELOG.md +75 -1
- data/lib/portage/ucp/adapter.rb +68 -6
- data/lib/portage/ucp/capabilities/discount.rb +16 -0
- data/lib/portage/ucp/capabilities/fulfillment.rb +16 -0
- data/lib/portage/ucp/capabilities/identity_linking.rb +1 -1
- data/lib/portage/ucp/capabilities/order.rb +6 -1
- data/lib/portage/ucp/capability.rb +12 -2
- data/lib/portage/ucp/errors.rb +30 -0
- data/lib/portage/ucp/reference_adapter.rb +282 -0
- data/lib/portage/ucp/rspec.rb +175 -0
- data/lib/portage/ucp/schema_validator.rb +23 -2
- data/lib/portage/ucp/support/api_error.rb +9 -6
- data/lib/portage/ucp/support/http_client.rb +9 -2
- data/lib/portage/ucp/support/idempotency.rb +22 -3
- data/lib/portage/ucp/support/retry.rb +66 -0
- data/lib/portage/ucp/support/session_lock.rb +40 -0
- data/lib/portage/ucp/value_objects.rb +346 -6
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp/wire_envelope.rb +5 -1
- data/lib/portage/ucp.rb +5 -0
- metadata +9 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ce343c7abccfbe4c1a8cf2ed0db6340d588c770e959fef42aec644ef6a69800
|
|
4
|
+
data.tar.gz: 7d1a96bb2e150b822c1dedbe95b8b3f14fbced7d6be9b5287e120e3e8152059f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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`,
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -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 [
|
|
7
|
+
# @return [Portage::Ucp::CatalogSearchResult]
|
|
8
8
|
def search_catalog(query:, limit:) = not_implemented
|
|
9
|
-
#
|
|
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:
|
|
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
|
|
@@ -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: {
|
|
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
|
-
|
|
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
|
|
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
|
|
data/lib/portage/ucp/errors.rb
CHANGED
|
@@ -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
|