Files
bid-doctor/server/tests/test_billing_flow.py
YANG JIANKUAN dd8c04001d
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。

用户端:
- 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV)
- 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒
- 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick)
- 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码)
- 通知铃铛、账号页

运营后台:
- 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单
- 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容
- 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签)
- 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤

数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。
另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:06:06 +08:00

206 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""M6 剩下的 P0(m6-plan):额度流水、80% / 100% 提醒、开通和续费、到期只读、删除项目、注销账号、通知已读。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID, uuid4
import httpx
import pytest
from sqlalchemy import text
from bid_doctor.billing import service as billing
from bid_doctor.billing import tasks
from bid_doctor.core.db import TenantContext, tenant_tx
from tests.conftest import User, balance, project_with_tender, register, run_parse
OPS = uuid4() # 运营后台还没做:直接调 service,记一个运营编号
INVOICE = {
"title": "测试集成公司",
"tax_no": "91320100MA1XXXXX0Y",
"invoice_type": "general",
"contact_name": "张工",
"contact_phone": "13800000000",
}
def ctx_of(u: User) -> TenantContext:
return TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0), actor_name="系统")
async def activate(u: User, request_id: str) -> None:
async with tenant_tx(ctx_of(u)) as s:
await billing.activate(s, ctx_of(u), UUID(request_id), ops_id=OPS)
async def entries(u: User, **params: Any) -> list[dict[str, Any]]:
r = await u.get("/quota/entries", params=params)
assert r.status_code == 200, r.text
return r.json()["items"]
async def test_ledger_alerts_and_pages(user: User) -> None:
"""流水:任务能对上项目和用途,余额逐条可核;用到 80%、100% 各提醒一次(免费版 3 次)。"""
project_id, doc_id = await project_with_tender(user)
assert (await run_parse(user, doc_id))["status"] == "succeeded"
rows = await entries(user)
assert [(r["kind"], r["amount"], r["balance_after"]) for r in rows] == [
("settle", 0, 2),
("hold", -1, 2),
("grant", 3, 3),
]
hold = rows[1]
assert hold["project"] == {"id": project_id, "name": hold["project"]["name"], "deleted": False}
assert hold["ref"]["type"] == "parse_job" and hold["ref"]["label"] == "招标文件"
assert rows[0]["hold_entry_id"] == hold["id"] and rows[2]["reason"] == "注册赠送"
# 分页:每页 1 条,接着上一页往后
first = (await user.get("/quota/entries", params={"limit": 1})).json()
second = (await user.get("/quota/entries", params={"limit": 1, "cursor": first["next_cursor"]})).json()
assert first["items"][0]["kind"] == "settle" and second["items"][0]["kind"] == "hold"
# 再解析两次:第 3 次时余额 0,80% 和 100% 一起提醒(每档只一次)
for _ in range(2):
r = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
from bid_doctor.document.pipeline import runner
await runner.run(UUID(user.tenant_id), UUID(r.json()["id"]))
assert await balance(user) == 0
kinds = [n["kind"] for n in (await user.get("/notifications")).json()["items"]]
assert kinds.count("quota_80") == 1 and kinds.count("quota_100") == 1
q = (await user.get("/quota")).json()
assert q["usage"]["tender"] == 3 and q["accounts"][0]["period_used"] == 3
async def test_activation_renewal_expiry_read_only(app_client: httpx.AsyncClient) -> None:
u = await register(app_client, "开通测试公司")
bad = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "tax_no": "123"}})
assert bad.status_code == 422
special = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "invoice_type": "special"}})
assert special.status_code == 422 and special.json()["code"] == "validation_failed"
r = await u.post("/activation-requests", json={"invoice_info": INVOICE})
assert r.status_code == 201, r.text
req = r.json()
assert req["status"] == "submitted" and req["request_no"].startswith("KT-") and req["payment"]["payee_name"]
assert (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()["code"] == "activation_pending"
assert (await u.get("/quota")).json()["pending_request"]["id"] == req["id"]
# 运营开通:免费版剩下的 3 次清零,发放专业版 500 次
await activate(u, req["id"])
me = (await u.get("/me")).json()
assert me["plan"]["code"] == "pro" and me["plan"]["read_only"] is False
rows = await entries(u)
assert [(x["kind"], x["amount"]) for x in rows[:2]] == [("grant", 500), ("expire", -3)]
assert await balance(u) == 500
ends = datetime.fromisoformat(me["plan"]["ends_at"])
notes = [n["kind"] for n in (await u.get("/notifications")).json()["items"]]
assert "subscription_activated" in notes
# 续费:当前一期还没到期,新一期排队,从旧一期结束时开始
r2 = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
await activate(u, r2["id"])
q = (await u.get("/quota")).json()
assert q["renewal"] and datetime.fromisoformat(q["renewal"]["starts_at"]) == ends and await balance(u) == 500
# 到期前 30 天、7 天提醒各一次;重复跑不重复提醒(续过费的不提醒,先把排队的一期撤掉)
async with tenant_tx(ctx_of(u)) as s:
await s.execute(text("UPDATE subscription SET status = 'cancelled' WHERE status = 'scheduled'"))
for days in (25, 25, 5):
await tasks.tick(ends - timedelta(days=days))
reminders = [n for n in (await u.get("/notifications")).json()["items"] if n["kind"] == "subscription_expiring"]
assert [n["title"] for n in reminders] == ["专业版还有 5 天到期", "专业版还有 25 天到期"]
# 到期:余额清零,只读——新建项目被拒,开通申请、通知已读照常
await tasks.tick(ends + timedelta(minutes=1))
me = (await u.get("/me")).json()
assert me["plan"]["read_only"] is True and await balance(u) == 0
assert (await u.post("/projects", json={})).json()["code"] == "plan_expired"
assert (await u.post("/notifications/read", json={})).status_code == 204
assert (await u.get("/notifications", params={"unread": True})).json()["unread_count"] == 0
r3 = await u.post("/activation-requests", json={"invoice_info": INVOICE})
assert r3.status_code == 201
await activate(u, r3.json()["id"]) # 过期后续费:马上开始新一期
assert (await u.get("/me")).json()["plan"]["read_only"] is False and await balance(u) == 500
async def test_delete_project(user: User) -> None:
project_id, doc_id = await project_with_tender(user)
queued = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
assert queued.status_code == 201
blocked = await user.delete(f"/projects/{project_id}")
assert blocked.status_code == 409 and blocked.json()["code"] == "job_already_running"
assert (await user.post(f"/parse-jobs/{queued.json()['id']}/cancel")).status_code in (200, 202)
from bid_doctor.document.pipeline import runner
await runner.run(UUID(user.tenant_id), UUID(queued.json()["id"])) # 取消:预扣退回
assert (await run_parse(user, doc_id))["status"] == "succeeded"
r = await user.delete(f"/projects/{project_id}")
assert r.status_code == 204, r.text
assert (await user.get(f"/projects/{project_id}")).status_code == 404
# 流水还在,项目标「已删除」;没有净扣点的任务(取消的那次)照样对得上
rows = [x for x in await entries(user) if x["project"]]
assert rows and all(x["project"]["deleted"] and x["project"]["name"] is None for x in rows)
assert sum(x["amount"] for x in rows) == -1
async with tenant_tx(ctx_of(user)) as s:
key = (await s.execute(text("SELECT wrapped_key FROM data_key WHERE project_id = :p"), {"p": project_id})).one()
audit = (
await s.execute(
text(
"SELECT count(*) FILTER (WHERE before IS NOT NULL OR after IS NOT NULL), count(*)"
" FROM audit_event WHERE project_id = :p"
),
{"p": project_id},
)
).one()
from bid_doctor.core import keys
from bid_doctor.core.errors import AppError
with pytest.raises(AppError) as err: # 删除后不再建新密钥(还在跑的任务写不回文件)
await keys.data_key(s, ctx_of(user), UUID(project_id))
assert err.value.code == "not_found"
assert key[0] is None and audit[0] == 0 and audit[1] >= 2
async def test_close_tenant(app_client: httpx.AsyncClient) -> None:
u = await register(app_client, "注销测试公司")
project_id, doc_id = await project_with_tender(u)
assert (await run_parse(u, doc_id))["status"] == "succeeded"
from bid_doctor.core.crypto import phone_lookup_key
from bid_doctor.core.valkey import valkey
await valkey().delete(f"sms:rate:{phone_lookup_key(u.phone)}") # 注册时刚发过一条,60 秒内不能再发
assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code == 202
wrong = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "别的公司"})
assert wrong.status_code == 422 and wrong.json()["code"] == "tenant_name_mismatch"
bad_code = await u.post("/tenant/close", json={"sms_code": "111111", "confirm_tenant_name": "注销测试公司"})
assert bad_code.json()["code"] == "sms_code_invalid"
assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code in (202, 429)
r = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "注销测试公司"})
assert r.status_code == 202, r.text
assert (await u.get("/me")).status_code == 401
from bid_doctor.identity import closure
await closure.finish(UUID(u.tenant_id))
await closure.finish(UUID(u.tenant_id)) # 重复执行没事
async with tenant_tx(ctx_of(u)) as s:
left = (
await s.execute(
text(
"SELECT (SELECT count(*) FROM project),"
" (SELECT count(*) FROM data_key WHERE wrapped_key IS NOT NULL),"
" (SELECT count(*) FROM quota_entry), (SELECT status FROM tenant),"
" (SELECT count(*) FROM audit_event WHERE actor_name IS NOT NULL OR after IS NOT NULL)"
)
)
).one()
assert left[0] == 0 and left[1] == 0 and left[2] > 0 and left[3] == "closed" and left[4] == 0
# 手机号可以重新注册(新企业)
assert (await app_client.post("/auth/sms-codes", json={"phone": u.phone, "purpose": "register"})).status_code in (
202,
429,
)
del project_id