支付流程图指南

怎么画一张经得起生产环境考验的支付流程图:钱到底在哪一刻动、为什么跳回来那一下不算数,以及三个可直接改的例子。

发布于 ·9 分钟阅读
paymentscheckoutsequence-diagramtemplate

一、什么是支付流程图

支付流程图 画的是一笔订单怎么变成你账上的钱 —— 以及更有用的那部分:这条路上每一个它不再是直线的地方。

多数人画一次就放弃了:五个方框,顾客付款、渠道通过、订单发货,然后得出结论「不值得画」。这个版本与其说是错的,不如说它描述的是整条流程里那百分之二从来不出事故的部分。真正出事故的是:一笔支付不是一个事件。它至少是两个 —— 授权,发卡行同意冻结这笔钱;和 扣款,钱真的动了 —— 而这两者可能隔着好几天。中间还夹着一件事:顾客的银行可能会在一个你控制不了的页面上弹出验证,而顾客可能就再也没回来。

五个方框还藏了另一件事:「已支付」不是终点。钱会倒着流,而且时间尺度是卡组织定的,不是你定的:下周的一次退款、一件商品退回时的部分退款、销售之后最长 120 天内提起的一次拒付。一个止步于 Paid 的支付状态机,注定会在生产环境里、由值班的人、在压力下被临时改掉。

所以这张图诚实的主题不是结账。它是「顾客以为自己付了」和「我们知道自己收到了」这两件事之间的那道缝 —— 以及缝里能发生的一切。

二、一张支付流程图要画出哪些东西

六样。漏掉第二样,这张图描述的就是一个你并不拥有的系统。

  • 授权和扣款是两步,分开画。 就算你是立即扣款,也把两步都画出来 —— 因为哪天有人问 「能不能发货时才扣」,答案会是改一条箭头,而不是重做。用大白话标出各自的含义:授权是一次带有效期的冻结,扣款是真正的转账。一笔从未扣款的授权不会大声失败,它大约一周后无声地失效。
  • 结果真正是从哪儿来的。 浏览器跳回你的返回页,只说明顾客的会话结束了,别的什么都不说明。权威答案是渠道带外、异步送来的,而且不管顾客的浏览器活没活下来它都会来。两条箭头都画,并标出你被允许相信的是哪一条。把跳转当成答案,是结账带着窟窿上线的第一大原因。
  • 那个离开你的流程的步骤。 3-D Secure、跳银行 App、钱包弹层 —— 顾客去了一个不属于你的地方,而且可能不回来。把它画成一个有 三个 结果的分支,不是两个:通过、拒绝、以及放弃。第三个结果压根没有箭头指回你这边 —— 而这正是它必须出现在图上的理由。
  • 一个幂等键,而且取自稳定的东西。 双击的按钮、重发的请求、刷新的标签页,对顾客是同一笔,对他的卡是三笔。这个键必须来自订单,而不是时间戳或者每次尝试新生成的随机值 —— 一个重试时会变的键不叫键。把它在哪儿生成画出来,它能不能扛住重试就一目了然。
  • 「已支付」之后的一切。 全额退款、部分退款、争议、拒付,以及你打赢的那次拒付。这是钱倒着流的地方,也是状态机里通常被漏掉的那一半 —— 因为结账那一周没人在想这些。一个有用的检验:如果你的图里没有任何一个「销售之后三个月还能进入」的状态,那就是漏了。
  • 金额,在每一个它可能不一致的位置。 授权额、扣款额、退款额、结算额是四个数,而它们不总是同一个。部分扣款、部分退款、货币换算、渠道手续费,每一样都会把它们拉开。这些不必全画进主图,但你必须知道自己 orders.total 那一列装的是哪一个 —— 而图正是这个问题被大声问出来的地方。

这是几乎所有人第一次都会画的骨架,而它里面就带着那个 bug。每条箭头单独看都对。问题是整件事是一个同步请求:订单是在「刷卡」的同一次调用里被标记成已支付的。

这个形状只描述了一种支付 —— 发卡行秒过、没有任何验证、回程一切顺利。这在真实结果里是少数,而在欧洲,对多数交易它甚至不是合法的默认路径。它没地方放银行的验证页,也没地方放一个「顾客关掉标签页四秒后才到」的答案。

看 Mermaid 源码
sequenceDiagram
    participant U as Customer
    participant App as Your App
    participant PSP as Payment Provider

    U->>App: submit the payment form
    App->>PSP: charge the card
    PSP-->>App: approved
    App->>App: mark the order as paid
    App-->>U: redirect to the success page
最小的支付流程图:应用在一次同步请求里刷卡并把订单标记成已支付。

三、支付流程图怎么画

三步。第一步决定这张图讲的是什么 —— 而它很容易以一种「感觉很高效」的方式画错。

第 1 步 · 画钱,不要画界面

第一反应通常是照着用户看到的顺序走一遍:购物车、地址、支付方式、确认、提交。这五个界面里有四个和「你能不能收到钱」毫无关系,把它们画上去,只会把真正要紧的部分挤到角落。

