Class: Portage::Ucp::ReferenceAdapter
Overview
In-memory Adapter implementing every capability in the contract,
including dev.ucp.shopping.discount/fulfillment/identity — roadmap §8
step 1 called for one of these ("no real backend required to prove the
protocol layer works") and it never shipped; spec/support/fake_adapter.rb
filled that gap for the core gem's own specs, but stayed test-only,
catalog/cart/checkout/order only, and undocumented outside this repo.
Two jobs: a copy-paste starting point for a third-party adapter author
(see README "Writing your own adapter"), and the fixture the
conformance kit (Portage::Ucp::RSpec, lib/portage/ucp/rspec.rb) runs its
own shared examples against to prove the kit itself is correct.
Not a mock — every mutating action does the real bookkeeping (line-item
totals, checkout status transitions, order adjustments) an adapter over
a live platform would, just against an in-process Hash instead of an
HTTP API. seed_product is the one method with no Adapter contract
counterpart — there is no real backend to seed real data into.
Constant Summary
collapse
- OUT_OF_STOCK_PREFIX =
Products whose id starts with this prefix are treated as sold out —
#complete_checkout raises Portage::Ucp::OutOfStockError for them, the
same way a real adapter's stock re-check (Adapter#complete_checkout's
docs, design-log §16) would. Lets the conformance kit exercise that
path without depending on adapter-specific seed data.
"oos_".freeze
Support::Idempotency::INIT_MUTEX, Support::Idempotency::NOT_FOUND
Instance Method Summary
collapse
-
#cancel_cart(cart_id:, idempotency_key:) ⇒ Object
-
#cancel_checkout(checkout_id:, idempotency_key:) ⇒ Object
-
#cancel_order(order_id:, idempotency_key:, reason: nil) ⇒ Object
-
#complete_checkout(checkout_id:, payment_token:, idempotency_key:, mandate: nil) ⇒ Object
payment_token: is part of the Adapter contract's call signature (and already validated as non-PAN by PaymentTokenGuard before this ever runs, per §9) but this in-memory adapter has no payment processor to pass it on to — nothing here should hold onto it any longer than the single call needs to, so it's accepted and left unused rather than stored (see .rubocop.yml's Lint/UnusedMethodArgument exclude, same posture as the abstract Adapter#complete_checkout it overrides).
-
#create_cart(line_items:, idempotency_key:, discount_codes: nil) ⇒ Object
-
#create_checkout(line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil) ⇒ Object
-
#create_payment_enrollment(idempotency_key:, mandate: nil) ⇒ Object
Simulates a real gateway-hosted enrollment: "pending" with a setup_url on the first poll, "complete" with a minted opaque token from the second poll onward — enough for a caller's poll loop to actually exercise both states rather than completing on the first check.
-
#delete_address(oauth_token:, address_id:, idempotency_key:) ⇒ Object
-
#delete_payment_method(oauth_token:, payment_method_id:, idempotency_key:) ⇒ Object
-
#delete_shopper_data(oauth_token:, idempotency_key:) ⇒ Object
Idempotent by construction, not by a guard: counts are captured before deleting, and Hash#delete/size on an already-empty subject naturally return zero — a second call is a no-op, never a raise.
-
#discount_codes_supported? ⇒ Boolean
-
#fulfillment_supported? ⇒ Boolean
-
#get_cart(cart_id:) ⇒ Object
-
#get_checkout(checkout_id:) ⇒ Object
-
#get_order(order_id:) ⇒ Object
-
#get_payment_enrollment(enrollment_id:) ⇒ Object
-
#get_product(product_id:) ⇒ Object
-
#initialize ⇒ ReferenceAdapter
constructor
A new instance of ReferenceAdapter.
-
#link_identity(oauth_token:) ⇒ Object
Accepts any non-blank token and mints a stable identity for it — real OAuth verification is a platform concern this in-memory adapter has no platform to defer to, same posture as PaymentTokenGuard drawing the line at "rejects the clearest misuse" rather than proving validity.
-
#list_addresses(oauth_token:) ⇒ Object
-
#list_payment_methods(oauth_token:) ⇒ Object
-
#refund_order(order_id:, line_items:, idempotency_key:, reason: nil) ⇒ Object
-
#reorder(order_id:, idempotency_key:) ⇒ Object
Not memoized against @carts' own id sequence namespace collision: next_id("cart") is shared with create_cart/update_cart, same as every other *_id prefix here.
-
#request_return(order_id:, line_items:, idempotency_key:, reason: nil) ⇒ Object
-
#save_address(oauth_token:, address:, idempotency_key:) ⇒ Object
-
#save_payment_method(oauth_token:, payment_token:, idempotency_key:) ⇒ Object
payment_token: already passed PaymentTokenGuard (via Dispatcher#call, dispatcher.rb:75) before this ever runs — same posture as #complete_checkout, this in-memory adapter has no PSP to forward it to, so it's hashed into a stand-in psp_reference rather than stored.
-
#search_catalog(query:, limit:) ⇒ Object
-
#seed_product(product) ⇒ Object
Not part of the Adapter contract — this adapter has no backend to seed real data into, so callers (a spec, a README example) hand it catalog fixtures directly.
-
#update_cart(cart_id:, line_items:, idempotency_key:, discount_codes: nil) ⇒ Object
-
#update_checkout(checkout_id:, line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil) ⇒ Object
observability_key, with_observability
#idempotency_store=
Methods inherited from Adapter
#lookup_catalog
Constructor Details
Returns a new instance of ReferenceAdapter.
33
34
35
36
37
38
39
40
41
42
43
44
|
# File 'lib/portage/ucp/reference_adapter.rb', line 33
def initialize
super
@products = {}
@carts = {}
@checkouts = {}
@orders = {}
@identities = {}
@payment_enrollments = {}
@payment_methods = Hash.new { |h, k| h[k] = {} } @addresses = Hash.new { |h, k| h[k] = {} } @next_id = 0
end
|
Instance Method Details
#cancel_cart(cart_id:, idempotency_key:) ⇒ Object
78
79
80
81
82
83
|
# File 'lib/portage/ucp/reference_adapter.rb', line 78
def cancel_cart(cart_id:, idempotency_key:)
dedup(idempotency_key) do
@carts.delete(cart_id)
Portage::Ucp::Cart.new(id: cart_id, line_items: [], currency: "USD", totals: zero_totals)
end
end
|
#cancel_checkout(checkout_id:, idempotency_key:) ⇒ Object
126
127
128
129
130
131
132
|
# File 'lib/portage/ucp/reference_adapter.rb', line 126
def cancel_checkout(checkout_id:, idempotency_key:)
dedup(idempotency_key) do
record_checkout_status(checkout_id, "canceled")
checkout = @checkouts.fetch(checkout_id)
@checkouts[checkout_id] = Portage::Ucp::Checkout.new(**checkout.to_h, status: "canceled")
end
end
|
#cancel_order(order_id:, idempotency_key:, reason: nil) ⇒ Object
136
137
138
139
140
|
# File 'lib/portage/ucp/reference_adapter.rb', line 136
def cancel_order(order_id:, idempotency_key:, reason: nil)
dedup(idempotency_key) do
add_adjustment(order_id, type: "cancellation", status: "completed", description: reason)
end
end
|
#complete_checkout(checkout_id:, payment_token:, idempotency_key:, mandate: nil) ⇒ Object
payment_token: is part of the Adapter contract's call signature (and
already validated as non-PAN by PaymentTokenGuard before this ever
runs, per §9) but this in-memory adapter has no payment processor to
pass it on to — nothing here should hold onto it any longer than the
single call needs to, so it's accepted and left unused rather than
stored (see .rubocop.yml's Lint/UnusedMethodArgument exclude, same
posture as the abstract Adapter#complete_checkout it overrides).
mandate: (design-log §33/Phase B) is the same story — already
shape-validated by Ap2::MandateGuard via Dispatcher#call before this
ever runs, and there's no real PSP here to hand it to.
113
114
115
116
117
118
119
120
121
122
123
124
|
# File 'lib/portage/ucp/reference_adapter.rb', line 113
def complete_checkout(checkout_id:, payment_token:, idempotency_key:, mandate: nil)
dedup(idempotency_key) do
checkout = @checkouts.fetch(checkout_id)
raise_if_any_line_out_of_stock!(checkout)
order = store_order(checkout)
confirmation = Portage::Ucp::OrderConfirmation.new(id: order.id, permalink_url: order.permalink_url)
record_checkout_status(checkout_id, "completed")
@checkouts[checkout_id] = Portage::Ucp::Checkout.new(**checkout.to_h, status: "completed",
order: confirmation)
end
end
|
#create_cart(line_items:, idempotency_key:, discount_codes: nil) ⇒ Object
65
66
67
68
69
70
|
# File 'lib/portage/ucp/reference_adapter.rb', line 65
def create_cart(line_items:, idempotency_key:, discount_codes: nil)
dedup(idempotency_key) do
id = next_id("cart")
@carts[id] = build_cart(id, line_items, discount_codes)
end
end
|
#create_checkout(line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil) ⇒ Object
85
86
87
88
89
90
91
|
# File 'lib/portage/ucp/reference_adapter.rb', line 85
def create_checkout(line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
dedup(idempotency_key) do
id = next_id("chk")
record_checkout_status(id, "incomplete")
@checkouts[id] = build_checkout(id, line_items, discount_codes, fulfillment, status: "incomplete")
end
end
|
#create_payment_enrollment(idempotency_key:, mandate: nil) ⇒ Object
Simulates a real gateway-hosted enrollment: "pending" with a setup_url
on the first poll, "complete" with a minted opaque token from the
second poll onward — enough for a caller's poll loop to actually
exercise both states rather than completing on the first check.
mandate: (design-log §33/Phase B) is stored and echoed back on
every subsequent poll — this in-memory adapter has no PSP to verify
it against, but round-tripping it proves the plumbing for a real
adapter that would.
177
178
179
180
181
182
183
|
# File 'lib/portage/ucp/reference_adapter.rb', line 177
def create_payment_enrollment(idempotency_key:, mandate: nil)
dedup(idempotency_key) do
id = next_id("penr")
@payment_enrollments[id] = { polls: 0, mandate: mandate }
pending_enrollment(id, mandate)
end
end
|
#delete_address(oauth_token:, address_id:, idempotency_key:) ⇒ Object
255
256
257
258
259
260
|
# File 'lib/portage/ucp/reference_adapter.rb', line 255
def delete_address(oauth_token:, address_id:, idempotency_key:)
dedup(idempotency_key) do
subject = link_identity(oauth_token: oauth_token).subject
!@addresses[subject].delete(address_id).nil?
end
end
|
#delete_payment_method(oauth_token:, payment_method_id:, idempotency_key:) ⇒ Object
235
236
237
238
239
240
|
# File 'lib/portage/ucp/reference_adapter.rb', line 235
def delete_payment_method(oauth_token:, payment_method_id:, idempotency_key:)
dedup(idempotency_key) do
subject = link_identity(oauth_token: oauth_token).subject
!@payment_methods[subject].delete(payment_method_id).nil?
end
end
|
#delete_shopper_data(oauth_token:, idempotency_key:) ⇒ Object
Idempotent by construction, not by a guard: counts are captured
before deleting, and Hash#delete/size on an already-empty subject
naturally return zero — a second call is a no-op, never a raise.
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
|
# File 'lib/portage/ucp/reference_adapter.rb', line 265
def delete_shopper_data(oauth_token:, idempotency_key:)
dedup(idempotency_key) do
subject = link_identity(oauth_token: oauth_token).subject
payment_methods_deleted = @payment_methods[subject].size
addresses_deleted = @addresses[subject].size
@payment_methods.delete(subject)
@addresses.delete(subject)
identity_unlinked = !@identities.reject! { |_, identity| identity.subject == subject }.nil?
Portage::Ucp::ShopperDataErasure.new(
subject: subject, payment_methods_deleted: payment_methods_deleted,
addresses_deleted: addresses_deleted, identity_unlinked: identity_unlinked
)
end
end
|
#discount_codes_supported? ⇒ Boolean
198
|
# File 'lib/portage/ucp/reference_adapter.rb', line 198
def discount_codes_supported? = true
|
#fulfillment_supported? ⇒ Boolean
199
|
# File 'lib/portage/ucp/reference_adapter.rb', line 199
def fulfillment_supported? = true
|
#get_cart(cart_id:) ⇒ Object
63
|
# File 'lib/portage/ucp/reference_adapter.rb', line 63
def get_cart(cart_id:) = @carts[cart_id]
|
#get_checkout(checkout_id:) ⇒ Object
93
|
# File 'lib/portage/ucp/reference_adapter.rb', line 93
def get_checkout(checkout_id:) = @checkouts[checkout_id]
|
#get_order(order_id:) ⇒ Object
134
|
# File 'lib/portage/ucp/reference_adapter.rb', line 134
def get_order(order_id:) = @orders[order_id]
|
#get_payment_enrollment(enrollment_id:) ⇒ Object
185
186
187
188
189
190
191
192
193
194
195
196
|
# File 'lib/portage/ucp/reference_adapter.rb', line 185
def get_payment_enrollment(enrollment_id:)
entry = @payment_enrollments[enrollment_id]
return nil unless entry
entry[:polls] += 1
return pending_enrollment(enrollment_id, entry[:mandate]) if entry[:polls] == 1
Portage::Ucp::PaymentEnrollment.new(
id: enrollment_id, status: "complete", mandate: entry[:mandate],
payment_token: "reftok_#{Digest::SHA256.hexdigest(enrollment_id)[0, 16]}"
)
end
|
#get_product(product_id:) ⇒ Object
58
59
60
61
|
# File 'lib/portage/ucp/reference_adapter.rb', line 58
def get_product(product_id:)
product = @products[product_id]
product && Portage::Ucp::ProductDetail.new(product: product)
end
|
#link_identity(oauth_token:) ⇒ Object
Accepts any non-blank token and mints a stable identity for it — real
OAuth verification is a platform concern this in-memory adapter has
no platform to defer to, same posture as PaymentTokenGuard drawing
the line at "rejects the clearest misuse" rather than proving
validity.
206
207
208
209
210
211
212
213
|
# File 'lib/portage/ucp/reference_adapter.rb', line 206
def link_identity(oauth_token:)
raise Portage::Ucp::AuthenticationError, "blank oauth_token" if oauth_token.to_s.empty?
@identities[oauth_token] ||= Portage::Ucp::Identity.new(
subject: "user_#{Digest::SHA256.hexdigest(oauth_token)[0, 12]}",
email: nil, linked_at: Time.now.utc.iso8601
)
end
|
#list_addresses(oauth_token:) ⇒ Object
251
252
253
|
# File 'lib/portage/ucp/reference_adapter.rb', line 251
def list_addresses(oauth_token:)
@addresses[link_identity(oauth_token: oauth_token).subject].values
end
|
#list_payment_methods(oauth_token:) ⇒ Object
231
232
233
|
# File 'lib/portage/ucp/reference_adapter.rb', line 231
def list_payment_methods(oauth_token:)
@payment_methods[link_identity(oauth_token: oauth_token).subject].values
end
|
#refund_order(order_id:, line_items:, idempotency_key:, reason: nil) ⇒ Object
148
149
150
151
152
|
# File 'lib/portage/ucp/reference_adapter.rb', line 148
def refund_order(order_id:, line_items:, idempotency_key:, reason: nil)
dedup(idempotency_key) do
add_adjustment(order_id, type: "refund", status: "completed", line_items: line_items, description: reason)
end
end
|
#reorder(order_id:, idempotency_key:) ⇒ Object
Not memoized against @carts' own id sequence namespace collision:
next_id("cart") is shared with create_cart/update_cart, same as every
other *_id prefix here.
157
158
159
160
161
162
163
164
165
166
167
|
# File 'lib/portage/ucp/reference_adapter.rb', line 157
def reorder(order_id:, idempotency_key:)
dedup(idempotency_key) do
order = @orders[order_id]
next nil unless order
available, unavailable = partition_reorderable(order.line_items)
cart = build_cart(next_id("cart"), available, nil)
@carts[cart.id] = cart
Portage::Ucp::ReorderResult.new(cart: cart, unavailable_items: unavailable)
end
end
|
#request_return(order_id:, line_items:, idempotency_key:, reason: nil) ⇒ Object
142
143
144
145
146
|
# File 'lib/portage/ucp/reference_adapter.rb', line 142
def request_return(order_id:, line_items:, idempotency_key:, reason: nil)
dedup(idempotency_key) do
add_adjustment(order_id, type: "return", status: "pending", line_items: line_items, description: reason)
end
end
|
#save_address(oauth_token:, address:, idempotency_key:) ⇒ Object
242
243
244
245
246
247
248
249
|
# File 'lib/portage/ucp/reference_adapter.rb', line 242
def save_address(oauth_token:, address:, idempotency_key:)
dedup(idempotency_key) do
subject = link_identity(oauth_token: oauth_token).subject
id = next_id("addr")
saved = Portage::Ucp::SavedAddress.new(id: id, address: address, created_at: Time.now.utc.iso8601)
@addresses[subject][id] = saved
end
end
|
#save_payment_method(oauth_token:, payment_token:, idempotency_key:) ⇒ Object
payment_token: already passed PaymentTokenGuard (via Dispatcher#call,
dispatcher.rb:75) before this ever runs — same posture as
#complete_checkout, this in-memory adapter has no PSP to forward it
to, so it's hashed into a stand-in psp_reference rather than stored.
219
220
221
222
223
224
225
226
227
228
229
|
# File 'lib/portage/ucp/reference_adapter.rb', line 219
def save_payment_method(oauth_token:, payment_token:, idempotency_key:)
dedup(idempotency_key) do
subject = link_identity(oauth_token: oauth_token).subject
id = next_id("pm")
ref = Portage::Ucp::PaymentMethodRef.new(
id: id, psp_reference: "psp_ref_#{Digest::SHA256.hexdigest(payment_token.to_s)[0, 16]}",
created_at: Time.now.utc.iso8601
)
@payment_methods[subject][id] = ref
end
end
|
#search_catalog(query:, limit:) ⇒ Object
53
54
55
56
|
# File 'lib/portage/ucp/reference_adapter.rb', line 53
def search_catalog(query:, limit:)
matches = @products.values.select { |p| p.title.downcase.include?(query.downcase) }.first(limit)
Portage::Ucp::CatalogSearchResult.new(products: matches)
end
|
#seed_product(product) ⇒ Object
Not part of the Adapter contract — this adapter has no backend to
seed real data into, so callers (a spec, a README example) hand it
catalog fixtures directly.
49
50
51
|
# File 'lib/portage/ucp/reference_adapter.rb', line 49
def seed_product(product)
@products[product.id] = product
end
|
#update_cart(cart_id:, line_items:, idempotency_key:, discount_codes: nil) ⇒ Object
72
73
74
75
76
|
# File 'lib/portage/ucp/reference_adapter.rb', line 72
def update_cart(cart_id:, line_items:, idempotency_key:, discount_codes: nil)
dedup(idempotency_key) do
@carts[cart_id] = build_cart(cart_id, line_items, discount_codes, previous: @carts[cart_id])
end
end
|
#update_checkout(checkout_id:, line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil) ⇒ Object
95
96
97
98
99
100
101
|
# File 'lib/portage/ucp/reference_adapter.rb', line 95
def update_checkout(checkout_id:, line_items:, idempotency_key:, discount_codes: nil, fulfillment: nil)
dedup(idempotency_key) do
record_checkout_status(checkout_id, "incomplete")
@checkouts[checkout_id] = build_checkout(checkout_id, line_items, discount_codes, fulfillment,
status: "incomplete", previous: @checkouts[checkout_id])
end
end
|