from datetime import UTC, date, datetime from fastapi.testclient import TestClient from zbt.commands.seed import build_seed_manifest from zbt.core.config import Settings from zbt.core.passwords import PasswordService from zbt.domains.catalog.repository import InMemoryCatalogRepository from zbt.domains.enrollment.models import Policy from zbt.domains.enrollment.repository import InMemoryEnrollmentRepository from zbt.domains.identity.repository import InMemoryIdentityRepository from zbt.main import create_app NOW = datetime(2026, 7, 26, 10, 0, tzinfo=UTC) def test_active_policy_owner_can_preview_deterministic_surrender_refund() -> None: identities = InMemoryIdentityRepository() enrollments = InMemoryEnrollmentRepository() app = create_app( settings=Settings( app_env="test", jwt_access_secret="a" * 32, jwt_refresh_secret="b" * 32, field_encryption_key="c" * 32, ), identity_repository=identities, catalog_repository=InMemoryCatalogRepository(), enrollment_repository=enrollments, clock=lambda: NOW, ) with TestClient(app) as client: token = client.post( "/api/v1/h5/auth/login", json={"mobile": "18800000001", "code": "147258"}, ).json()["data"]["tokens"]["access_token"] user = identities.get_h5_user_by_mobile("18800000001") assert user is not None enrollments.save_policy( Policy( id="01KZ0000000000000000000001", policy_no="ZBT-P-20260701-0001", order_id="01KZ0000000000000000000002", user_id=user.id, product_id="01KZ0000000000000000000003", product_version_id="01KZ0000000000000000000004", plan_id="01KZ0000000000000000000005", premium_cents=36_500, currency="CNY", coverage_start=date(2026, 7, 1), coverage_end=date(2027, 6, 30), status="ACTIVE", issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC), ) ) response = client.get( "/api/v1/h5/policies/01KZ0000000000000000000001/surrender-preview", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 assert response.json()["data"] == { "policy_id": "01KZ0000000000000000000001", "policy_no": "ZBT-P-20260701-0001", "eligible": True, "reason_code": "ELIGIBLE", "reason": "保单符合退保申请条件", "refund_amount_cents": 30_600, "refund_basis": "UNEARNED_PREMIUM_WITH_DEDUCTION", "refund_explanation": ( "已超过10天犹豫期,按剩余保障天数折算未满期保费,并扣减10%。" ), "currency": "CNY", "rule_version": "surrender-v1", "calculation": { "premium_cents": 36_500, "total_days": 365, "elapsed_days": 25, "remaining_days": 340, "gross_unearned_cents": 34_000, "deduction_cents": 3_400, "cooling_off": False, "pre_effective": False, }, } def test_newly_issued_policy_can_be_cancelled_before_coverage_starts() -> None: identities = InMemoryIdentityRepository() enrollments = InMemoryEnrollmentRepository() app = create_app( settings=Settings( app_env="test", jwt_access_secret="a" * 32, jwt_refresh_secret="b" * 32, field_encryption_key="c" * 32, ), identity_repository=identities, catalog_repository=InMemoryCatalogRepository(), enrollment_repository=enrollments, clock=lambda: NOW, ) with TestClient(app) as client: token = client.post( "/api/v1/h5/auth/login", json={"mobile": "18800000001", "code": "147258"}, ).json()["data"]["tokens"]["access_token"] user = identities.get_h5_user_by_mobile("18800000001") assert user is not None enrollments.save_policy( Policy( id="01KZ0000000000000000000006", policy_no="POL-20260726-NEW001", order_id="01KZ0000000000000000000007", user_id=user.id, product_id="01KZ0000000000000000000008", product_version_id="01KZ0000000000000000000009", plan_id="01KZ0000000000000000000010", premium_cents=15_900, currency="CNY", coverage_start=date(2026, 7, 27), coverage_end=date(2027, 7, 26), status="ACTIVE", issued_at=NOW, ) ) response = client.get( "/api/v1/h5/policies/01KZ0000000000000000000006/surrender-preview", headers={"Authorization": f"Bearer {token}"}, ) created = client.post( "/api/v1/h5/surrenders", headers={ "Authorization": f"Bearer {token}", "Idempotency-Key": "pre-effective-surrender-001", }, json={ "policy_id": "01KZ0000000000000000000006", "reason": "保障计划调整", }, ) request_id = created.json()["data"]["request_id"] confirmed = client.post( f"/api/v1/h5/surrenders/{request_id}/confirm", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 assert response.json()["data"] == { "policy_id": "01KZ0000000000000000000006", "policy_no": "POL-20260726-NEW001", "eligible": True, "reason_code": "ELIGIBLE", "reason": "保单符合退保申请条件", "refund_amount_cents": 15_900, "refund_basis": "PRE_EFFECTIVE_FULL_REFUND", "refund_explanation": "保单保障尚未开始,按已交保费全额退还。", "currency": "CNY", "rule_version": "surrender-v1", "calculation": { "premium_cents": 15_900, "total_days": 365, "elapsed_days": 0, "remaining_days": 365, "gross_unearned_cents": 15_900, "deduction_cents": 0, "cooling_off": False, "pre_effective": True, }, } assert created.status_code == 201 assert created.json()["data"]["status"] == "PENDING_CONFIRMATION" assert created.json()["data"]["calculation"]["pre_effective"] is True assert confirmed.status_code == 200 assert confirmed.json()["data"]["status"] == "REFUND_PENDING" assert confirmed.json()["data"]["risk_level"] == "LOW" policy = enrollments.get_policy("01KZ0000000000000000000006") assert policy is not None assert policy.status == "SURRENDERING" def test_surrender_submission_is_idempotent_and_waits_for_customer_confirmation() -> None: identities = InMemoryIdentityRepository() enrollments = InMemoryEnrollmentRepository() app = create_app( settings=Settings( app_env="test", jwt_access_secret="a" * 32, jwt_refresh_secret="b" * 32, field_encryption_key="c" * 32, ), identity_repository=identities, catalog_repository=InMemoryCatalogRepository(), enrollment_repository=enrollments, clock=lambda: NOW, ) with TestClient(app) as client: token = client.post( "/api/v1/h5/auth/login", json={"mobile": "18800000001", "code": "147258"}, ).json()["data"]["tokens"]["access_token"] user = identities.get_h5_user_by_mobile("18800000001") assert user is not None enrollments.save_policy( Policy( id="01KZ0000000000000000000011", policy_no="ZBT-P-20260701-0011", order_id="01KZ0000000000000000000012", user_id=user.id, product_id="01KZ0000000000000000000013", product_version_id="01KZ0000000000000000000014", plan_id="01KZ0000000000000000000015", premium_cents=36_500, currency="CNY", coverage_start=date(2026, 7, 1), coverage_end=date(2027, 6, 30), status="ACTIVE", issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC), ) ) headers = { "Authorization": f"Bearer {token}", "Idempotency-Key": "surrender-client-001", } payload = { "policy_id": "01KZ0000000000000000000011", "reason": "保障计划调整", } first = client.post("/api/v1/h5/surrenders", headers=headers, json=payload) repeated = client.post("/api/v1/h5/surrenders", headers=headers, json=payload) assert first.status_code == 201 assert first.json()["data"]["status"] == "PENDING_CONFIRMATION" assert first.json()["data"]["refund_amount_cents"] == 30_600 assert first.json()["data"]["request_no"].startswith("ZBT-TB-20260726-") assert repeated.status_code == 200 assert repeated.json()["data"]["request_id"] == first.json()["data"]["request_id"] def test_customer_confirmation_pauses_workflow_for_operations_approval() -> None: identities = InMemoryIdentityRepository() enrollments = InMemoryEnrollmentRepository() app = create_app( settings=Settings( app_env="test", jwt_access_secret="a" * 32, jwt_refresh_secret="b" * 32, field_encryption_key="c" * 32, ), identity_repository=identities, catalog_repository=InMemoryCatalogRepository(), enrollment_repository=enrollments, clock=lambda: NOW, ) with TestClient(app) as client: token = client.post( "/api/v1/h5/auth/login", json={"mobile": "18800000001", "code": "147258"}, ).json()["data"]["tokens"]["access_token"] user = identities.get_h5_user_by_mobile("18800000001") assert user is not None enrollments.save_policy( Policy( id="01KZ0000000000000000000021", policy_no="ZBT-P-20260701-0021", order_id="01KZ0000000000000000000022", user_id=user.id, product_id="01KZ0000000000000000000023", product_version_id="01KZ0000000000000000000024", plan_id="01KZ0000000000000000000025", premium_cents=36_500, currency="CNY", coverage_start=date(2026, 7, 1), coverage_end=date(2027, 6, 30), status="ACTIVE", issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC), ) ) headers = { "Authorization": f"Bearer {token}", "Idempotency-Key": "surrender-client-002", } created = client.post( "/api/v1/h5/surrenders", headers=headers, json={ "policy_id": "01KZ0000000000000000000021", "reason": "保障计划调整", }, ).json()["data"] confirmed = client.post( f"/api/v1/h5/surrenders/{created['request_id']}/confirm", headers={"Authorization": f"Bearer {token}"}, ) detail = client.get( f"/api/v1/h5/surrenders/{created['request_id']}", headers={"Authorization": f"Bearer {token}"}, ) assert confirmed.status_code == 200 assert confirmed.json()["data"]["status"] == "WAITING_APPROVAL" assert confirmed.json()["data"]["workflow"]["paused"] is True assert confirmed.json()["data"]["workflow"]["step"] == "OPERATIONS_APPROVAL" assert detail.status_code == 200 assert [item["event_type"] for item in detail.json()["data"]["timeline"]] == [ "REQUEST_CREATED", "CUSTOMER_CONFIRMED", "RISK_ASSESSED", "WAITING_APPROVAL", ] def test_low_risk_surrender_is_auto_approved_and_enters_refund_queue() -> None: identities = InMemoryIdentityRepository() enrollments = InMemoryEnrollmentRepository() app = create_app( settings=Settings( app_env="test", jwt_access_secret="a" * 32, jwt_refresh_secret="b" * 32, field_encryption_key="c" * 32, ), identity_repository=identities, catalog_repository=InMemoryCatalogRepository(), enrollment_repository=enrollments, clock=lambda: NOW, ) with TestClient(app) as client: token = client.post( "/api/v1/h5/auth/login", json={"mobile": "18800000001", "code": "147258"}, ).json()["data"]["tokens"]["access_token"] user = identities.get_h5_user_by_mobile("18800000001") assert user is not None policy_id = "01KZ0000000000000000000061" enrollments.save_policy( Policy( id=policy_id, policy_no="ZBT-P-20260701-0061", order_id="01KZ0000000000000000000062", user_id=user.id, product_id="01KZ0000000000000000000063", product_version_id="01KZ0000000000000000000064", plan_id="01KZ0000000000000000000065", premium_cents=19_900, currency="CNY", coverage_start=date(2026, 7, 1), coverage_end=date(2027, 6, 30), status="ACTIVE", issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC), ) ) created = client.post( "/api/v1/h5/surrenders", headers={ "Authorization": f"Bearer {token}", "Idempotency-Key": "surrender-low-risk-001", }, json={"policy_id": policy_id, "reason": "保障计划调整"}, ).json()["data"] confirmed = client.post( f"/api/v1/h5/surrenders/{created['request_id']}/confirm", headers={"Authorization": f"Bearer {token}"}, ) detail = client.get( f"/api/v1/h5/surrenders/{created['request_id']}", headers={"Authorization": f"Bearer {token}"}, ).json()["data"] assert confirmed.status_code == 200 assert confirmed.json()["data"]["status"] == "REFUND_PENDING" assert confirmed.json()["data"]["risk_level"] == "LOW" assert confirmed.json()["data"]["workflow"]["paused"] is False assert confirmed.json()["data"]["workflow"]["step"] == "AUTO_APPROVED" assert { event["node"] for event in confirmed.json()["data"]["workflow"]["node_events"] } >= { "planner", "check_amount", "check_reason", "check_cooling_off", "aggregate_risk", "auto_approved", } assert [item["event_type"] for item in detail["timeline"]] == [ "REQUEST_CREATED", "CUSTOMER_CONFIRMED", "RISK_ASSESSED", "AUTO_APPROVED", "REFUND_PROCESSING", ] policy = enrollments.get_policy(policy_id) assert policy is not None assert policy.status == "SURRENDERING" def test_surrender_reason_detail_can_trigger_sensitive_reason_review() -> None: identities = InMemoryIdentityRepository() enrollments = InMemoryEnrollmentRepository() app = create_app( settings=Settings( app_env="test", jwt_access_secret="a" * 32, jwt_refresh_secret="b" * 32, field_encryption_key="c" * 32, ), identity_repository=identities, catalog_repository=InMemoryCatalogRepository(), enrollment_repository=enrollments, clock=lambda: NOW, ) with TestClient(app) as client: token = client.post( "/api/v1/h5/auth/login", json={"mobile": "18800000001", "code": "147258"}, ).json()["data"]["tokens"]["access_token"] user = identities.get_h5_user_by_mobile("18800000001") assert user is not None policy_id = "01KZ0000000000000000000071" enrollments.save_policy( Policy( id=policy_id, policy_no="ZBT-P-20260701-0071", order_id="01KZ0000000000000000000072", user_id=user.id, product_id="01KZ0000000000000000000073", product_version_id="01KZ0000000000000000000074", plan_id="01KZ0000000000000000000075", premium_cents=19_900, currency="CNY", coverage_start=date(2026, 7, 1), coverage_end=date(2027, 6, 30), status="ACTIVE", issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC), ) ) created_response = client.post( "/api/v1/h5/surrenders", headers={ "Authorization": f"Bearer {token}", "Idempotency-Key": "surrender-sensitive-reason-001", }, json={ "policy_id": policy_id, "reason_category": "其他原因", "reason_detail": "发现存在重复扣费,希望人工协助核实。", }, ) created = created_response.json()["data"] confirmed = client.post( f"/api/v1/h5/surrenders/{created['request_id']}/confirm", headers={"Authorization": f"Bearer {token}"}, ) assert created_response.status_code == 201 assert created["reason"] == "其他原因:发现存在重复扣费,希望人工协助核实。" assert confirmed.status_code == 200 assert confirmed.json()["data"]["status"] == "WAITING_APPROVAL" assert confirmed.json()["data"]["risk_level"] == "HIGH" assert any( "重复扣费" in reason for reason in confirmed.json()["data"]["risk_reasons"] ) def test_operations_approval_resumes_workflow_and_marks_policy_surrendering() -> None: identities = InMemoryIdentityRepository() enrollments = InMemoryEnrollmentRepository() manifest = build_seed_manifest(PasswordService().hash("zaq1XSW@")) for admin in manifest.admin_users: identities.save_admin_user(admin) app = create_app( settings=Settings( app_env="test", jwt_access_secret="a" * 32, jwt_refresh_secret="b" * 32, field_encryption_key="c" * 32, ), identity_repository=identities, catalog_repository=InMemoryCatalogRepository(), enrollment_repository=enrollments, clock=lambda: NOW, ) with TestClient(app) as client: h5_token = client.post( "/api/v1/h5/auth/login", json={"mobile": "18800000001", "code": "147258"}, ).json()["data"]["tokens"]["access_token"] user = identities.get_h5_user_by_mobile("18800000001") assert user is not None policy_id = "01KZ0000000000000000000031" enrollments.save_policy( Policy( id=policy_id, policy_no="ZBT-P-20260701-0031", order_id="01KZ0000000000000000000032", user_id=user.id, product_id="01KZ0000000000000000000033", product_version_id="01KZ0000000000000000000034", plan_id="01KZ0000000000000000000035", premium_cents=36_500, currency="CNY", coverage_start=date(2026, 7, 1), coverage_end=date(2027, 6, 30), status="ACTIVE", issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC), ) ) h5_headers = { "Authorization": f"Bearer {h5_token}", "Idempotency-Key": "surrender-client-003", } created = client.post( "/api/v1/h5/surrenders", headers=h5_headers, json={"policy_id": policy_id, "reason": "保障计划调整"}, ).json()["data"] client.post( f"/api/v1/h5/surrenders/{created['request_id']}/confirm", headers={"Authorization": f"Bearer {h5_token}"}, ) admin_token = client.post( "/api/v1/admin/auth/login", json={"username": "admin", "password": "zaq1XSW@"}, ).json()["data"]["tokens"]["access_token"] admin_headers = {"Authorization": f"Bearer {admin_token}"} rejected_without_note = client.post( f"/api/v1/admin/surrenders/{created['request_id']}/review", headers=admin_headers, json={"decision": "REJECT", "note": " "}, ) approved = client.post( f"/api/v1/admin/surrenders/{created['request_id']}/review", headers=admin_headers, json={"decision": "APPROVE", "note": "资料与退款试算无误"}, ) repeated = client.post( f"/api/v1/admin/surrenders/{created['request_id']}/review", headers=admin_headers, json={"decision": "APPROVE", "note": "重复审批"}, ) assert rejected_without_note.status_code == 400 assert rejected_without_note.json()["error"]["code"] == ( "SURRENDER_REJECTION_NOTE_REQUIRED" ) assert approved.status_code == 200 assert approved.json()["data"]["status"] == "REFUND_PENDING" assert repeated.status_code == 200 assert repeated.json()["data"]["status"] == "REFUND_PENDING" policy = enrollments.get_policy(policy_id) assert policy is not None assert policy.status == "SURRENDERING" def test_outbox_worker_completes_approved_refund_idempotently() -> None: identities = InMemoryIdentityRepository() enrollments = InMemoryEnrollmentRepository() manifest = build_seed_manifest(PasswordService().hash("zaq1XSW@")) for admin in manifest.admin_users: identities.save_admin_user(admin) app = create_app( settings=Settings( app_env="test", jwt_access_secret="a" * 32, jwt_refresh_secret="b" * 32, field_encryption_key="c" * 32, ), identity_repository=identities, catalog_repository=InMemoryCatalogRepository(), enrollment_repository=enrollments, clock=lambda: NOW, ) with TestClient(app) as client: h5_token = client.post( "/api/v1/h5/auth/login", json={"mobile": "18800000001", "code": "147258"}, ).json()["data"]["tokens"]["access_token"] user = identities.get_h5_user_by_mobile("18800000001") assert user is not None policy_id = "01KZ0000000000000000000041" enrollments.save_policy( Policy( id=policy_id, policy_no="ZBT-P-20260701-0041", order_id="01KZ0000000000000000000042", user_id=user.id, product_id="01KZ0000000000000000000043", product_version_id="01KZ0000000000000000000044", plan_id="01KZ0000000000000000000045", premium_cents=36_500, currency="CNY", coverage_start=date(2026, 7, 1), coverage_end=date(2027, 6, 30), status="ACTIVE", issued_at=datetime(2026, 7, 1, 8, 0, tzinfo=UTC), ) ) created = client.post( "/api/v1/h5/surrenders", headers={ "Authorization": f"Bearer {h5_token}", "Idempotency-Key": "surrender-client-004", }, json={"policy_id": policy_id, "reason": "保障计划调整"}, ).json()["data"] client.post( f"/api/v1/h5/surrenders/{created['request_id']}/confirm", headers={"Authorization": f"Bearer {h5_token}"}, ) admin_token = client.post( "/api/v1/admin/auth/login", json={"username": "admin", "password": "zaq1XSW@"}, ).json()["data"]["tokens"]["access_token"] admin_headers = {"Authorization": f"Bearer {admin_token}"} client.post( f"/api/v1/admin/surrenders/{created['request_id']}/review", headers=admin_headers, json={"decision": "APPROVE", "note": "审批通过"}, ) dispatched = app.state.refund_worker.run_once() repeated = app.state.refund_worker.run_once() completed = client.get( f"/api/v1/h5/surrenders/{created['request_id']}", headers={"Authorization": f"Bearer {h5_token}"}, ) assert dispatched["processed"] == 1 assert dispatched["items"][0]["status"] == "SUCCEEDED" assert repeated["processed"] == 0 assert completed.status_code == 200 assert completed.json()["data"]["status"] == "REFUNDED" assert completed.json()["data"]["refund_attempts"][-1]["status"] == "SUCCEEDED" assert completed.json()["data"]["timeline"][-1]["event_type"] == "REFUND_SUCCEEDED" policy = enrollments.get_policy(policy_id) assert policy is not None assert policy.status == "SURRENDERED"