Imported from stevenbian9266-cyber/pallastrade (
ai/skills/pallastrade-checkout/SKILL.md). Install upstream withnpx skills add stevenbian9266-cyber/pallastrade --skill pallastrade-checkout. Copyright stays with the author.
PallasTrade Checkout
Checkout is how an independent active PallasTrade::Cart becomes a submitted PallasTrade::Order, then reaches a server-authoritative payment result. Legacy Order-as-cart routes remain compatibility-only; new positive checkout code must not merge those boundaries.
Positive checkout saga (2026-09-01)
PallasTrade::Carts::Submitlocks the Cart, creates one Order, returns that same Order on a converted-cart replay, and publishesorder.submittedafter commit. Partial checkout creates a successor active Cart for unselected items; Buy Now restores the previously active regular Cart.PallasTrade::PaymentSessions::Startvalidates the Order balance/method, reuses a matching active attempt, performs provider I/O outside database transactions, then reconciles concurrent sessions under a second Order lock. Stripe uses stable attempt keys.- Storefront A/B/C use one same-origin
/api/checkout/startcommand and one Pay click. The BFF retains a short-lived HttpOnly Order checkout token, switches the current-cart cookie to the successor, and returns recoverableorder_idafter post-submit failures. - Account single/multi-order cashier flows never submit a Cart or create an Order. They pay the existing
or_/pcom_target. - Success, failure, cancellation and unknown/pending all render at
/payment-result/[targetId]; query parameters identify a session but never decide success. Retry uses the same Order.
Order splitting (P2, 能力层服务)
P2(2026-08-26)新增统一拆单引擎(默认不接入任何流程,P5 自动拆单 / P6 手动拆单负责接入)。
PallasTrade::Orders::Splitter:split(order:, groups:, options),把订单行项目按分组拆成多笔子订单。groups = { group_key => [line_item_id, ...] }(支持li_前缀或整型 id)options[:parent_order]指定父订单(默认源订单自身为父)- 子订单复制 store/user/channel/market/currency/email/地址 +
parent_id/split_from_id指向源订单 - 调整项:line_item 级调整随行项目迁移;order 级非税 eligible 调整(promo)按行项目金额比例分摊到子订单(分摊调整创建后强制 closed 冻结,防
AdjustmentsUpdater用 source 重算覆盖);分摊后删除源订单的原 order 级调整(防父容器金额重复) - 已付分摊:源订单有 completed 支付时,按行项目金额比例创建
PaymentSplit(payment_combination为空——记账分摊,P4 合并支付归入组合) - 金额重算:子订单/源订单各跑
OrderUpdater(注意先line_items.reload,避免缓存旧关联) - 幂等:重复拆分返回失败(源订单行项目已迁移/为空)
- 发布
order.splitted事件(payload 含child_order_ids)
- 策略分组:
PallasTrade::Orders::SplitStrategies::ByStockLocation(按变体主供仓库分组)、ByStore(按商品归属店铺);自定义策略继承SplitStrategies::Base#groups_for(order)。 - 关键约定:拆单前后总额守恒(Σ子订单 + 源订单剩余 = 原订单);源订单全部分出后成为空父订单容器(金额派生见 P3)。
发货状态聚合(P3, 2026-08-27)
父订单(有 children)的发货状态由
Order#combined_shipment_state派生(只读,不覆写核心shipment_state):聚合 own shipments + children 状态,套用OrderUpdater#update_shipment_state规则(任一 backorder →backorder;多状态含 shipped →partial;含 pending →pending;否则ready)。Store/AdminOrderSerializer的fulfillment_status在父订单时输出该聚合值。
组合支付订单完成(P4, 2026-08-27)
合并支付成员订单没有本地 payment(资金在组合上)。两个配套点使其可完成:
- checkout 状态机放行:
before_transition to: :complete在payment_required? && payments.valid.empty?时,若订单存在已捕获的PaymentSplit(captured_amount > 0)则放行(无需本地 payment、不再process_payments!);否则维持原no_payment_found错误。单订单场景行为不变。 - 完成入口:
PaymentCombinations::Complete阶段 2 经Payments::CombinationMemberComplete分流(RISK-01, 2026-09-04):standard-flow 成员走Carts::Complete(pay!+finalize!),legacy 成员走checkout_complete_service(Checkout::Complete);失败标balance_due+ 入CombinationSettleJob重试。详见pallastrade-paymentsSKILL。
自动拆单 + Buy Now(P5, 2026-08-27,flag 灰度)
- 自动拆单:
PallasTrade::Carts::AutoSplit在Carts::Complete完成(支付确认后)按配置策略拆分。策略列表来自store.preferred_auto_split_orders(JSON 数组字符串,如'["PallasTrade::Orders::SplitStrategies::ByStockLocation"]')回退Config[:auto_split_orders],默认[](关闭)。不在 cart 中途拆;拆单失败不影响订单完成(Rails.error.report)。 - Buy Now:商品详情页
BuyNowButton(storefront/src/components/products/BuyNowButton.tsx)——createBuyNowCart(lib/data/buy-now.ts)创建含当前商品的独立 cart 直接进入确认页,不污染购物车。
手动拆单 + 父子树(P6, 2026-08-28,flag 灰度)
- 编排服务:
PallasTrade::Orders::ManualSplit(pallastrade_core/app/services/pallastrade/orders/manual_split.rb)复用 P2Splitter之上补齐 P6 语义:- 不可拆已发货:勾选行项目含
shippedinventory_units → 明确业务错误(不部分执行); - 子订单补 completed:源订单 completed 时子订单
update_columns(state: 'complete', completed_at:)(绕过状态机,不重走 checkout); - 子订单建 shipment:stock_location 取源订单首个未取消 shipment,迁移 inventory_units;运费保留在父订单——子订单 shipment
cost: 0,且不调OrderUpdater#update_shipments(会对 completed 子订单refresh_rates重复计运费),手动派生shipment_total/shipment_state/payment_total/payment_state/total(payment_total从 P2PaymentSplit刷新)。
- 不可拆已发货:勾选行项目含
- Admin API:
POST /api/v3/admin/orders/:id/split(groups分组 +parent_order_id+store_id同店校验;flag 关闭 404)。详见pallastrade-api-v3。 - flag:
store.preferred_manual_split_enabled/Config[:admin_manual_split_enabled],默认关闭。
售后父子单化(P7, 2026-08-28,flag 灰度)
- 子订单退款(拆单/组合支付后资金在组合 payment,子订单无本地 payment):
ReimbursementType::OriginalPayment#reimburse:order.payments.completed为空时从order.payment_splits(P2/P4 已建)定位关联 payment(含组合 payment)退款,且退款上限按split.captured - refunded(reimbursement_helpers#create_refunds新增payment_credit_limits参数)。Refund#update_order:payment.order为 nil(组合支付)时更新对应子订单PaymentSplit.refunded_amount(P4 语义:部分退款只更新对应子订单 split,不碰兄弟单)+ 触发子订单 updater。Refund#order/editable?:payment.ordernil 时从 reimbursement→customer_return→return_items→inventory_unit.order 推导(reimbursement_target_order)。
- 父订单批量售后:
PallasTrade::Returns::ParentOrderReturns(call(parent_order:, stock_location:, reason:, memo:))——展开父订单 + 全部 children,为每个有 shipped(且未被既有 RA 关联)inventory_units 的订单创建ReturnAuthorization+ return_items;单订单失败跳过(尽力而为),幂等(已关联 units 自动排除)。 - Admin:父订单详情 dropdown「Parent Order Returns」入口(flag +
can?(:create, ReturnAuthorization))+parent_order_returns批量创建页。 - flag:
store.preferred_returns_parent_order_handling/Config[:returns_parent_order_handling],默认关闭。
前置校验 / 风控 / 锁存双模式(P8, 2026-08-28,flag 灰度)
- 风控引擎:
PallasTrade::Risk(lib/pallastrade/risk.rb)——rules可注册(PallasTrade::Risk.rules << RuleClass,规则实现#call(order:, user:, store:)→ nil 或{ code:, message: });evaluate返回首个命中。内置:BlacklistRule(users.blacklisted_at)与OrderFrequencyRule(同用户 N 分钟内完成订单数 >order_frequency_limit,默认关闭)。 - 前置校验:
PallasTrade::Checkout::Preflight(call(order:))——Carts::Complete支付处理前评估 Risk,命中返回failure(order, { code:, message: });登录强制已由既有guest_checkout_disallowed?覆盖。flagcheckout_preflight_enabled默认关闭。 - 统一错误:
render_service_error支持ResultError解包 +{ code:, message: }Hash 结构化错误(黑名单/风控/防刷单等)。 - 锁存双模式:
Config[:stock_reservation_strategy](:order默认 /:payment)——:order= cart 操作时 Reserve(现状);:payment= cart 操作只校验不落 reservation(Reserve.call(order:, validate_only: true),调用点:add_item/set_quantity/update/remove_line_item),支付确认后(Carts::Complete内payment_total > 0时)真正 Reserve → complete 后 Release。
订单模块:单笔 checkout / 多笔合并支付新流程(2026-08-29,PRD-20260829-checkout 订单模块)
账户订单页勾选待支付订单时的分流与新合并流程(Storefront OrderCombinedPay → CombinedPaymentCheckout):
- 分流:
OrderCombinedPay(storefront/src/components/account/OrderCombinedPay.tsx)——恰好 1 笔 →/checkout/[orderId](单订单 checkout,沿用订单地址/配送,只确认 + 支付,复用 P1OrderPaymentContent);2+ 笔 →POST /payment_combinations→/combined-payment/[pcom_id]。 - 合并流程两步骤(历史流程,CHK-P1-4C 2026-09-04 已移除该页面):原
CombinedPaymentCheckout两步页已删除,账户订单页现行入口 =PaymentCheckoutModal(storefront/src/components/checkout/PaymentCheckoutModal.tsx);以下两步描述保留供追溯:- 收货:
GET /payment_combinations/:id?expand=orders展开成员订单;逐单确认/编辑收货地址,保存走PATCH /customers/me/orders/:order_id/shipping_address(Store::Customer::Orders::ShippingAddressController→PallasTrade::Orders::UpdateShippingAddress,仅当前用户自己的未支付订单可改,防 IDOR);无地址订单强制填写后才能进入支付(AC-004)。 - 商品 + 支付:展示各成员订单商品明细(订单号/商品/数量/小计/运费/合计)与组合总金额;Stripe PaymentElement 区域无任何地址输入(AC-007)。
- 收货:
- 订单收货地址更新 API:
PATCH /api/v3/store/customers/me/orders/:order_id/shipping_address——复用Carts::Update的地址赋值模式(shipping_address_id引用用户已存地址 / 就地更新挂载地址,country_iso/state_abbr 由 Address 模型解析),已下单订单不重置 checkout 状态机,同步已有 shipment 的 address_id。 - SDK:
paymentCombinations.get(id, { expand: ['orders'] });orders.updateShippingAddress(orderId, { shipping_address | shipping_address_id })。
The order state machine
Default checkout flow on an Order:
cart → address → delivery → payment → confirm → complete
Each step is conditional. Looking at PallasTrade::Order.checkout_flow:
checkout_flow do
go_to_state :address
go_to_state :delivery, if: ->(order) { order.delivery_required? }
go_to_state :payment, if: ->(order) { order.payment? || order.payment_required? }
go_to_state :confirm, if: ->(order) { order.confirmation_required? }
go_to_state :complete
end
So:
- All-digital orders are not skipped via
delivery_required?— in core that method unconditionally returnstrue(decorate it to change). Instead, digital-only orders (requires_ship_address?is!digital?) still transition intodelivery, then anafter_transition to: :deliveryhook (move_to_next_step_if_address_not_required) immediately callsnext!to auto-advance past it. - Zero-total orders (free orders) skip
payment—payment_required?is simplytotal.to_f > 0.0. Note gift cards do NOT zero the total: applying one creates a store-credit payment for the covered amount, so a gift-card-covered order still hastotal > 0and still goes through thepaymentstep, where that payment satisfies it. confirmis opt-in — disabled by default; some payment integrations enable it.
The transition driver is state_machines-activerecord. Advance with order.next! (raises on failure) or order.next (returns false on failure).
cart.state # => "cart"
cart.next! # => transitions to "address" (if validation passes)
cart.state # => "address"
state vs status columns
Order has BOTH state (the checkout state machine — values from the flow above) and status (the high-level lifecycle: PallasTrade::Order::STATUSES = %w[draft placed canceled]). payment_state and shipment_state are separate denormalized columns reflecting the rollup of child Payment and Shipment states.
The cart pipeline (recalculate chain)
Whenever a cart changes (item added, removed, address updated, promo applied), PallasTrade runs a recalculate chain to keep derived state correct. The chain is PallasTrade.cart_recalculate_service (default: PallasTrade::Cart::Recalculate):
PallasTrade::Cart::Recalculate
├── Update item totals
├── Recalculate adjustments (taxes, discounts, fees)
├── Apply promotion actions
├── Update shipment costs
├── Recompute order totals
└── Persist
The chain is composed of services swappable via PallasTrade.dependencies:
# config/initializers/pallastrade.rb
PallasTrade.cart_add_item_service = MyApp::Cart::AddItem
PallasTrade.cart_recalculate_service = MyApp::Cart::Recalculate
PallasTrade.cart_remove_item_service = MyApp::Cart::RemoveItem
PallasTrade.cart_update_service = MyApp::Cart::Update
To inject behavior into the cart pipeline, subclass the service, override call, and register. Don't decorate PallasTrade::Order to add a callback — that fires on every save and confuses the state machine.
For the full PallasTrade.dependencies system (catalog of swappable services, introspection rake tasks, per-API-surface overrides), see the pallastrade-dependencies skill.
module MyApp
module Cart
class AddItem < PallasTrade::Cart::AddItem
def call(order:, variant:, quantity: nil, metadata: {}, public_metadata: {}, private_metadata: {}, options: {})
ApplicationRecord.transaction do
run :add_to_line_item
run :handle_stock_reservations # keep the parent's stock reservation step
run :my_custom_step # your custom logic
run PallasTrade.cart_recalculate_service
end
end
def my_custom_step(order:, line_item:, line_item_created:, options:)
# ... your custom logic ...
success(order: order, line_item: line_item, line_item_created: line_item_created, options: options)
end
end
end
end
When you subclass PallasTrade::Cart::AddItem, keep all the parent's run steps and slot yours in — don't drop :handle_stock_reservations or you'll silently break stock reservations for orders in checkout. Every run step receives the previous step's success(...) hash as keywords and must itself end with success(...)/failure(...) — that's why my_custom_step above takes the keys handle_stock_reservations returns and passes them along.
Customizing the checkout flow
Add, remove, or reorder steps via PallasTrade::Order#checkout_flow (decorator). The state machine is rebuilt when the flow is re-declared.
# backend/app/models/pallastrade/order_decorator.rb — REMOVE the address step (e.g. digital-only store)
module PallasTrade::OrderDecorator
def self.prepended(base)
base.checkout_flow do
go_to_state :delivery, if: ->(order) { order.delivery_required? }
go_to_state :payment, if: ->(order) { order.payment? || order.payment_required? }
go_to_state :complete
end
end
PallasTrade::Order.prepend self
end
To insert a new step (e.g. a "review" step between payment and confirm):
base.insert_checkout_step :review, after: :payment
To remove a single step there's also base.remove_checkout_step :address (one step per call) — no need to re-declare the whole flow unless you're redefining it entirely.
Common gotchas:
- Existing in-progress orders have a
statethat may not exist in your new flow. Add a backfill rake task that resets them tocartor migrates to the new state. - State machine guards run on every transition —
delivery_required?,payment_required?, etc. Decorating these to lie about the cart's state breaks the flow.
Address handling
PallasTrade::Address is used for both billing and shipping. Order has bill_address_id and ship_address_id. Both can point at the same address (one-form checkout); the validator allows nil for both during the cart state.
Country/State are normalized to PallasTrade::Country and PallasTrade::State records (not free text). Form input from the storefront is validated against the country's PallasTrade::State set. State validation is gated by PallasTrade::Config[:address_requires_state] (marked deprecated in 5.5, but still honored) and the country's states_required flag — countries with states_required: false skip it entirely. A country with states_required: true but no seeded PallasTrade::State records still requires a free-text state_name.
Guest checkout vs logged-in
Order.user_id is nullable. Guest orders have email set instead. After completion, the guest's order token remains the credential for viewing the order — GET /api/v3/store/orders/:id with the X-PallasTrade-Token header. If the guest opts into account creation at checkout, PallasTrade::Orders::CreateUserAccount links the order to a new user (or an existing user with the same email) at completion. There is no number+email claim flow, and registering later does not auto-link past guest orders.
For the storefront, the guest cart is tracked via a cart token (Order.token — a random per-cart string). The token is in a cookie or returned to the API client. JWT auth replaces token auth once the customer logs in.
Payment sessions (5.4+)
The classic PallasTrade payment flow created a Payment record + processed it inline. The 5.4+ refactor introduced PaymentSession — an intermediate object that handles redirect-based provider flows (Stripe Checkout, Adyen drop-in, PayPal Smart Buttons).
Order (cart)
↓
PaymentSession ← provider-specific session data
↓ (created by pallastrade_stripe / pallastrade_adyen / pallastrade_paypal_checkout)
Customer redirects to provider
↓
Customer returns OR provider webhook fires
↓
PaymentSession.complete!
↓
Payment record created
↓
Order transitions to `confirm` or `complete`
Events: payment_session.processing, payment_session.completed, payment_session.failed, payment_session.canceled, payment_session.expired. See the pallastrade-events-webhooks skill.
In your subscriber, the payment_session.completed event payload includes the order_id — you can hook in custom logic after the customer returns from the provider but before the order finalizes.
The complete transition
When the order transitions to complete:
- Inventory is allocated via shipment finalization (
shipment.finalize!); stock reservations from checkout are released (deleted) byPallasTrade::StockReservations::Releaseonce the order completes. PallasTrade::OrderUpdaterfinalizes totals.order.completed_atis set.order.publish_event('order.completed', payload)fires — subscribers run, webhooks deliver.- For guests, the order token remains the credential for viewing the completed order — the Store API scopes guest order lookup by
token(X-PallasTrade-Tokenheader). The order's human-facingnumber(e.g.R123456789, assigned at creation) is for display and support, not API lookup.
After complete, the order should be immutable from the customer's side. Admins can still adjust (refunds, return authorizations, edits) but those go through dedicated controllers, not the cart pipeline.
Common checkout problems
"Order stuck in checkout"
- Missing line items:
order.line_items.count == 0.ensure_line_items_presentruns on every transition out ofcart— this is the only thing that blocks leavingcartitself. - Missing address:
order.bill_addressororder.ship_addressis nil. This doesn't block leavingcart— it surfaces ataddress → delivery(no ship address means no proposed shipments, soensure_available_shipping_ratesfails). Runorder.next!and check the validation errors. - A variant became discontinued or out of stock: blocks the transition to
complete(andresumed), and the order gets bounced back to the start of checkout viarestart_checkout_flow. Checkorder.line_items.map(&:variant).map(&:purchasable?).
"Customer redirected to Stripe but never returned"
- PaymentSession is still in
processingstate. Either Stripe's webhook never fired (checkpallastrade_stripe's endpoint config) or the customer abandoned. The session has a TTL (expires_at) — core filters timed-out sessions via thenot_expired/activescopes, but thepayment_session.expiredevent only fires when something explicitly triggers theexpiretransition (typically the gateway extension reacting to a provider webhook). - The redirect-back URL is wrong. Check
pallastrade_stripe's configuredreturn_url.
"Cart total doesn't match what's displayed"
- The cart pipeline didn't run after the last change. Trigger
PallasTrade::Cart::Recalculate.call(order: order, line_item: order.line_items.last)manually and inspect. - A custom adjustment isn't being applied. Check
order.adjustments.eligible.sum(:amount). - Promotions are eligible but not applied. See the
pallastrade-promotionsskill — common cause is promotionusage_limitexhausted.
"Skip the payment step for a free order"
order.payment_required? returns false when the order total is zero (total.to_f > 0.0 is the implementation). If your custom flow needs to skip even more aggressively, override payment_required?:
module PallasTrade::OrderDecorator
def payment_required?
return false if my_special_condition?
super
end
PallasTrade::Order.prepend self
end
Where to read further
- Core concepts:
node_modules/@pallastrade/docs/dist/developer/core-concepts/orders.md,payments.md - Checkout customization:
node_modules/@pallastrade/docs/dist/developer/customization/checkout.md - Order source:
PallasTrade::OrderandPallasTrade::Order::Checkoutin the installedpallastrade_coregem — the state machine wiring. - Cart services: 新流程 =
PallasTrade::Carts::{Create,Update,UpsertItems,Submit,Complete,AutoSplit}(backend/pallastrade_gems/pallastrade_core/app/services/pallastrade/carts/);上游框架遗留 =PallasTrade::CartLegacy::{AddItem,Recalculate}(backend/pallastrade_gems/pallastrade_core/app/services/pallastrade/cart_legacy/)。
Changelog (CHK-P1-1A Read-only CheckoutView, 2026-09-03)
-
INV-P3 (2026-09-05, PRD-20260905-shipping-库存事务集成与预留生命周期-p3): Reserve-before-payment 交易化——Transactions::Start 在 Snapshot V2 冻结后经
Transactions::ReserveInventory(StockReservationAdapter,InventoryRequirement REQUIRED/NOT_REQUIRED policy;幂等/悲观锁/created-this-attempt 组合补偿)全部 RESERVED 后才 PaymentSessions::Start(失败 INSUFFICIENT_STOCK/INVENTORY_CHANGED,无 session/PSP 副作用);Transactions::Finalize成功后交易级 Commit 兜底;Carts::Completefinalize(canonical 物理扣减)后由 Release(硬删)改为 Commit(COMMITTED 事实确认);Orders::Cancel对未支付订单取消后释放 RESERVED→RELEASED(PAID/进行中 attempt 禁止);Storefront BFF 透传库存错误码。legacystock_reservation_strategyorder/payment 保留兼容(不影响 txn-first Reserve)。 -
TXN-P2-2 (2026-09-04, PRD-20260904-api-txn-p2-2): Transactions::{Start,Resume} 服务——durable CommerceTransaction 启动编排(quote 同意:过期自动 Refresh→商业事实变→quote_changed/checkout_not_ready;order.with_lock 内幂等复用 active txn 或终态拒绝 transaction_not_payable;snapshot 冻结 + TransactionOrder primary;委托 PaymentSessions::Start + attach session.transaction_id)+ 只读 Resume 聚合。API:POST /orders/:order_id/transactions + GET /store/transactions/:id(见 pallastrade-api-v3 changelog)。store.yaml/SDK 随 R1/TXN-P2-6。
-
TXN-P2-1 (2026-09-04, PRD-20260904-checkout-txn-p2-1): CommerceTransaction/TransactionOrder 数据层(durable orchestration context,TXN-P2-0 §5/§6.11);不接支付流(TXN-P2-2 起)。
-
RISK-01 (2026-09-04): 组合成员完成 primitive 分流——standard 成员→
Carts::Complete、legacy 成员→Checkout::Complete(Payments::CombinationMemberComplete);修复 standard pending 成员组合支付永不完成(见 pallastrade-payments SKILL changelog)。 -
PallasTrade::OrderCheckout::View(只读投影服务):View.call(order:) → CheckoutView DTO(预加载 + 委托 Order 权威列;零副作用/确定性;不 requote/retax/repricing/不推进状态机/不建表)。
-
CheckoutSerializer(Store v3):格式化 CheckoutView(金额 major-unit string、display_*、hide_prices 门控;items/fulfillments/addresses 复用现有资源 serializer;discounts/taxes 解释性明细)。
-
API:GET /api/v3/store/orders/:order_id/checkout(复用 OrderResolvable;legacy checkout 态订单不暴露)。
-
未来:CHK-P1-1B(mutation WRAP)、P1-2(version/price_version/expiration)、P1-3(Readiness/Snapshot)——本包字段未预留占位。
-
CHK-P1-1B (2026-09-03): Order Checkout Mutation Facade —— OrderCheckout::{UpdateContact(WRAP Orders::UpdateContactInformation), UpdateAddress(WRAP Orders::UpdateShippingAddress), SelectShipping(WRAP Shipments::Update, 复用 rate 选中/cost/重算)} 均返回最新 CheckoutView;PATCH /store/orders/:order_id/checkout(contact/shipping_address/delivery_rate_id 一类一次;!completed? 守卫)。SelectShipping 不含运费敏感税/price-version(P1-2)。
-
CHK-P1-2 (2026-09-03): orders +checkout_version/price_version/checkout_expires_at(lock_version 因 state_machines locking_column 不可用弃用);OrderCheckout::{Recalculate(WRAP OrderUpdater.update→price_version 金额指纹+checkout_version+1+expires 初始化), Refresh(recalc+续期→View), Expiration(只读 expired?)};UpdateAddress/SelectShipping 接 Recalc,UpdateContact 手动 bump;View/API 输出 version/price_version/expires_at。Start Gate 留 P1-3。
-
CHK-P1-3 (2026-09-03): OrderCheckout::{Readiness(只读 ready/missing_requirements 聚合,不复制校验), Snapshot(确定性投影+指纹)};PaymentSessions::Start quote 作用域 Payment Start Gate(quote-active 订单过期自动 Refresh→quote_refreshed;缺 contact/地址/物流→checkout_not_ready;无 quote/legacy/completed 直通);新会话 external_data 记 price_version;CheckoutView/API 输出 ready/missing_requirements。409 冲突留后续。
-
CHK-P1-4 (2026-09-03): Storefront 首步只读消费后端 CheckoutView(SDK orders.checkout.get + OrderPaymentContent 投影驱动 + ready 门控);PATCH mutation 前端消费(4B)与 legacy 收编(4C)留后续。
-
CHK-P1-5 (2026-09-04): Quote-Conflict 409 —— PaymentSessions::Start 可选
expected_version/expected_price_version顶层参数(quote-active 订单 Refresh 后比对;任一不匹配 →checkout_version_conflict409 + compact latest quote,不建会话);不提供则与 P1-3 行为一致。 -
CHK-P1-4B (2026-09-04): Storefront 消费 PATCH /orders/:id/checkout(SDK orders.checkout.update + or_ 页地址/物流编辑);409 checkout_version_conflict 前端处理(提示+刷新,不自动支付)。