选参与者只用一个判据:这个方框能不能持有这笔钱,或者拒绝把它放出来? 对一笔卡支付,这给你四个:顾客、你的应用、你的支付渠道、发卡行。发卡行是最常被漏掉的那个,而它恰恰是那个说不、要求验证、或者三个月后把钱划回去的角色。它不在图上,每一次拒付都会变成一条从渠道里冒出来、没有解释的箭头。

支付方式之前的一切 —— 购物车、运费、税 —— 属于另一张图,如果它真的属于任何一张的话。这张图从「金额已定、马上要向某人收这笔钱」的那一刻开始。

第 2 步 · 标出你被允许相信的那条箭头

画完的图上会有两条箭头看起来都像「支付成功了」。一条是顾客的浏览器落到你的返回页。另一条是渠道带外告诉你的服务器,这笔 intent 成功了。只有第二条算证据。在图上用文字标出来,别让人靠箭头方向自己推。

理由不在于微妙,而在于:浏览器根本不是这笔支付的参与者。它只是一个碰巧在场了一段时间的旁观者。它可能在验证页被关掉、在电梯里断网、是一个被系统杀掉的移动 App,或者干脆是某个从没付过钱的人拿到 URL 之后打开的。上面每一种情况里,钱都照样动了,而唯一知道这件事的,是渠道那条异步消息。

实际后果是一个多数初稿没有的形状:返回页读当前状态,如果还没看到结果就说 「我们正在确认你的付款」,而履约挂在那条带外消息上。这在图上是两条箭头、在代码里大约十五行,而没有它们的版本,失败方式恰好是最难复现、最贵发现的那种 —— 钱收了、订单没建、没人知道,直到顾客写信来。

那条带外消息就是一个 webhook,所以那套规矩全都适用 —— 对原始 body 验签、按事件 id 去重、在干活之前先确认。在这张图上它是一条箭头;它的内部是另一张图,本页底部有链接。

第 3 步 · 画出来

用句子描述,让 text2diagram 排版 ——「顾客下单,我们用订单号当幂等键创建一个 payment intent,顾客在渠道那边输入卡号,发卡行可能要求 3-D Secure 验证,然后渠道通知我们的服务器,我们把订单标记为已支付」,还回来的是一段分支已经嵌好、可以直接改的 sequenceDiagram

或者把下面三张打开、改掉参与方名字。值得留下的是分支结构。

四、三个支付流程图例子

三张图:一次画对的结账、一笔支付的完整生命(包括几个月后才发生的部分),以及一次没成功的扣款该怎么判。

例 1 · 卡支付结账,验证和确认都放在正确的位置

底下那条 Note 就是这张图的全部意义。末尾附近到达的两条箭头都长得像成功,但只有一条是。

三个细节值得原样抄。幂等键是 订单号,在任何东西发出去之前就生成 —— 于是双击、重发、刷新页面都会归到同一个 intent,而不是三笔扣款。一个每次尝试新生成的键在图上长得一模一样,但什么都防不住。

卡号从顾客 直接 到渠道,一次都不经过你的服务器,这是你合规范围能保持很小的原因。在图上这表现为一条跳过了一个参与者的箭头,而它值得精确画出来,恰恰因为错误的版本 —— 卡号先 POST 到你的后端、再转发出去 —— 作为一段时序看上去同样合情合理。

还有,订单是在 渠道到你服务器 的那条箭头上被标记为已支付的,不是在它上面那条跳转上。这两条箭头的区别,就是「一个扛得住关标签页的结账」和「一个悄悄弄丢已付款订单的结账」的区别。

看 Mermaid 源码
sequenceDiagram
    autonumber
    participant U as Customer
    participant App as Your App
    participant PSP as Payment Provider
    participant Bank as Issuing Bank

    U->>App: place the order
    App->>App: create the order, status awaiting_payment
    App->>PSP: create a payment intent, idempotency key is the order id
    PSP-->>App: intent id plus a client secret
    App-->>U: payment form bound to that intent
    U->>PSP: card details, they never reach our server
    PSP->>Bank: authorization request
    alt the issuer wants a challenge
        Bank-->>PSP: 3-D Secure required
        PSP-->>U: challenge screen hosted by the bank
        U->>Bank: approve in the banking app
        Bank-->>PSP: authenticated
    else no challenge needed
        Bank-->>PSP: authorized
    end
    PSP-->>U: send the browser back to our return page
    PSP->>App: payment_intent.succeeded
    App->>App: mark the order paid, then fulfil it
    Note over PSP,App: this arrow is the truth - the redirect above is only a hint
卡支付结账时序图:用订单号作幂等键的 payment intent、卡号直达渠道、发卡行可能要求的 3-D Secure 验证,以及在渠道的异步确认(而非浏览器跳转)上把订单标记为已支付。

例 2 · 一笔支付的完整生命

用状态图,因为一笔支付是一个会变老的对象,不是一段对话。谁觉得活儿到结账就结束了,就把这张图摆到他面前 —— 图上大约一半描述的是「顾客早已忘了这笔订单之后」才发生的事。

