test_surrender_flow.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. from datetime import UTC, date, datetime
  2. from fastapi.testclient import TestClient
  3. from zbt.commands.seed import build_seed_manifest
  4. from zbt.core.config import Settings
  5. from zbt.core.passwords import PasswordService
  6. from zbt.domains.catalog.repository import InMemoryCatalogRepository
  7. from zbt.domains.enrollment.models import Policy
  8. from zbt.domains.enrollment.repository import InMemoryEnrollmentRepository
  9. from zbt.domains.identity.repository import InMemoryIdentityRepository
  10. from zbt.main import create_app
  11. NOW = datetime(2026, 7, 26, 10, 0, tzinfo=UTC)
  12. def test_active_policy_owner_can_preview_deterministic_surrender_refund() -> None:
  13. identities = InMemoryIdentityRepository()
  14. enrollments = InMemoryEnrollmentRepository()
  15. app = create_app(
  16. settings=Settings(
  17. app_env="test",
  18. jwt_access_secret="a" * 32,
  19. jwt_refresh_secret="b" * 32,
  20. field_encryption_key="c" * 32,
  21. ),
  22. identity_repository=identities,
  23. catalog_repository=InMemoryCatalogRepository(),
  24. enrollment_repository=enrollments,
  25. clock=lambda: NOW,
  26. )
  27. with TestClient(app) as client:
  28. token = client.post(
  29. "/api/v1/h5/auth/login",
  30. json={"mobile": "18800000001", "code": "147258"},
  31. ).json()["data"]["tokens"]["access_token"]
  32. user = identities.get_h5_user_by_mobile("18800000001")
  33. assert user is not None
  34. enrollments.save_policy(
  35. Policy(
  36. id="01KZ0000000000000000000001",
  37. policy_no="ZBT-P-20260701-0001",
  38. order_id="01KZ0000000000000000000002",
  39. user_id=user.id,
  40. product_id="01KZ0000000000000000000003",
  41. product_version_id="01KZ0000000000000000000004",
  42. plan_id="01KZ0000000000000000000005",
  43. premium_cents=36_500,
  44. currency="CNY",
  45. coverage_start=date(2026, 7, 1),
  46. coverage_end=date(2027, 6, 30),
  47. status="ACTIVE",
  48. issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC),
  49. )
  50. )
  51. response = client.get(
  52. "/api/v1/h5/policies/01KZ0000000000000000000001/surrender-preview",
  53. headers={"Authorization": f"Bearer {token}"},
  54. )
  55. assert response.status_code == 200
  56. assert response.json()["data"] == {
  57. "policy_id": "01KZ0000000000000000000001",
  58. "policy_no": "ZBT-P-20260701-0001",
  59. "eligible": True,
  60. "reason_code": "ELIGIBLE",
  61. "reason": "保单符合退保申请条件",
  62. "refund_amount_cents": 30_600,
  63. "refund_basis": "UNEARNED_PREMIUM_WITH_DEDUCTION",
  64. "refund_explanation": (
  65. "已超过10天犹豫期,按剩余保障天数折算未满期保费,并扣减10%。"
  66. ),
  67. "currency": "CNY",
  68. "rule_version": "surrender-v1",
  69. "calculation": {
  70. "premium_cents": 36_500,
  71. "total_days": 365,
  72. "elapsed_days": 25,
  73. "remaining_days": 340,
  74. "gross_unearned_cents": 34_000,
  75. "deduction_cents": 3_400,
  76. "cooling_off": False,
  77. "pre_effective": False,
  78. },
  79. }
  80. def test_newly_issued_policy_can_be_cancelled_before_coverage_starts() -> None:
  81. identities = InMemoryIdentityRepository()
  82. enrollments = InMemoryEnrollmentRepository()
  83. app = create_app(
  84. settings=Settings(
  85. app_env="test",
  86. jwt_access_secret="a" * 32,
  87. jwt_refresh_secret="b" * 32,
  88. field_encryption_key="c" * 32,
  89. ),
  90. identity_repository=identities,
  91. catalog_repository=InMemoryCatalogRepository(),
  92. enrollment_repository=enrollments,
  93. clock=lambda: NOW,
  94. )
  95. with TestClient(app) as client:
  96. token = client.post(
  97. "/api/v1/h5/auth/login",
  98. json={"mobile": "18800000001", "code": "147258"},
  99. ).json()["data"]["tokens"]["access_token"]
  100. user = identities.get_h5_user_by_mobile("18800000001")
  101. assert user is not None
  102. enrollments.save_policy(
  103. Policy(
  104. id="01KZ0000000000000000000006",
  105. policy_no="POL-20260726-NEW001",
  106. order_id="01KZ0000000000000000000007",
  107. user_id=user.id,
  108. product_id="01KZ0000000000000000000008",
  109. product_version_id="01KZ0000000000000000000009",
  110. plan_id="01KZ0000000000000000000010",
  111. premium_cents=15_900,
  112. currency="CNY",
  113. coverage_start=date(2026, 7, 27),
  114. coverage_end=date(2027, 7, 26),
  115. status="ACTIVE",
  116. issued_at=NOW,
  117. )
  118. )
  119. response = client.get(
  120. "/api/v1/h5/policies/01KZ0000000000000000000006/surrender-preview",
  121. headers={"Authorization": f"Bearer {token}"},
  122. )
  123. created = client.post(
  124. "/api/v1/h5/surrenders",
  125. headers={
  126. "Authorization": f"Bearer {token}",
  127. "Idempotency-Key": "pre-effective-surrender-001",
  128. },
  129. json={
  130. "policy_id": "01KZ0000000000000000000006",
  131. "reason": "保障计划调整",
  132. },
  133. )
  134. request_id = created.json()["data"]["request_id"]
  135. confirmed = client.post(
  136. f"/api/v1/h5/surrenders/{request_id}/confirm",
  137. headers={"Authorization": f"Bearer {token}"},
  138. )
  139. assert response.status_code == 200
  140. assert response.json()["data"] == {
  141. "policy_id": "01KZ0000000000000000000006",
  142. "policy_no": "POL-20260726-NEW001",
  143. "eligible": True,
  144. "reason_code": "ELIGIBLE",
  145. "reason": "保单符合退保申请条件",
  146. "refund_amount_cents": 15_900,
  147. "refund_basis": "PRE_EFFECTIVE_FULL_REFUND",
  148. "refund_explanation": "保单保障尚未开始,按已交保费全额退还。",
  149. "currency": "CNY",
  150. "rule_version": "surrender-v1",
  151. "calculation": {
  152. "premium_cents": 15_900,
  153. "total_days": 365,
  154. "elapsed_days": 0,
  155. "remaining_days": 365,
  156. "gross_unearned_cents": 15_900,
  157. "deduction_cents": 0,
  158. "cooling_off": False,
  159. "pre_effective": True,
  160. },
  161. }
  162. assert created.status_code == 201
  163. assert created.json()["data"]["status"] == "PENDING_CONFIRMATION"
  164. assert created.json()["data"]["calculation"]["pre_effective"] is True
  165. assert confirmed.status_code == 200
  166. assert confirmed.json()["data"]["status"] == "REFUND_PENDING"
  167. assert confirmed.json()["data"]["risk_level"] == "LOW"
  168. policy = enrollments.get_policy("01KZ0000000000000000000006")
  169. assert policy is not None
  170. assert policy.status == "SURRENDERING"
  171. def test_surrender_submission_is_idempotent_and_waits_for_customer_confirmation() -> None:
  172. identities = InMemoryIdentityRepository()
  173. enrollments = InMemoryEnrollmentRepository()
  174. app = create_app(
  175. settings=Settings(
  176. app_env="test",
  177. jwt_access_secret="a" * 32,
  178. jwt_refresh_secret="b" * 32,
  179. field_encryption_key="c" * 32,
  180. ),
  181. identity_repository=identities,
  182. catalog_repository=InMemoryCatalogRepository(),
  183. enrollment_repository=enrollments,
  184. clock=lambda: NOW,
  185. )
  186. with TestClient(app) as client:
  187. token = client.post(
  188. "/api/v1/h5/auth/login",
  189. json={"mobile": "18800000001", "code": "147258"},
  190. ).json()["data"]["tokens"]["access_token"]
  191. user = identities.get_h5_user_by_mobile("18800000001")
  192. assert user is not None
  193. enrollments.save_policy(
  194. Policy(
  195. id="01KZ0000000000000000000011",
  196. policy_no="ZBT-P-20260701-0011",
  197. order_id="01KZ0000000000000000000012",
  198. user_id=user.id,
  199. product_id="01KZ0000000000000000000013",
  200. product_version_id="01KZ0000000000000000000014",
  201. plan_id="01KZ0000000000000000000015",
  202. premium_cents=36_500,
  203. currency="CNY",
  204. coverage_start=date(2026, 7, 1),
  205. coverage_end=date(2027, 6, 30),
  206. status="ACTIVE",
  207. issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC),
  208. )
  209. )
  210. headers = {
  211. "Authorization": f"Bearer {token}",
  212. "Idempotency-Key": "surrender-client-001",
  213. }
  214. payload = {
  215. "policy_id": "01KZ0000000000000000000011",
  216. "reason": "保障计划调整",
  217. }
  218. first = client.post("/api/v1/h5/surrenders", headers=headers, json=payload)
  219. repeated = client.post("/api/v1/h5/surrenders", headers=headers, json=payload)
  220. assert first.status_code == 201
  221. assert first.json()["data"]["status"] == "PENDING_CONFIRMATION"
  222. assert first.json()["data"]["refund_amount_cents"] == 30_600
  223. assert first.json()["data"]["request_no"].startswith("ZBT-TB-20260726-")
  224. assert repeated.status_code == 200
  225. assert repeated.json()["data"]["request_id"] == first.json()["data"]["request_id"]
  226. def test_customer_confirmation_pauses_workflow_for_operations_approval() -> None:
  227. identities = InMemoryIdentityRepository()
  228. enrollments = InMemoryEnrollmentRepository()
  229. app = create_app(
  230. settings=Settings(
  231. app_env="test",
  232. jwt_access_secret="a" * 32,
  233. jwt_refresh_secret="b" * 32,
  234. field_encryption_key="c" * 32,
  235. ),
  236. identity_repository=identities,
  237. catalog_repository=InMemoryCatalogRepository(),
  238. enrollment_repository=enrollments,
  239. clock=lambda: NOW,
  240. )
  241. with TestClient(app) as client:
  242. token = client.post(
  243. "/api/v1/h5/auth/login",
  244. json={"mobile": "18800000001", "code": "147258"},
  245. ).json()["data"]["tokens"]["access_token"]
  246. user = identities.get_h5_user_by_mobile("18800000001")
  247. assert user is not None
  248. enrollments.save_policy(
  249. Policy(
  250. id="01KZ0000000000000000000021",
  251. policy_no="ZBT-P-20260701-0021",
  252. order_id="01KZ0000000000000000000022",
  253. user_id=user.id,
  254. product_id="01KZ0000000000000000000023",
  255. product_version_id="01KZ0000000000000000000024",
  256. plan_id="01KZ0000000000000000000025",
  257. premium_cents=36_500,
  258. currency="CNY",
  259. coverage_start=date(2026, 7, 1),
  260. coverage_end=date(2027, 6, 30),
  261. status="ACTIVE",
  262. issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC),
  263. )
  264. )
  265. headers = {
  266. "Authorization": f"Bearer {token}",
  267. "Idempotency-Key": "surrender-client-002",
  268. }
  269. created = client.post(
  270. "/api/v1/h5/surrenders",
  271. headers=headers,
  272. json={
  273. "policy_id": "01KZ0000000000000000000021",
  274. "reason": "保障计划调整",
  275. },
  276. ).json()["data"]
  277. confirmed = client.post(
  278. f"/api/v1/h5/surrenders/{created['request_id']}/confirm",
  279. headers={"Authorization": f"Bearer {token}"},
  280. )
  281. detail = client.get(
  282. f"/api/v1/h5/surrenders/{created['request_id']}",
  283. headers={"Authorization": f"Bearer {token}"},
  284. )
  285. assert confirmed.status_code == 200
  286. assert confirmed.json()["data"]["status"] == "WAITING_APPROVAL"
  287. assert confirmed.json()["data"]["workflow"]["paused"] is True
  288. assert confirmed.json()["data"]["workflow"]["step"] == "OPERATIONS_APPROVAL"
  289. assert detail.status_code == 200
  290. assert [item["event_type"] for item in detail.json()["data"]["timeline"]] == [
  291. "REQUEST_CREATED",
  292. "CUSTOMER_CONFIRMED",
  293. "RISK_ASSESSED",
  294. "WAITING_APPROVAL",
  295. ]
  296. def test_low_risk_surrender_is_auto_approved_and_enters_refund_queue() -> None:
  297. identities = InMemoryIdentityRepository()
  298. enrollments = InMemoryEnrollmentRepository()
  299. app = create_app(
  300. settings=Settings(
  301. app_env="test",
  302. jwt_access_secret="a" * 32,
  303. jwt_refresh_secret="b" * 32,
  304. field_encryption_key="c" * 32,
  305. ),
  306. identity_repository=identities,
  307. catalog_repository=InMemoryCatalogRepository(),
  308. enrollment_repository=enrollments,
  309. clock=lambda: NOW,
  310. )
  311. with TestClient(app) as client:
  312. token = client.post(
  313. "/api/v1/h5/auth/login",
  314. json={"mobile": "18800000001", "code": "147258"},
  315. ).json()["data"]["tokens"]["access_token"]
  316. user = identities.get_h5_user_by_mobile("18800000001")
  317. assert user is not None
  318. policy_id = "01KZ0000000000000000000061"
  319. enrollments.save_policy(
  320. Policy(
  321. id=policy_id,
  322. policy_no="ZBT-P-20260701-0061",
  323. order_id="01KZ0000000000000000000062",
  324. user_id=user.id,
  325. product_id="01KZ0000000000000000000063",
  326. product_version_id="01KZ0000000000000000000064",
  327. plan_id="01KZ0000000000000000000065",
  328. premium_cents=19_900,
  329. currency="CNY",
  330. coverage_start=date(2026, 7, 1),
  331. coverage_end=date(2027, 6, 30),
  332. status="ACTIVE",
  333. issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC),
  334. )
  335. )
  336. created = client.post(
  337. "/api/v1/h5/surrenders",
  338. headers={
  339. "Authorization": f"Bearer {token}",
  340. "Idempotency-Key": "surrender-low-risk-001",
  341. },
  342. json={"policy_id": policy_id, "reason": "保障计划调整"},
  343. ).json()["data"]
  344. confirmed = client.post(
  345. f"/api/v1/h5/surrenders/{created['request_id']}/confirm",
  346. headers={"Authorization": f"Bearer {token}"},
  347. )
  348. detail = client.get(
  349. f"/api/v1/h5/surrenders/{created['request_id']}",
  350. headers={"Authorization": f"Bearer {token}"},
  351. ).json()["data"]
  352. assert confirmed.status_code == 200
  353. assert confirmed.json()["data"]["status"] == "REFUND_PENDING"
  354. assert confirmed.json()["data"]["risk_level"] == "LOW"
  355. assert confirmed.json()["data"]["workflow"]["paused"] is False
  356. assert confirmed.json()["data"]["workflow"]["step"] == "AUTO_APPROVED"
  357. assert {
  358. event["node"]
  359. for event in confirmed.json()["data"]["workflow"]["node_events"]
  360. } >= {
  361. "planner",
  362. "check_amount",
  363. "check_reason",
  364. "check_cooling_off",
  365. "aggregate_risk",
  366. "auto_approved",
  367. }
  368. assert [item["event_type"] for item in detail["timeline"]] == [
  369. "REQUEST_CREATED",
  370. "CUSTOMER_CONFIRMED",
  371. "RISK_ASSESSED",
  372. "AUTO_APPROVED",
  373. "REFUND_PROCESSING",
  374. ]
  375. policy = enrollments.get_policy(policy_id)
  376. assert policy is not None
  377. assert policy.status == "SURRENDERING"
  378. def test_surrender_reason_detail_can_trigger_sensitive_reason_review() -> None:
  379. identities = InMemoryIdentityRepository()
  380. enrollments = InMemoryEnrollmentRepository()
  381. app = create_app(
  382. settings=Settings(
  383. app_env="test",
  384. jwt_access_secret="a" * 32,
  385. jwt_refresh_secret="b" * 32,
  386. field_encryption_key="c" * 32,
  387. ),
  388. identity_repository=identities,
  389. catalog_repository=InMemoryCatalogRepository(),
  390. enrollment_repository=enrollments,
  391. clock=lambda: NOW,
  392. )
  393. with TestClient(app) as client:
  394. token = client.post(
  395. "/api/v1/h5/auth/login",
  396. json={"mobile": "18800000001", "code": "147258"},
  397. ).json()["data"]["tokens"]["access_token"]
  398. user = identities.get_h5_user_by_mobile("18800000001")
  399. assert user is not None
  400. policy_id = "01KZ0000000000000000000071"
  401. enrollments.save_policy(
  402. Policy(
  403. id=policy_id,
  404. policy_no="ZBT-P-20260701-0071",
  405. order_id="01KZ0000000000000000000072",
  406. user_id=user.id,
  407. product_id="01KZ0000000000000000000073",
  408. product_version_id="01KZ0000000000000000000074",
  409. plan_id="01KZ0000000000000000000075",
  410. premium_cents=19_900,
  411. currency="CNY",
  412. coverage_start=date(2026, 7, 1),
  413. coverage_end=date(2027, 6, 30),
  414. status="ACTIVE",
  415. issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC),
  416. )
  417. )
  418. created_response = client.post(
  419. "/api/v1/h5/surrenders",
  420. headers={
  421. "Authorization": f"Bearer {token}",
  422. "Idempotency-Key": "surrender-sensitive-reason-001",
  423. },
  424. json={
  425. "policy_id": policy_id,
  426. "reason_category": "其他原因",
  427. "reason_detail": "发现存在重复扣费,希望人工协助核实。",
  428. },
  429. )
  430. created = created_response.json()["data"]
  431. confirmed = client.post(
  432. f"/api/v1/h5/surrenders/{created['request_id']}/confirm",
  433. headers={"Authorization": f"Bearer {token}"},
  434. )
  435. assert created_response.status_code == 201
  436. assert created["reason"] == "其他原因:发现存在重复扣费,希望人工协助核实。"
  437. assert confirmed.status_code == 200
  438. assert confirmed.json()["data"]["status"] == "WAITING_APPROVAL"
  439. assert confirmed.json()["data"]["risk_level"] == "HIGH"
  440. assert any(
  441. "重复扣费" in reason
  442. for reason in confirmed.json()["data"]["risk_reasons"]
  443. )
  444. def test_operations_approval_resumes_workflow_and_marks_policy_surrendering() -> None:
  445. identities = InMemoryIdentityRepository()
  446. enrollments = InMemoryEnrollmentRepository()
  447. manifest = build_seed_manifest(PasswordService().hash("zaq1XSW@"))
  448. for admin in manifest.admin_users:
  449. identities.save_admin_user(admin)
  450. app = create_app(
  451. settings=Settings(
  452. app_env="test",
  453. jwt_access_secret="a" * 32,
  454. jwt_refresh_secret="b" * 32,
  455. field_encryption_key="c" * 32,
  456. ),
  457. identity_repository=identities,
  458. catalog_repository=InMemoryCatalogRepository(),
  459. enrollment_repository=enrollments,
  460. clock=lambda: NOW,
  461. )
  462. with TestClient(app) as client:
  463. h5_token = client.post(
  464. "/api/v1/h5/auth/login",
  465. json={"mobile": "18800000001", "code": "147258"},
  466. ).json()["data"]["tokens"]["access_token"]
  467. user = identities.get_h5_user_by_mobile("18800000001")
  468. assert user is not None
  469. policy_id = "01KZ0000000000000000000031"
  470. enrollments.save_policy(
  471. Policy(
  472. id=policy_id,
  473. policy_no="ZBT-P-20260701-0031",
  474. order_id="01KZ0000000000000000000032",
  475. user_id=user.id,
  476. product_id="01KZ0000000000000000000033",
  477. product_version_id="01KZ0000000000000000000034",
  478. plan_id="01KZ0000000000000000000035",
  479. premium_cents=36_500,
  480. currency="CNY",
  481. coverage_start=date(2026, 7, 1),
  482. coverage_end=date(2027, 6, 30),
  483. status="ACTIVE",
  484. issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC),
  485. )
  486. )
  487. h5_headers = {
  488. "Authorization": f"Bearer {h5_token}",
  489. "Idempotency-Key": "surrender-client-003",
  490. }
  491. created = client.post(
  492. "/api/v1/h5/surrenders",
  493. headers=h5_headers,
  494. json={"policy_id": policy_id, "reason": "保障计划调整"},
  495. ).json()["data"]
  496. client.post(
  497. f"/api/v1/h5/surrenders/{created['request_id']}/confirm",
  498. headers={"Authorization": f"Bearer {h5_token}"},
  499. )
  500. admin_token = client.post(
  501. "/api/v1/admin/auth/login",
  502. json={"username": "admin", "password": "zaq1XSW@"},
  503. ).json()["data"]["tokens"]["access_token"]
  504. admin_headers = {"Authorization": f"Bearer {admin_token}"}
  505. rejected_without_note = client.post(
  506. f"/api/v1/admin/surrenders/{created['request_id']}/review",
  507. headers=admin_headers,
  508. json={"decision": "REJECT", "note": " "},
  509. )
  510. approved = client.post(
  511. f"/api/v1/admin/surrenders/{created['request_id']}/review",
  512. headers=admin_headers,
  513. json={"decision": "APPROVE", "note": "资料与退款试算无误"},
  514. )
  515. repeated = client.post(
  516. f"/api/v1/admin/surrenders/{created['request_id']}/review",
  517. headers=admin_headers,
  518. json={"decision": "APPROVE", "note": "重复审批"},
  519. )
  520. assert rejected_without_note.status_code == 400
  521. assert rejected_without_note.json()["error"]["code"] == (
  522. "SURRENDER_REJECTION_NOTE_REQUIRED"
  523. )
  524. assert approved.status_code == 200
  525. assert approved.json()["data"]["status"] == "REFUND_PENDING"
  526. assert repeated.status_code == 200
  527. assert repeated.json()["data"]["status"] == "REFUND_PENDING"
  528. policy = enrollments.get_policy(policy_id)
  529. assert policy is not None
  530. assert policy.status == "SURRENDERING"
  531. def test_outbox_worker_completes_approved_refund_idempotently() -> None:
  532. identities = InMemoryIdentityRepository()
  533. enrollments = InMemoryEnrollmentRepository()
  534. manifest = build_seed_manifest(PasswordService().hash("zaq1XSW@"))
  535. for admin in manifest.admin_users:
  536. identities.save_admin_user(admin)
  537. app = create_app(
  538. settings=Settings(
  539. app_env="test",
  540. jwt_access_secret="a" * 32,
  541. jwt_refresh_secret="b" * 32,
  542. field_encryption_key="c" * 32,
  543. ),
  544. identity_repository=identities,
  545. catalog_repository=InMemoryCatalogRepository(),
  546. enrollment_repository=enrollments,
  547. clock=lambda: NOW,
  548. )
  549. with TestClient(app) as client:
  550. h5_token = client.post(
  551. "/api/v1/h5/auth/login",
  552. json={"mobile": "18800000001", "code": "147258"},
  553. ).json()["data"]["tokens"]["access_token"]
  554. user = identities.get_h5_user_by_mobile("18800000001")
  555. assert user is not None
  556. policy_id = "01KZ0000000000000000000041"
  557. enrollments.save_policy(
  558. Policy(
  559. id=policy_id,
  560. policy_no="ZBT-P-20260701-0041",
  561. order_id="01KZ0000000000000000000042",
  562. user_id=user.id,
  563. product_id="01KZ0000000000000000000043",
  564. product_version_id="01KZ0000000000000000000044",
  565. plan_id="01KZ0000000000000000000045",
  566. premium_cents=36_500,
  567. currency="CNY",
  568. coverage_start=date(2026, 7, 1),
  569. coverage_end=date(2027, 6, 30),
  570. status="ACTIVE",
  571. issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC),
  572. )
  573. )
  574. created = client.post(
  575. "/api/v1/h5/surrenders",
  576. headers={
  577. "Authorization": f"Bearer {h5_token}",
  578. "Idempotency-Key": "surrender-client-004",
  579. },
  580. json={"policy_id": policy_id, "reason": "保障计划调整"},
  581. ).json()["data"]
  582. client.post(
  583. f"/api/v1/h5/surrenders/{created['request_id']}/confirm",
  584. headers={"Authorization": f"Bearer {h5_token}"},
  585. )
  586. admin_token = client.post(
  587. "/api/v1/admin/auth/login",
  588. json={"username": "admin", "password": "zaq1XSW@"},
  589. ).json()["data"]["tokens"]["access_token"]
  590. admin_headers = {"Authorization": f"Bearer {admin_token}"}
  591. client.post(
  592. f"/api/v1/admin/surrenders/{created['request_id']}/review",
  593. headers=admin_headers,
  594. json={"decision": "APPROVE", "note": "审批通过"},
  595. )
  596. dispatched = app.state.refund_worker.run_once()
  597. repeated = app.state.refund_worker.run_once()
  598. completed = client.get(
  599. f"/api/v1/h5/surrenders/{created['request_id']}",
  600. headers={"Authorization": f"Bearer {h5_token}"},
  601. )
  602. assert dispatched["processed"] == 1
  603. assert dispatched["items"][0]["status"] == "SUCCEEDED"
  604. assert repeated["processed"] == 0
  605. assert completed.status_code == 200
  606. assert completed.json()["data"]["status"] == "REFUNDED"
  607. assert completed.json()["data"]["refund_attempts"][-1]["status"] == "SUCCEEDED"
  608. assert completed.json()["data"]["timeline"][-1]["event_type"] == "REFUND_SUCCEEDED"
  609. policy = enrollments.get_policy(policy_id)
  610. assert policy is not None
  611. assert policy.status == "SURRENDERED"