"""支付安全门(feat-chv-muse-p10,叶子模块,纯标准库)。 对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见 docs/chv-muse-p10-gap.md):支付时发行一次性卡号给商家而非 真实卡号,绑定特定商家、特定金额、有限有效期;已保存支付 凭证的网站每次购买都弹人工审批并展示确切购买详情;未购物 过的网站走内置合规钱包(Stripe Link 首发、Shop Pay 次之)。 本模块只做客内 agent 侧的支付安全门(检测、路由、限额、 审批、绑定、审计),不产生真实扣款: - ``PaymentCredentialVault``:已保存支付方式的隔离存储, 走 P3 authd 模式(只活内存、白名单、按次释放绑定、审计 绝无凭证值),但另立库——只存不透明支付方式 id + 掩码 标签,原始卡号 PAN 拒绝入库; - ``OneTimeCard``:一次性卡号的绑定与兑现校验(商家 + 金额 + 货币 + 有效期,单次有效); - ``SimulatedWalletBackend``:沙箱钱包(假 PAN 只在后端 私有内存里,调用方只见掩码标签与 card_id;``charge`` 只记账不联网),供测试与演示;真实钱包集成是待用户 另行批准的事项,见差距表 §3; - ``PaymentGate``:编排——结账检测 → 路由 → 限额 → P5 对话外审批(确切购买详情进 payload)→ 铸一次性卡 → 兑现校验 → 记账 → 审计。无审批的支付一律拒绝。 真实一次性虚拟卡由 Link 在其侧发行(连接器文档原文), 本轨的卡是绑定与兑现校验的等价模型,不是发卡行。 """ import hashlib import json import secrets import time from collections.abc import Callable from dataclasses import dataclass from cloud.chv.approval_channel import ( Answer, request_approval, resolve_via_channel, verify_answer, ) from cloud.chv.checkout_detect import CheckoutVerdict from cloud.chv.sentinel import ApprovalQueue, EgressRequest # 钱包提供方白名单(连接器文档现状:stripe-link 首发、 # shop-pay 已可用;未知提供方一律拒绝)。 ALLOWED_WALLET_PROVIDERS = frozenset({"stripe-link", "shop-pay"}) # 一次性卡默认有效期(秒)与按次释放默认有效期(秒)。 DEFAULT_CARD_TTL_SECONDS = 900 DEFAULT_RELEASE_TTL_SECONDS = 300 # 审计里绝不出现的字段名(命中即丢弃并标 redacted)。 _SECRET_FIELD_NAMES = ("card_number", "cardnumber", "pan", "cvv", "cvc", "token", "secret", "password") # 沙箱钱包的假卡号段(Stripe 测试卡号段,永不产生真实扣款)。 _TEST_PAN_PREFIX = "400000000000" class PaymentRefusal(Exception): """支付拒绝:kind + 原因,不含任何卡号与凭证值。""" def __init__(self, kind: str, message: str) -> None: super().__init__("payment_refused:%s: %s" % (kind, message)) self.kind = kind def luhn_valid(number: str) -> bool: """Luhn 校验(只用于识别“像卡号”的数字串,拒绝入库)。""" digits = [int(c) for c in number if c.isdigit()] if len(digits) < 13 or len(digits) > 19: return False total = 0 for i, d in enumerate(reversed(digits)): if i % 2 == 1: d *= 2 if d > 9: d -= 9 total += d return total % 10 == 0 def _looks_like_pan(value: str) -> bool: """值是否像原始卡号:去空格/横线后 13–19 位数字且过 Luhn。命中即视为 PAN,拒绝入库、拒绝进审计。""" compact = value.replace(" ", "").replace("-", "") return compact.isdigit() and luhn_valid(compact) def _masked_label(pan: str, brand: str = "Visa") -> str: """假卡号的掩码标签(只露后 4 位,审计与审批里唯一 允许出现的卡号派生形式)。""" return "%s ....%s" % (brand, pan[-4:]) @dataclass class PaymentMethod: """一个已保存的支付方式(只含不透明 id + 掩码标签, 无真实卡号)。``merchant_host`` 是该凭证保存于的商家 主机(None = 未限定商家,释放时仍须绑定本次商家)。""" provider: str method_id: str masked_label: str merchant_host: str | None = None def describe(self) -> dict: """审计/审批用描述(不含任何凭证值)。""" return {"provider": self.provider, "method_id": self.method_id, "masked_label": self.masked_label, "merchant_host": self.merchant_host} @dataclass class PaymentRelease: """一次支付方式按次释放(替身):绑定到一次具体的购买。 兑现后交出的是不透明 method_id + 掩码标签,不是卡号。""" release_id: str provider: str method_id: str masked_label: str merchant: str amount_minor: int currency: str expires_at: float uses_left: int = 1 def describe(self) -> dict: """审计用描述(只含绑定,不含值)。""" return {"release_id": self.release_id, "provider": self.provider, "method_id": self.method_id, "masked_label": self.masked_label, "merchant": self.merchant, "amount_minor": self.amount_minor, "currency": self.currency, "expires_at": self.expires_at, "uses_left": self.uses_left} def __repr__(self) -> str: # 绝不含任何凭证值 return ("PaymentRelease(id=%s provider=%s merchant=%s " "amount=%d %s)" % (self.release_id, self.provider, self.merchant, self.amount_minor, self.currency)) class PaymentCredentialVault: """只活内存的支付凭证保险库(一个 VM 一个实例)。 - 值只存在内存 dict 里,不落盘、不进日志; - 只存不透明支付方式 id + 掩码标签:原始卡号 PAN、 未知提供方、空 id 一律拒绝入库; - ``release()`` 按次发放:绑定 {商家, 金额, 货币}, 单次有效、ttl 后过期; - ``consume()`` 单次兑现:兑现一次即删除,过期也兑现 不了;兑现失败不留可重试的残值。 clock 可注入(测试用假时钟测过期),默认 time.time。""" def __init__(self, clock: Callable[[], float] | None = None) -> None: self._store: dict[tuple[str, str], PaymentMethod] = {} self._releases: dict[str, PaymentRelease] = {} self._clock = clock or time.time def __repr__(self) -> str: # 绝不含任何凭证值 return "PaymentCredentialVault(providers=%s)" % sorted( {p for (p, _m) in self._store}) def store(self, provider: str, method_id: str, masked_label: str, merchant_host: str | None = None ) -> None: """入库:白名单校验 + PAN 拒绝。原始卡号、未知提供方、 空 id、空标签一律拒绝入库(PaymentRefusal),绝不静默 收下。""" if provider not in ALLOWED_WALLET_PROVIDERS: raise PaymentRefusal( "unknown_provider", "钱包提供方 %s 不在白名单内" % provider) if not isinstance(method_id, str) or not method_id.strip(): raise PaymentRefusal("empty_method_id", "支付方式 id 为空") if _looks_like_pan(method_id) or _looks_like_pan(masked_label): raise PaymentRefusal( "raw_pan_refused", "原始卡号不得入库,只存不透明 id 与掩码标签") if not isinstance(masked_label, str) or not masked_label.strip(): raise PaymentRefusal("empty_label", "掩码标签为空") key = (provider, method_id) self._store[key] = PaymentMethod( provider, method_id, masked_label, merchant_host) def has(self, provider: str, method_id: str) -> bool: """该(提供方,方式 id)是否已入库(不返回值)。""" return (provider, method_id) in self._store def methods_for_merchant(self, merchant_host: str) -> list: """该商家主机上已保存的支付方式(只返回描述对象, 不含任何凭证值)。""" return [m for m in self._store.values() if m.merchant_host is None or m.merchant_host == merchant_host] def all_methods(self) -> list: """全部已保存支付方式(描述对象,不含任何凭证值)。""" return list(self._store.values()) def forget(self, provider: str, method_id: str) -> bool: """删一条入库支付方式;同时作废其全部未兑现释放。 未知键返回 False,不抛错。""" gone = self._store.pop((provider, method_id), None) is not None if gone: doomed = [rid for rid, rel in self._releases.items() if rel.provider == provider and rel.method_id == method_id] for rid in doomed: self._releases.pop(rid, None) return gone def release(self, provider: str, method_id: str, merchant: str, amount_minor: int, currency: str, ttl_seconds: int = DEFAULT_RELEASE_TTL_SECONDS, max_uses: int = 1) -> PaymentRelease: """按次发放:绑定须与入库作用域一致(merchant_host 有记录时须一致),金额须 > 0。发放的释放单次有效、 ttl 后过期;须经 consume() 兑现一次才交出方式引用。""" method = self._store.get((provider, method_id)) if method is None: raise PaymentRefusal( "method_not_stored", "支付方式 %s 未入库" % method_id) if method.merchant_host is not None \ and method.merchant_host != merchant: raise PaymentRefusal( "scope_mismatch", "支付方式作用域商家不一致,拒绝释放") if amount_minor <= 0: raise PaymentRefusal("bad_amount", "金额须大于 0") release = PaymentRelease( release_id=secrets.token_hex(8), provider=provider, method_id=method_id, masked_label=method.masked_label, merchant=merchant, amount_minor=amount_minor, currency=currency, expires_at=self._clock() + ttl_seconds, uses_left=max_uses) self._releases[release.release_id] = release return release def consume(self, release_id: str) -> PaymentMethod: """兑现一次释放:过期/用完/未知 id 一律拒绝 (PaymentRefusal,不含值)。兑现成功返回支付方式 引用(不透明 id + 掩码标签),并把该释放删除。""" release = self._releases.get(release_id) if release is None: raise PaymentRefusal( "release_unknown", "释放记录不存在或已兑现") if self._clock() >= release.expires_at: self._releases.pop(release_id, None) raise PaymentRefusal("release_expired", "释放已过期") if release.uses_left <= 0: self._releases.pop(release_id, None) raise PaymentRefusal("release_exhausted", "释放已用完") release.uses_left -= 1 method = self._store.get((release.provider, release.method_id)) self._releases.pop(release_id, None) if method is None: raise PaymentRefusal( "method_gone", "支付方式已删除,释放作废") return method @dataclass class PurchaseDetails: """一次购买的确切详情(审批单上展示给用户的全部内容, 也是防篡改摘要的输入)。金额以 minor units 计(分/日元 等无小数货币按其最小单位),货币为三位字母码,不换算。""" merchant: str merchant_host: str items: tuple amount_minor: int currency: str wallet_provider: str def total_minor(self) -> int: """商品小计(数量 × 单价之和),须等于 amount_minor。""" return sum(qty * unit for (_n, qty, unit) in self.items) def validate(self) -> None: """校验详情自洽:金额 > 0、货币三位码、商品非空、 小计等于总额、提供方在白名单内。任一失败即 ValueError(调用方 bug,不走支付拒绝通道)。""" if self.amount_minor <= 0: raise ValueError("amount_minor must be > 0") if len(self.currency) != 3 or not self.currency.isalpha(): raise ValueError("currency must be a 3-letter code") if not self.items: raise ValueError("items must not be empty") if self.total_minor() != self.amount_minor: raise ValueError("items total != amount_minor") if self.wallet_provider not in ALLOWED_WALLET_PROVIDERS: raise ValueError("unknown wallet_provider") def describe(self) -> dict: """审批 payload 里的确切购买详情(商家、商品、数量、 金额、货币、提供方;无任何卡号与凭证值)。""" return {"merchant": self.merchant, "merchant_host": self.merchant_host, "items": [{"name": n, "qty": q, "unit_minor": u} for (n, q, u) in self.items], "amount_minor": self.amount_minor, "currency": self.currency, "wallet_provider": self.wallet_provider} def digest(self) -> str: """购买详情摘要(SHA-256,规范 JSON):审批时记下, 扣款时重算——详情被篡改(换商家/改金额/换商品)即 摘要对不上,支付拒绝。""" canonical = json.dumps(self.describe(), sort_keys=True, separators=(",", ":"), ensure_ascii=False) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() @dataclass class OneTimeCard: """一张一次性卡号(绑定与兑现校验的等价模型)。 绑定 {商家, 金额, 货币, 有效期},单次有效。真实一次 性虚拟卡由 Link 在其侧发行(连接器文档原文),本类 只做绑定校验——调用方只见 card_id 与掩码标签,卡号 本体只活在钱包后端私有内存里。""" card_id: str merchant: str amount_minor: int currency: str expires_at: float masked_label: str uses_left: int = 1 def describe(self) -> dict: """审计用描述(只含绑定与掩码标签,不含卡号)。""" return {"card_id": self.card_id, "merchant": self.merchant, "amount_minor": self.amount_minor, "currency": self.currency, "expires_at": self.expires_at, "masked_label": self.masked_label, "uses_left": self.uses_left} def __repr__(self) -> str: # 绝不含卡号 return ("OneTimeCard(id=%s merchant=%s amount=%d %s)" % (self.card_id, self.merchant, self.amount_minor, self.currency)) def authorize(self, merchant: str, amount_minor: int, currency: str, now: float) -> tuple: """兑现校验:用完/过期/换商家/超额/换货币任一命中 即 (False, 原因);全过则消耗一次并返回 (True, "ok")。单次有效,用过即废。""" if self.uses_left <= 0: return False, "card_exhausted" if now >= self.expires_at: return False, "card_expired" if merchant != self.merchant: return False, "wrong_merchant" if currency != self.currency: return False, "wrong_currency" if amount_minor > self.amount_minor: return False, "amount_exceeded" self.uses_left -= 1 return True, "ok" @dataclass class SpendLimits: """钱包侧限额(minor units,无货币、不换算——按 stripe-link 连接器文档口径)。None = 该档未设置。 ``daily_spent_minor`` / ``thirty_day_spent_minor`` 是 调用方传入的已用额度(本模块不记账)。""" per_purchase_minor: int | None = None daily_minor: int | None = None thirty_day_minor: int | None = None daily_spent_minor: int = 0 thirty_day_spent_minor: int = 0 def check(self, amount_minor: int) -> tuple: """逐项比 minor units:超任一档即 (False, 原因); 三档全为 None(限额不可用)→ (True, "limits_unavailable"),绝不编造上限。""" if self.per_purchase_minor is not None \ and amount_minor > self.per_purchase_minor: return False, "over_per_purchase_limit" if self.daily_minor is not None \ and self.daily_spent_minor + amount_minor \ > self.daily_minor: return False, "over_daily_limit" if self.thirty_day_minor is not None \ and self.thirty_day_spent_minor + amount_minor \ > self.thirty_day_minor: return False, "over_thirty_day_limit" if self.per_purchase_minor is None \ and self.daily_minor is None \ and self.thirty_day_minor is None: return True, "limits_unavailable" return True, "ok" class PaymentAudit: """支付审计(内存态记录表)。 事件只含允许字段(商家、金额、货币、掩码标签、 pending_id、原因、路由、提供方);字段名命中 ``_SECRET_FIELD_NAMES`` 一律丢弃并标 ``redacted``—— 卡号与凭证值绝不进审计。""" ALLOWED_FIELDS = frozenset({ "merchant", "merchant_host", "amount_minor", "currency", "masked_label", "pending_id", "reason", "route", "wallet_provider", "card_id", "limits_checked", }) def __init__(self, clock: Callable[[], float] | None = None) -> None: self._records: list = [] self._clock = clock or time.time def record(self, event: str, fields: dict | None = None, now: float | None = None) -> dict: """记一条审计(只留允许字段;秘密字段名丢弃并标 redacted)。返回记下的记录副本。""" at = now if now is not None else self._clock() kept: dict = {"event": event, "ts": at} redacted = False for key, value in (fields or {}).items(): if key in _SECRET_FIELD_NAMES: redacted = True continue if key in self.ALLOWED_FIELDS: kept[key] = value if redacted: kept["redacted"] = True self._records.append(dict(kept)) return dict(kept) def list_records(self) -> list: """全部审计记录(副本,按记录时间排序)。""" return [dict(r) for r in self._records] class SimulatedWalletBackend: """沙箱钱包(测试与演示用,绝不产生真实扣款)。 - 假 PAN 只存在 ``self._pans`` 私有内存里,调用方只见 card_id 与掩码标签;``__repr__`` 与账本里绝无卡号; - ``mint_card`` 按购买详情铸一次性卡(绑定商家 + 金额 + 货币 + 900 秒有效期); - ``charge`` 只记账不联网:绑定校验通过即记一笔 ``simulated_paid``,否则记拒绝原因; - ``void_card`` 作废一张卡(取消待支付时调用)。 真实钱包集成是待用户另行批准的事项(见差距表 §3)。""" def __init__(self, providers: tuple = ("stripe-link", "shop-pay"), clock: Callable[[], float] | None = None) -> None: self._providers = tuple(providers) self._vault = PaymentCredentialVault(clock=clock) self._pans: dict[str, str] = {} self._cards: dict[str, OneTimeCard] = {} self._voided: set = set() self._ledger: list = [] self._clock = clock or time.time self.fail_next_charge = False def __repr__(self) -> str: # 绝不含任何卡号 return "SimulatedWalletBackend(providers=%s)" % list( self._providers) def supports(self, provider: str) -> bool: """该钱包提供方是否可用(沙箱里由构造参数决定, 真实侧由钱包连接状态决定)。""" return provider in self._providers def add_method(self, provider: str, method_id: str, masked_label: str, merchant_host: str | None = None ) -> None: """往沙箱钱包里加一个已保存支付方式(只存不透明 id + 掩码标签,走同一入库校验)。""" self._vault.store(provider, method_id, masked_label, merchant_host) def list_methods(self, merchant_host: str | None = None) -> list: """已保存支付方式(描述对象;merchant_host 给了 则只返回该商家的)。""" if merchant_host is None: return [m.describe() for m in self._vault.all_methods()] return [m.describe() for m in self._vault.methods_for_merchant(merchant_host)] def mint_card(self, details: PurchaseDetails, now: float | None = None) -> OneTimeCard: """按购买详情铸一张一次性卡:假 PAN 只进私有内存, 返回的卡对象只有 card_id 与掩码标签。""" at = now if now is not None else self._clock() card_id = "otc_" + secrets.token_hex(8) pan = _TEST_PAN_PREFIX + "%04d" % secrets.randbelow(10000) self._pans[card_id] = pan card = OneTimeCard( card_id=card_id, merchant=details.merchant, amount_minor=details.amount_minor, currency=details.currency, expires_at=at + DEFAULT_CARD_TTL_SECONDS, masked_label=_masked_label(pan)) self._cards[card_id] = card return card def charge(self, card: OneTimeCard, details: PurchaseDetails, now: float | None = None) -> dict: """模拟扣款:只记账不联网。绑定校验通过即记一笔 simulated_paid;``fail_next_charge`` 为真时模拟 钱包侧失败(扣款失败即 void 该卡)。""" at = now if now is not None else self._clock() entry: dict if self.fail_next_charge: self.fail_next_charge = False self.void_card(card.card_id) entry = {"status": "simulated_failed", "reason": "wallet_declined", "card_id": card.card_id} self._ledger.append(dict(entry)) return dict(entry) ok, reason = card.authorize( details.merchant, details.amount_minor, details.currency, at) if not ok: entry = {"status": "refused", "reason": reason, "card_id": card.card_id} self._ledger.append(dict(entry)) return dict(entry) entry = {"status": "simulated_paid", "card_id": card.card_id, "merchant": details.merchant, "amount_minor": details.amount_minor, "currency": details.currency, "masked_label": card.masked_label} self._ledger.append(dict(entry)) return dict(entry) def void_card(self, card_id: str) -> bool: """作废一张一次性卡(取消待支付时调用;已作废或 未知 id 返回 False,不抛错)。""" if card_id in self._voided or card_id not in self._cards: return False self._voided.add(card_id) card = self._cards[card_id] card.uses_left = 0 return True def ledger(self) -> list: """模拟账本(副本;只含记账条目,无任何卡号)。""" return [dict(e) for e in self._ledger] def _verdict_of(answer: Answer | dict) -> str: """答复对象或同款 dict 里的 verdict(verify_answer 已 校验过合法性,这里只取值;取不到即空串)。""" if isinstance(answer, dict): value = answer.get("verdict", "") return value if isinstance(value, str) else "" return getattr(answer, "verdict", "") or "" class PaymentGate: """支付安全门:一次购买的完整编排。 ``begin_purchase``:结账检测 → 路由(已保存凭证 / 合规钱包 stripe-link 优先、shop-pay 次之)→ 限额 → P5 对话外审批(确切购买详情进 payload)。无审批不支付。 ``complete_purchase``:验签(不消费)→ 验详情摘要 (篡改即拒,审批保持待处理)→ 单次消费答复 → 铸一次 性卡 → 兑现校验 → 记账 → 审计。审批被拒即停止,不扣款。 """ def __init__(self, queue: ApprovalQueue, wallet: SimulatedWalletBackend, vault: PaymentCredentialVault, channel_secret: str, clock: Callable[[], float] | None = None, audit: PaymentAudit | None = None) -> None: self._queue = queue self._wallet = wallet self._vault = vault self._secret = channel_secret self._clock = clock or time.time self._audit = audit or PaymentAudit(clock=clock) self._pending: dict[str, dict] = {} def _route(self, details: PurchaseDetails) -> tuple: """路由:商家有已保存凭证 → saved;无 → 合规钱包 (stripe-link 优先、shop-pay 次之);两家都不支持 → (None, "no_wallet_route")。""" saved = self._vault.methods_for_merchant( details.merchant_host) if saved: return "saved", saved[0].provider if self._wallet.supports("stripe-link"): return "wallet", "stripe-link" if self._wallet.supports("shop-pay"): return "wallet", "shop-pay" return "", "no_wallet_route" def _refuse(self, details: PurchaseDetails, reason: str, at: float, limits_checked: bool | None = None) -> dict: """记一条 payment_refused 审计并返回统一的拒绝结果。""" fields: dict = {"merchant": details.merchant, "merchant_host": details.merchant_host, "amount_minor": details.amount_minor, "currency": details.currency, "wallet_provider": details.wallet_provider, "reason": reason} if limits_checked is not None: fields["limits_checked"] = limits_checked self._audit.record("payment_refused", fields, now=at) out: dict = {"ok": False, "reason": reason} if limits_checked is not None: out["limits_checked"] = limits_checked return out def _request_payment_approval(self, details: PurchaseDetails, route: str, provider: str, limits_checked: bool, at: float) -> dict: """记入 Sentinel 待审批队列并经 P5 通道发审批请求 (确切购买详情进 payload)。返回 begin 的成功结果。""" request = EgressRequest( connector="payment", method="purchase", scheme="https", host=details.merchant_host, port=443, path="/checkout") pending_id = self._queue.record(request, ["payment_approval"]) record = next(r for r in self._queue.list_pending() if r["pending_id"] == pending_id) approval = request_approval( record, self._secret, now=at, details=details.describe()) self._pending[pending_id] = { "details": details, "digest": details.digest(), "route": route, "provider": provider, "card_id": None, "limits_checked": limits_checked} self._audit.record( "payment_approval_requested", {"merchant": details.merchant, "merchant_host": details.merchant_host, "amount_minor": details.amount_minor, "currency": details.currency, "wallet_provider": details.wallet_provider, "pending_id": pending_id, "route": route, "limits_checked": limits_checked}, now=at) return {"ok": True, "pending_id": pending_id, "payload": approval.payload(), "route": route, "provider": provider} def begin_purchase(self, details: PurchaseDetails, checkout: CheckoutVerdict, limits: SpendLimits | None = None, now: float | None = None) -> dict: """开始一次购买:返回 {"ok": True, "pending_id", "payload", "route", "provider"},或 {"ok": False, "reason"}。审批请求发给客户端 UI,payload 的 details 就是用户看到的确切购买详情。""" details.validate() at = now if now is not None else self._clock() if not checkout.is_checkout: return self._refuse(details, "not_checkout", at) route, provider = self._route(details) if not route: return self._refuse(details, provider, at) limits_checked = False if limits is not None: ok, reason = limits.check(details.amount_minor) if not ok: return self._refuse(details, reason, at, True) limits_checked = reason != "limits_unavailable" return self._request_payment_approval( details, route, provider, limits_checked, at) def _charge_after_approval(self, pending_id: str, details: PurchaseDetails, base: dict, at: float) -> dict: """审批通过后:铸一次性卡 → 兑现校验 → 记账。扣款 失败即 {"ok": False, "reason"},不扣款;成功即 {"ok": True, "charge", "card_id", "masked_label"}。""" card = self._wallet.mint_card(details, now=at) self._pending[pending_id]["card_id"] = card.card_id result = self._wallet.charge(card, details, now=at) if result.get("status") != "simulated_paid": self._audit.record( "payment_refused", dict(base, reason=result.get("reason", "charge_failed"), card_id=card.card_id), now=at) self._pending.pop(pending_id, None) return {"ok": False, "reason": result.get("reason", "charge_failed")} self._audit.record( "payment_completed", dict(base, card_id=card.card_id, masked_label=card.masked_label), now=at) self._pending.pop(pending_id, None) return {"ok": True, "charge": result, "card_id": card.card_id, "masked_label": card.masked_label} def complete_purchase(self, pending_id: str, answer: Answer | dict, actual_details: PurchaseDetails, now: float | None = None) -> dict: """完成一次购买:验签 → 验详情摘要 → 单次消费答复 → 铸一次性卡 → 兑现校验 → 记账。任一步失败即 {"ok": False, "reason"},不扣款。""" pending = self._pending.get(pending_id) if pending is None: return {"ok": False, "reason": "unknown_pending"} at = now if now is not None else self._clock() details = pending["details"] base = {"merchant": details.merchant, "amount_minor": details.amount_minor, "currency": details.currency, "pending_id": pending_id} if not verify_answer(answer, self._secret, now=at): self._audit.record("payment_refused", dict(base, reason="invalid_answer"), now=at) return {"ok": False, "reason": "invalid_answer"} if actual_details.digest() != pending["digest"]: self._audit.record("payment_refused", dict(base, reason="details_tampered"), now=at) return {"ok": False, "reason": "details_tampered"} if not resolve_via_channel(self._queue, answer, self._secret, now=at): return {"ok": False, "reason": "resolve_failed"} if _verdict_of(answer) == "deny": self._audit.record("payment_denied", base, now=at) self._pending.pop(pending_id, None) return {"ok": False, "reason": "denied"} return self._charge_after_approval(pending_id, details, base, at) def cancel_purchase(self, pending_id: str, now: float | None = None) -> dict: """取消一笔待支付:审批保持未消费(用户可重新发起), 已铸的卡即 void(取消待支付 void 虚拟卡,连接器文档 口径)。未知 pending 返回 False,不抛错。""" pending = self._pending.pop(pending_id, None) if pending is None: return {"ok": False, "reason": "unknown_pending"} at = now if now is not None else self._clock() card_id = pending.get("card_id") if card_id is not None: self._wallet.void_card(card_id) self._audit.record("payment_card_voided", {"pending_id": pending_id, "card_id": card_id}, now=at) self._audit.record( "payment_cancelled", {"merchant": pending["details"].merchant, "amount_minor": pending["details"].amount_minor, "currency": pending["details"].currency, "pending_id": pending_id}, now=at) return {"ok": True, "reason": "cancelled"} def pending_ids(self) -> list: """全部待处理购买的 pending_id(副本)。""" return list(self._pending)