Class: Portage::Ucp::ReferenceAdapter

Inherits:
Adapter
  • Object
show all
Includes:
Support::CheckoutState, Support::Idempotency
Defined in:
lib/portage/ucp/reference_adapter.rb

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

Constants included from Support::Idempotency

Support::Idempotency::INIT_MUTEX, Support::Idempotency::NOT_FOUND

Instance Method Summary collapse

Methods included from Support::CheckoutState

observability_key, with_observability

Methods included from Support::Idempotency

#idempotency_store=

Methods inherited from Adapter

#lookup_catalog

Constructor Details

#initialize ⇒ ReferenceAdapter

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] = {} } # subject => id => PaymentMethodRef
  @addresses = Hash.new { |h, k| h[k] = {} } # subject => id => SavedAddress
  @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

Returns:

  • (Boolean)


198
# File 'lib/portage/ucp/reference_adapter.rb', line 198

def discount_codes_supported? = true

#fulfillment_supported? ⇒ Boolean

Returns:

  • (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

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