Skip to main content

wanning_core/
pending.rs

1//! 人在环待支付(W-53a,第一形态)。
2//!
3//! 产品分层(所有者 2026-09-03 拍板):**第一形态 = 人在环待支付**(个人默认,
4//! 零开户——AI 把单下到「待支付」,人按指纹确认);第二形式 = 免密代扣(平台侧,
5//! 全自动,W-50 模板 + W-52 钥匙验证);`manual` = 纯闸,只判定不开单。
6//!
7//! **待支付单是账本里的一行状态,不是通道请求**:零网络、零外联、零通道 API。
8//! 一笔人在环消费的五段事件链(全部落 WAL,一行不缺,回放页可逐段回放):
9//!
10//! ①意图 + ②审批 → 既有 `Decide` 行(意图与判定原子一行;闸判定面零改动)
11//! ③待支付 → `Pending` 行(审批额 + TTL)
12//! ④人确认 → `Confirm` 行(关联 pending id,幂等,带支付凭证)
13//! ⑤终态 → `Terminal` 行(完成 / TTL 过期作废)
14//!
15//! 三钉(fail-closed,被拒的确认**一行都不落**):
16//! 1. **金额一致**:确认额必须等于审批额——审批 400 确认 500 = 拒(防夹带,
17//!    这是「限制 AI」的本体语义);
18//! 2. **幂等**:同一单只能确认一次,二次确认 = 拒;
19//! 3. **TTL**:待支付半开窗口 `[created, expires)`,过期作废,确认过期单 = 拒
20//!    (作废本身是账本事实,落一行 `Terminal{ExpiredVoid}` 再拒)。
21//!
22//! 完整性不豁免:每一行都过 W-21 完整性链;回放侧([`crate::state::WanningState::replay`])
23//! 与实时侧共用同一套 [`PendingLedger`] 应用逻辑——链合法但语义不通的账本
24//! (没有放行的待支付、确认额与审批额不符、没有确认就完成)回放一律 fail-closed。
25//!
26//! 崩溃窗口的诚实边界:④确认行与⑤终态行之间崩溃,重放后单处于 `Confirmed`
27//! 态(确认已发生、终态行缺失)——回放按「已确认未了结」接受,不视为损坏;
28//! 补一行终态(或人重新确认会被幂等钉拒)即可对账。
29//!
30//! 金额一律 u64 分,禁浮点(钱,与闸同一纪律)。
31
32use std::collections::BTreeMap;
33use std::fmt;
34
35use serde::{Deserialize, Serialize};
36
37use crate::error::CoreError;
38use crate::intent::SpendIntent;
39
40/// 支付形态档位(挂在接入面:W-53 产品分层)。
41///
42/// 档位只决定「闸放行之后做什么」,**不改闸判定面**——预算/撤销/重放/策略
43/// 四道门在任何档位下都先到先拒。
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum PayMode {
47    /// 第一形态:人在环待支付(个人默认,零开户)。闸放行后开待支付单,
48    /// 人确认后才算消费落地。
49    #[default]
50    PendingPay,
51    /// 第二形式:免密代扣(平台侧,全自动)。闸放行即消费落地,通道半边走
52    /// W-50 报文模板 + W-52 钥匙验证(商户号是接入平台自己的事)。
53    AutoDebit,
54    /// 纯闸:只判定不开单(demo / 内嵌闸面)。
55    Manual,
56}
57
58impl PayMode {
59    /// 人可读形态名(审计/终端输出用)。
60    pub fn label(self) -> &'static str {
61        match self {
62            PayMode::PendingPay => "人在环待支付",
63            PayMode::AutoDebit => "免密代扣",
64            PayMode::Manual => "纯闸",
65        }
66    }
67
68    /// 该档位放行后是否开待支付单(只有第一形态开)。
69    pub fn opens_pending(self) -> bool {
70        matches!(self, PayMode::PendingPay)
71    }
72}
73
74/// 待支付单的状态机:`Open → Confirmed → Completed`,任意时刻可 `Voided`(过期作废)。
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum PendingState {
78    /// 待支付:等人确认。
79    Open,
80    /// 人已确认(④确认行已落);⑤终态行之前的过渡态。
81    Confirmed,
82    /// 已完成(⑤终态行 = 完成)。
83    Completed,
84    /// 已作废(TTL 过期,⑤终态行 = 过期作废)。
85    Voided,
86}
87
88/// ⑤终态行的两种结局。
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum PendingOutcome {
92    /// 人确认后的完成。
93    Completed,
94    /// TTL 过期作废(无人确认)。
95    ExpiredVoid,
96}
97
98/// 一笔待支付单(账本行状态的内存映像;行本身在 WAL,这里是回放/实时共用的账)。
99#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
100pub struct PendingOrder {
101    /// 待支付单号(`p-` 前缀 + 64 位十六进制指纹,由委托/nonce/时刻派生)。
102    pub pending_id: String,
103    /// 所属委托。
104    pub delegation_id: String,
105    /// ①意图(原样入账,回放可对账)。
106    pub intent: SpendIntent,
107    /// ②审批额(= 意图额;确认时三钉之一就是和它比)。
108    pub approved_amount_cents: u64,
109    /// 开单时刻(Unix 秒)。
110    pub created_ts: u64,
111    /// 过期时刻(开单时刻 + TTL;半开窗口 `[created, expires)`)。
112    pub expires_ts: u64,
113    /// 当前状态。
114    pub state: PendingState,
115    /// ④支付凭证(交易号;人确认时给的,回放可对账)。
116    pub proof: Option<String>,
117    /// ④确认时刻。
118    pub confirmed_ts: Option<u64>,
119}
120
121/// 开单回执(`decide_opening_pending` 的第二返回值;放行才开单,拒绝时没有回执)。
122///
123/// 给接入面(MCP/CLI/SDK)的三件事:单号(给人看、给人确认)、审批额与过期时刻
124/// (展示用)、审计行号(证据挂钩——「这张单在第几行」)。纯内存无 WAL 时行号为
125/// `None`(回执诚实体现实时侧有没有落盘证据可挂)。
126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
127pub struct PendingReceipt {
128    /// 待支付单号(`p-` 前缀),人确认时用它指认这一单。
129    pub pending_id: String,
130    /// ②审批额(= 意图额,分)。
131    pub approved_amount_cents: u64,
132    /// 过期时刻(Unix 秒;半开窗口 `[开单, 过期)`)。
133    pub expires_ts: u64,
134    /// ③待支付行的 WAL 行号(1-based);无 WAL 时为 `None`。
135    pub wal_line: Option<u64>,
136}
137
138/// 人在环待支付被拒(三钉 / 单号不存在 / 凭证缺失)。fail-closed:宁可拒,不可放。
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub enum PendingError {
141    /// 单号不存在(不是本闸开出的单)。
142    UnknownPending { pending_id: String },
143    /// 单不在 `Open` 态(已确认/已完成/已作废)——幂等钉:同一单只能确认一次。
144    NotOpen {
145        pending_id: String,
146        state: PendingState,
147    },
148    /// 确认额 ≠ 审批额——金额一致钉(防夹带)。
149    AmountMismatch {
150        pending_id: String,
151        approved_cents: u64,
152        given_cents: u64,
153    },
154    /// 单已过 TTL——TTL 钉(过期作废,确认过期单 = 拒)。
155    Expired {
156        pending_id: String,
157        expires_ts: u64,
158        now_ts: u64,
159    },
160    /// TTL 非法(0 = 开出来就是死的单,没有存在意义)。
161    InvalidTtl { ttl_secs: u64 },
162    /// 支付凭证为空——没有凭证的确认不是可对账的确认。
163    EmptyProof,
164    /// 单未确认,没有「完成」可言(状态机误用/回放对账不一致)。
165    NotConfirmed {
166        pending_id: String,
167        state: PendingState,
168    },
169    /// 还没到期就写作废(状态机误用/回放对账不一致)。
170    NotYetExpired {
171        pending_id: String,
172        expires_ts: u64,
173        now_ts: u64,
174    },
175    /// 单号重复(单号必须唯一;回放对账不一致时按 WalMismatch 上抛)。
176    DuplicatePendingId { pending_id: String },
177}
178
179impl fmt::Display for PendingError {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            PendingError::UnknownPending { pending_id } => {
183                write!(f, "待支付单不存在: {pending_id}")
184            }
185            PendingError::NotOpen { pending_id, state } => write!(
186                f,
187                "待支付单 {pending_id} 不在待支付状态(当前 {state:?}),不能再次确认(幂等)"
188            ),
189            PendingError::AmountMismatch {
190                pending_id,
191                approved_cents,
192                given_cents,
193            } => write!(
194                f,
195                "待支付单 {pending_id} 金额不一致:审批 {approved_cents} 分,确认 {given_cents} 分(防夹带,拒)"
196            ),
197            PendingError::Expired {
198                pending_id,
199                expires_ts,
200                now_ts,
201            } => write!(
202                f,
203                "待支付单 {pending_id} 已过期(过期时刻 {expires_ts},当前 {now_ts}),确认被拒"
204            ),
205            PendingError::InvalidTtl { ttl_secs } => {
206                write!(f, "待支付 TTL 非法: {ttl_secs} 秒(必须 > 0)")
207            }
208            PendingError::EmptyProof => {
209                write!(f, "支付凭证为空:确认必须带交易号,回放才可对账")
210            }
211            PendingError::NotConfirmed { pending_id, state } => write!(
212                f,
213                "待支付单 {pending_id} 未处于已确认状态(当前 {state:?}),不能记完成"
214            ),
215            PendingError::NotYetExpired {
216                pending_id,
217                expires_ts,
218                now_ts,
219            } => write!(
220                f,
221                "待支付单 {pending_id} 还没到期(过期时刻 {expires_ts},当前 {now_ts}),不能作废"
222            ),
223            PendingError::DuplicatePendingId { pending_id } => {
224                write!(f, "待支付单号重复: {pending_id}(单号必须唯一)")
225            }
226        }
227    }
228}
229
230impl std::error::Error for PendingError {}
231
232/// 待支付台账(实时态与回放态共用的应用逻辑——两边绝不各写一套)。
233///
234/// 内层用 `BTreeMap`:按单号有序迭代,`state_hash` 因此确定性(同一份账本
235/// 回放两遍指纹必相同)。
236#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
237pub struct PendingLedger {
238    orders: BTreeMap<String, PendingOrder>,
239}
240
241impl PendingLedger {
242    pub fn new() -> Self {
243        Self {
244            orders: BTreeMap::new(),
245        }
246    }
247
248    pub fn is_empty(&self) -> bool {
249        self.orders.is_empty()
250    }
251
252    pub fn len(&self) -> usize {
253        self.orders.len()
254    }
255
256    pub fn get(&self, pending_id: &str) -> Option<&PendingOrder> {
257        self.orders.get(pending_id)
258    }
259
260    pub fn contains_key(&self, pending_id: &str) -> bool {
261        self.orders.contains_key(pending_id)
262    }
263
264    /// 按单号有序迭代(确定性)。
265    pub fn iter(&self) -> impl Iterator<Item = (&String, &PendingOrder)> {
266        self.orders.iter()
267    }
268
269    /// 该委托 + nonce 是否已开过单(一个意图至多一张待支付单;nonce 在成功消费
270    /// 时被闸登记,重复意图本就过不了闸,出现两张 = 账本语义不通)。
271    pub fn contains_intent(&self, delegation_id: &str, nonce: u64) -> bool {
272        self.orders
273            .values()
274            .any(|o| o.delegation_id == delegation_id && o.intent.nonce == nonce)
275    }
276
277    /// ③开单(行已落 WAL 之后调用;单号重复 = 调用方 bug / 账本语义不通)。
278    pub(crate) fn apply_open(&mut self, order: PendingOrder) -> Result<(), CoreError> {
279        if self.orders.contains_key(&order.pending_id) {
280            return Err(CoreError::Pending(PendingError::DuplicatePendingId {
281                pending_id: order.pending_id.clone(),
282            }));
283        }
284        self.orders.insert(order.pending_id.clone(), order);
285        Ok(())
286    }
287
288    /// 确认前的**纯检查**(零变更):单号存在 → 金额一致 → 状态 `Open` → 未过期。
289    /// 实时侧先用它挡下被拒的确认(一行都不落),回放侧经由 [`PendingLedger::apply_confirm`]
290    /// 走同一套检查——两边不会漂移。
291    pub(crate) fn check_confirm(
292        &self,
293        pending_id: &str,
294        amount_cents: u64,
295        now_ts: u64,
296    ) -> Result<(), PendingError> {
297        let order = self
298            .orders
299            .get(pending_id)
300            .ok_or_else(|| PendingError::UnknownPending {
301                pending_id: pending_id.to_string(),
302            })?;
303        // 三钉顺序:金额一致 → 幂等(状态)→ TTL。金额不一致永远先报——
304        // 「确认单金额对不上」是比「单已处理」更要紧的夹带信号。
305        if order.approved_amount_cents != amount_cents {
306            return Err(PendingError::AmountMismatch {
307                pending_id: pending_id.to_string(),
308                approved_cents: order.approved_amount_cents,
309                given_cents: amount_cents,
310            });
311        }
312        if order.state != PendingState::Open {
313            return Err(PendingError::NotOpen {
314                pending_id: pending_id.to_string(),
315                state: order.state,
316            });
317        }
318        if now_ts >= order.expires_ts {
319            return Err(PendingError::Expired {
320                pending_id: pending_id.to_string(),
321                expires_ts: order.expires_ts,
322                now_ts,
323            });
324        }
325        Ok(())
326    }
327
328    /// ④确认(确认行已落 WAL 之后调用;内部先跑同一套纯检查)。
329    pub(crate) fn apply_confirm(
330        &mut self,
331        pending_id: &str,
332        amount_cents: u64,
333        proof: &str,
334        now_ts: u64,
335    ) -> Result<(), CoreError> {
336        self.check_confirm(pending_id, amount_cents, now_ts)
337            .map_err(CoreError::Pending)?;
338        let order = self
339            .orders
340            .get_mut(pending_id)
341            .expect("check_confirm 已确认单存在");
342        order.state = PendingState::Confirmed;
343        order.proof = Some(proof.to_string());
344        order.confirmed_ts = Some(now_ts);
345        Ok(())
346    }
347
348    /// ⑤终态 = 完成(终态行已落 WAL 之后调用;必须已确认)。
349    pub(crate) fn apply_complete(&mut self, pending_id: &str) -> Result<(), CoreError> {
350        let order = self.orders.get_mut(pending_id).ok_or_else(|| {
351            CoreError::Pending(PendingError::UnknownPending {
352                pending_id: pending_id.to_string(),
353            })
354        })?;
355        if order.state != PendingState::Confirmed {
356            return Err(CoreError::Pending(PendingError::NotConfirmed {
357                pending_id: pending_id.to_string(),
358                state: order.state,
359            }));
360        }
361        order.state = PendingState::Completed;
362        Ok(())
363    }
364
365    /// ⑤终态 = 过期作废(作废行已落 WAL 之后调用;必须仍是 `Open` 且确已过期)。
366    pub(crate) fn apply_void(&mut self, pending_id: &str, now_ts: u64) -> Result<(), CoreError> {
367        let order = self.orders.get_mut(pending_id).ok_or_else(|| {
368            CoreError::Pending(PendingError::UnknownPending {
369                pending_id: pending_id.to_string(),
370            })
371        })?;
372        if order.state != PendingState::Open {
373            return Err(CoreError::Pending(PendingError::NotOpen {
374                pending_id: pending_id.to_string(),
375                state: order.state,
376            }));
377        }
378        if now_ts < order.expires_ts {
379            return Err(CoreError::Pending(PendingError::NotYetExpired {
380                pending_id: pending_id.to_string(),
381                expires_ts: order.expires_ts,
382                now_ts,
383            }));
384        }
385        order.state = PendingState::Voided;
386        Ok(())
387    }
388}