AuthorizedCaptured 是两个状态,这一点就算你立即扣款也要保留。备注写了原因:授权有有效期,而且它失效时不通知任何人。一个要九天才发货的仓库拿到的不是一笔失败的支付,而是一笔从来没真正扣过钱的订单。

Expired 值得单列一个状态,而不是画一条箭头进 Declined,因为这两者的处理完全不同 —— 一个顾客需要换张卡,另一个需要被请求「为一件他已经同意买的东西再付一次」。

Disputed 在你打赢时有一条边回到 Captured,这是最常被忘掉的分支,也是让这张图真正可运维的那一条:一笔支付可以在销售三个月后、按别人的时间表,离开一个你以为是终态的状态。如果你的模型里没有任何东西能做到这件事,那你的模型对「支付是什么」的理解就是错的。

看 Mermaid 源码
stateDiagram-v2
    [*] --> RequiresPayment: order placed
    RequiresPayment --> RequiresAction: the issuer asks for 3-D Secure
    RequiresAction --> Authorizing: the customer approved it
    RequiresPayment --> Authorizing: no challenge needed
    Authorizing --> Authorized: funds held, not moved
    Authorizing --> Declined: the issuer said no
    Authorized --> Captured: we ship, or we charge straight away
    Authorized --> Expired: never captured, the hold is released
    Captured --> PartiallyRefunded: one item came back
    PartiallyRefunded --> Refunded: the rest came back
    Captured --> Refunded: refunded in full
    Captured --> Disputed: chargeback filed, up to 120 days later
    PartiallyRefunded --> Disputed: chargeback filed
    Disputed --> Won: our evidence was accepted
    Disputed --> Lost: the funds are pulled back plus a fee
    Won --> Captured: the money stays with us
    Declined --> [*]
    Expired --> [*]
    Refunded --> [*]
    Lost --> [*]
    note right of Authorized
        an authorization is a promise with an expiry
        seven days is typical and it lapses silently
    end note
一笔支付的状态图:待支付、需要 3-D Secure 验证、授权中、已授权、已扣款、授权过期、部分退款、已退款、争议中、争议胜诉与败诉。

例 3 · 扣款没成功时怎么办

用流程图,因为这一张是一次判断而不是一段时序 —— 而且做判断的代码,是在顾客早已离开之后才跑的。

软拒付硬拒付 的分野就是这张图的全部。余额不足、发卡行短暂不可达,意思是 过会儿再试,可能就成了;卡被盗刷、账户已注销、或者干脆一句「不予承兑」,意思是 下周它照样以同样的方式失败。重试第二种不只是没用:卡组织会盯你的授权成功率,而对着一堆死卡持续重试,正是一个商户号开始引人注意的方式之一。

注意订阅和单次结账从同一个拒付走的是不同的路。订阅续费没人盯着,所以按计划重试是正确行为;而结账 有人 盯着 —— 在他盯着转圈的时候偷偷重试,比立刻告诉他并保住购物车要糟得多。

最常被漏掉的是重试用尽那条分支。取消订阅并通知顾客 是个不起眼的方框,而没有它,失败模式是:一个账号安安静静地继续为一个四个月没付过钱的顾客服务。

看 Mermaid 源码
flowchart TD
    A[A charge attempt comes back declined] --> B{What kind of decline}
    B -->|Insufficient funds, issuer unavailable| C[Soft decline]
    B -->|Stolen card, closed account, do not honour| D[Hard decline]
    B -->|Authentication required| E[Send the customer to a challenge]
    C --> F{Is this a subscription}
    F -->|Yes| G[Retry on day 3, day 5 and day 7, then stop]
    F -->|No| H[Tell the customer now and keep the cart]
    G --> I{Did one of them go through}
    I -->|Yes| J[Captured]
    I -->|No| K[Cancel the subscription and email the customer]
    D --> L[Never retry, ask for a different card]
    E --> M{Did they finish the challenge}
    M -->|Yes| J
    M -->|No| H
处理扣款失败的流程图:软拒付对订阅按计划重试、硬拒付永不重试、需要验证的拒付送回验证流程。

把手指按在验证页上,然后关掉标签页

把画完的结账图拿来,找到顾客离开去银行验证页的那条箭头,手指按在那儿。现在假定:顾客通过了验证,然后关掉了标签页 —— 验证成功了,浏览器再也没回来。

只用图上的箭头,把你的系统从这一刻起会做什么走一遍。如果答案是 「渠道的消息到达,我们把订单标记为已支付,履约,顾客收到邮件」,那这个设计立得住,关标签页这件事无关紧要 —— 这正是正确的结果。如果走这一遍需要你说出「然后用户会回到成功页」这句话,那你就找到了一个真的窟窿:钱动了,而图上没有任何一条箭头会创建这张订单。

这不是罕见情况。这是一个在火车上用手机、并且完全照你说的做了的顾客。在流程离开你控制范围的那三个位置各做一次这个手指测试 —— 存在的窟窿,一定就在其中之一。

常见问题

接着读

去试试 text2diagram

打开工具
← 回到全部教程