在所有「不知不觉花掉的钱」里,订阅是最特殊的一类:它的每一次扣款都是合法的、你当初同意的,而你完全不知道它还在继续。视频会员、网盘、修图软件、几个能生成图的小工具、某个为了看一部剧开的流媒体——加起来的数字往往远超预期。
更麻烦的是,这类支出不会自己暴露。年付的一次扣完就忘,月付的金额小到不会出现在你的注意力里,涨价的邮件埋在收件箱第 40 屏。这篇给一套完整的处理方法:先用脚本从流水里把它们全部揪出来,再用一个自托管工具建一份真正属于你的台账。
订阅为什么是最容易漏的一类支出
五个原因,每一个都在削弱你的察觉能力:
- 自动续费:默认开启,多数平台把取消入口放在三层菜单之后
- 免费试用转付费:试用期结束时自动转为正式扣款,而那天你多半在想别的事
- 年付的遗忘曲线:一年前做的决定,谁也记不住。到期扣款时你只会觉得「这笔有点眼熟」
- 涨价不通知或通知到垃圾箱:很多服务涨价只发一封邮件,且不显著提示
- 家庭重复:「我们家的视频会员是哪个账号开的」——这个问题在很多家庭里没有明确答案
结果就是:你实际支付的周期性支出,通常显著高于你自己估的数字。这不是自制力问题,是记忆和注意力的结构性限制。所以不靠回忆,靠数据。
第一步:从流水里找周期性扣款
做法很朴素——导出流水,让脚本按「同一商户 + 金额稳定 + 间隔规律」三个特征去筛。人手做不到这件事,因为在几百条流水里比对间隔和金额差异,靠眼睛是不现实的。
导出方式:微信和支付宝都有账单导出功能(微信在「支付 – 钱包 – 账单 – 常见问题 – 下载账单」,支付宝在「账单 – 开具交易流水证明」),银行 App 里通常也有流水导出。导出后整理成一个 CSV,只需要三列:日期、商户、金额。列名用中文或英文都可以,脚本会自动识别常见表头。
import csv
import sys
from collections import defaultdict
from datetime import datetime
from statistics import median
DATE_KEYS = ("日期", "交易日期", "date", "交易时间")
MERCHANT_KEYS = ("商户", "交易对方", "摘要", "描述", "merchant", "description")
AMOUNT_KEYS = ("金额", "支出", "amount", "交易金额")
def pick(fieldnames, keys):
for k in keys:
for f in fieldnames:
if f and f.strip().lower() == k.lower():
return f
for k in keys:
for f in fieldnames:
if f and k.lower() in f.strip().lower():
return f
return None
def num(v):
v = str(v).replace(",", "").replace("¥", "").replace("¥", "").strip()
neg = v.startswith("-") or v.startswith("(")
v = "".join(c for c in v if c.isdigit() or c == ".")
if not v:
return None
return -float(v) if neg else float(v)
def load(path):
with open(path, encoding="utf-8-sig") as f:
rd = csv.DictReader(f)
dc = pick(rd.fieldnames, DATE_KEYS)
mc = pick(rd.fieldnames, MERCHANT_KEYS)
ac = pick(rd.fieldnames, AMOUNT_KEYS)
if not (dc and mc and ac):
sys.exit("CSV 需要包含 日期 / 商户 / 金额 三列,当前表头:%s" % rd.fieldnames)
rows = []
for r in rd:
d = None
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y%m%d", "%Y-%m-%d %H:%M:%S"):
try:
d = datetime.strptime(str(r[dc]).strip()[:19], fmt)
break
except ValueError:
continue
a = num(r[ac])
if d is None or a is None or a >= 0:
continue
rows.append((d, str(r[mc]).strip(), -a))
return rows
def width(s):
return sum(2 if ord(c) > 0x2E80 else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - width(s))
def analyze(rows, months=6, zombie=50):
groups = defaultdict(list)
for d, m, a in rows:
groups[m].append((d, a))
out = []
for merchant, items in groups.items():
items.sort()
if len(items) < 2:
continue
amounts = [a for _, a in items]
lo, hi = min(amounts), max(amounts)
avg = sum(amounts) / len(amounts)
if lo < 1 or (hi - lo) / hi > 0.05:
continue
gaps = [(items[i + 1][0] - items[i][0]).days for i in range(len(items) - 1)]
g = median(gaps)
if 25 <= g <= 35:
cycle, per_year, label = 30, 12, "月付"
elif 80 <= g <= 100:
cycle, per_year, label = 90, 4, "季付"
elif 350 <= g <= 380:
cycle, per_year, label = 365, 1, "年付"
else:
continue
out.append({
"merchant": merchant,
"label": label,
"times": len(items),
"amount": avg,
"yearly": avg * per_year,
"last": items[-1][0].strftime("%Y-%m-%d"),
"zombie": label == "月付" and avg <= zombie,
})
out.sort(key=lambda x: -x["yearly"])
return out
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "statement.csv"
rows = load(path)
res = analyze(rows)
print("流水 %d 条,覆盖 %s ~ %s" % (
len(rows),
min(r[0] for r in rows).strftime("%Y-%m-%d"),
max(r[0] for r in rows).strftime("%Y-%m-%d")))
print("-" * 68)
print(pad("商户", 24) + pad("周期", 6) + pad("次数", 6) +
pad("单次", 12) + pad("年化", 12) + "最近")
print("-" * 76)
total = 0.0
for r in res:
total += r["yearly"]
flag = " <- 小额月付,重点复核" if r["zombie"] else ""
print(pad(r["merchant"][:22], 24) + pad(r["label"], 6) + pad(str(r["times"]), 6) +
pad("%.2f" % r["amount"], 12) + pad("%.2f" % r["yearly"], 12) +
r["last"] + flag)
print("-" * 68)
print("识别出的周期性支出年化合计: %.2f" % total)
z = [r for r in res if r["zombie"]]
if z:
print("其中小额月付 %d 项,年化 %.2f —— 这类最容易忘了还在扣" % (
len(z), sum(r["yearly"] for r in z)))
if __name__ == "__main__":
main()
用一份六个月、27 条流水的测试数据跑出来的效果:
流水 27 条,覆盖 2026-03-08 ~ 2027-03-28
------------------------------------------------------------------------
商户 周期 次数 单次 年化 最近
----------------------------------------------------------------------------
云存储月付套餐 月付 7 68.00 816.00 2026-08-12
DesignSaaS 订阅 月付 7 45.00 540.00 2026-08-20 <- 小额月付,重点复核
某网盘年度会员 年付 2 365.00 365.00 2027-03-28
某视频会员 月付 7 25.00 300.00 2026-08-08 <- 小额月付,重点复核
----------------------------------------------------------------------------
识别出的周期性支出年化合计: 2021.00
其中小额月付 2 项,年化 840.00 —— 这类最容易忘了还在扣
几个值得注意的点:
看「年化」而不是「单次」。25 元一个月听上去毫无压力,年化 300 元就是另一回事。脚本把所有项目换算成同一尺度,你才能横向比较。
小额月付单独标出来。每月几十块的服务是最容易被忘掉的一类,因为单笔金额小到不值得你回忆。脚本默认把月付且金额不高的项标成「重点复核」。
年付的也要算进来。一年只扣一次的项目不会被任何月度视角发现,但对账单的影响是实打实的。
金额变化超过 5% 的会被过滤掉。这是为了排除日常消费(超市、餐厅、加油)。但要注意:如果某个订阅涨价了,它的金额就不再稳定,脚本可能漏掉它——所以跑完之后,值得再手工扫一遍「出现两次以上但金额有变」的商户。
第二步:建一份自己掌控的台账
找出清单只是当下这一刻的快照。真正的价值在于持续管理:知道下次什么时候扣、扣多少、以及在那之前要不要取消。这件事用表格也能做,但你会忘记更新;用商业订阅管理 App 更省事,但代价是要交出银行或支付账户的读取权限。
Wallos(ellite/Wallos,GPL-3.0)是这个需求下最合适的一个开源项目,GitHub 上 8 千多 star,定位很纯粹——只做订阅和周期性支出管理,不试图做完整的记账软件。它有几个特点正好对上订阅场景:
- 不连接银行账户:手动录入,或从 CSV 批量导入。这点看似麻烦,实际是个好处——手动填的过程本身就会让你重新审视每一项
- 到期提醒:扣款前通过邮件、Discord、Telegram、Gotify、ntfy 或 webhook 通知你。这是整个工具的核心价值:在扣款前而不是扣款后知道
- 月/年总额与分类统计:一眼看到每月固定流出多少、钱集中在哪几类
- 多币种:有海外服务的家庭很实用,自动按汇率换算到基准货币
- 家庭多用户:可以给每个家庭成员各自建台账,解决「这个会员到底是谁开的」这类问题
- 轻量:PHP + SQLite,单个 Docker 容器,不需要额外数据库,资源占用很小
部署是标准的 Docker Compose,改两个卷的路径就能跑起来,默认访问端口 8282。注意首次登录的默认账号密码是公开的,一定要立刻改掉,并且不要把服务裸奔在公网上。
另外它支持把 AI 建议接到 ChatGPT、Gemini 或者本地 Ollama 上——如果只是记录和提醒,这个功能用不用都行;在意数据不出本机的话,本地模型是更稳的选择。
第三步:决定取舍,并且让决定生效
清单和台账都齐了之后,处理顺序很重要。建议按这个顺序走:
- 先算总额。把年化合计写下来。这个数字是后面所有判断的基准
- 按年化金额从高到低处理。前三项处理完,通常就是八成的效果
- 对每一项问三个问题:上个月真用过吗?不用它有没有替代方案?如果删掉,我会想念它吗?三个问题里有两个是否定,就可以取消
- 要取消的当场取消,不要「明天再弄」。从决定到执行的间隔越长,取消率越低
- 要保留的,关掉自动续费。改成手动续费,把控制权拿回来。同时关掉自动续费通常也不影响你用到当前周期结束
- 把保留项录入台账,设好提醒。提醒时间设在扣款日前 3–7 天,留出决策余地
关于年付项有个特别提醒:年付的取消决策必须在到期前做,而不是到期后。很多服务的年付一旦扣款,退款政策非常严格。在台账里给所有年付项单独设一个「到期前 14 天」的提醒,是最省心的一条纪律。
省钱之外的一个附带好处
把订阅摊开看一遍,会暴露一件平时不容易意识到的事:你在为多少「偶尔用一次」的能力按月付费。一年用三次的工具,和一年用三十次的工具,月费是一样的——这个价格结构对低频使用者天然不利。
所以对于低频需求,更合理的做法是把固定成本换成可变成本:用一次付一次的钱,而不是一直买着「随时可用」的权利。这不只适用于软件,素材、额度包、临时算力都是一个道理。判断标准很简单:如果某项服务过去三个月里你用它的次数用一只手数得过来,它就是月度订阅的典型错误使用者。
按需获取,替代长期订阅
素材是这类「低频但单价不低」的典型:一年里真正需要商用图的时间可能就几个项目,但订阅是按月扣的,额度还月度清零。按张获取则完全跟着项目走,不用时为成本为零。 代下载仅提供文件获取服务,商用授权需自行购买。
国内订阅的三类,有一类不在支付流水里
把流水跑一遍能找出大部分周期性扣款,但国内的订阅分布有三个特点,值得单独说:
- 视频、音乐、网盘类会员:走微信/支付宝,流水查重脚本可以直接覆盖
- 运营商增值业务(彩铃、云盘会员、各类权益包):通常不出现在支付流水里,而是随话费一起扣。这类必须单独拿话费账单明细跑一遍,否则永远查不出来
- 宽带、副卡这类绑定关系:不是订阅但性质接近,取消前后牵涉合约
所以国内完整的做法是「两条线」:支付流水查平台会员,运营商账单查通信类增值业务。后者的筛查脚本和取消路径见话费宽带降档被拒怎么办;取消之后留下的账单证据怎么归档,见票据本地 OCR 归档。
常见问题
我的流水没有导出功能,怎么办?
多数支付渠道都提供导出,入口通常在「账单」或「钱包」的设置里,申请后一般会通过邮件发送文件。如果确实拿不到完整导出,退一步的做法是:看近三个月的账单页面,把每一笔自己认不出来的扣款手工抄下来,只需要商户名和金额两列,同样能完成大部分审计。重点是覆盖足够长的时间跨度,因为年付项可能只在某一个月出现。
取消订阅之后,之前付的钱能退吗?
多数情况不能,尤其是已经扣款的周期。这也是为什么提醒要设在扣款日前。唯一相对宽松的是部分服务的刚扣款短时间内申诉,具体政策各家差别很大。稳妥的做法是不依赖退款,而是靠提前提醒把决策做在扣款之前。
自建的订阅台账和商业 App 比,哪些地方不如它?
商业 App 能自动从银行流水里识别订阅,省掉手工录入这一步,这是它最大的优势。代价是需要绑定银行或支付账户的读取权限,把完整消费记录交给第三方。自托管方案的前提是你接受「手工录入 10–20 项、只在新增订阅时维护」这件事——一次大概二十分钟,之后很少需要动。选择取决于你更在意便利还是数据归属。




评论 (0)