from __future__ import annotations from datetime import UTC, datetime from decimal import Decimal import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool import app.models # noqa: F401 - 注册完整 metadata from app.db.base_class import Base from app.models.commercial_billing import CommercialAdminEvent, CommercialBillingPeriod from app.schemas.commercial import CommercialPlanCreate, CommercialSubscriptionCreate from app.services.commercial_admin import CommercialAdminService from app.services.commercial_subscription_rollover import ( CommercialSubscriptionRolloverService, ) @pytest.fixture() def db() -> Session: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) with factory() as session: yield session Base.metadata.drop_all(engine) engine.dispose() def test_subscription_creation_issues_period_and_redacted_audit(db: Session) -> None: start = datetime(2026, 1, 1, tzinfo=UTC) end = datetime(2026, 2, 1, tzinfo=UTC) _, subscription = _seed_subscription( db, "tenant-a", start=start, end=end, request_prefix="initial", ) periods = list( db.scalars( select(CommercialBillingPeriod).where(CommercialBillingPeriod.tenant_id == "tenant-a") ).all() ) assert len(periods) == 1 assert periods[0].subscription_id == subscription.id assert periods[0].period_start.replace(tzinfo=UTC) == start assert periods[0].period_end.replace(tzinfo=UTC) == end assert periods[0].base_fee_snapshot == Decimal("100.0000") events = list( db.scalars( select(CommercialAdminEvent).where(CommercialAdminEvent.tenant_id == "tenant-a") ).all() ) assert {event.action for event in events} >= { "plan_created", "plan_activated", "subscription_created", "billing_period_created", } serialized = str([(event.before_json, event.after_json) for event in events]).casefold() for forbidden in ( "contract_terms_json", "metadata_json", "external_subscription_id", "secret", "password", "token", ): assert forbidden not in serialized def test_auto_renew_rollover_is_idempotent_and_keeps_month_end_anchor(db: Session) -> None: start = datetime(2027, 1, 31, tzinfo=UTC) due = datetime(2027, 2, 28, tzinfo=UTC) _, subscription = _seed_subscription( db, "tenant-rollover", start=start, end=due, auto_renew=True, request_prefix="rollover", ) first = CommercialSubscriptionRolloverService(db).rollover_due( "tenant-rollover", subscription.id, as_of=due, ) replay = CommercialSubscriptionRolloverService(db).rollover_due( "tenant-rollover", subscription.id, as_of=due, ) assert first.status == "rolled_over" assert len(first.created_period_ids) == 1 assert first.current_period_start == due assert first.current_period_end == datetime(2027, 3, 31, tzinfo=UTC) assert replay.status == "not_due" periods = list( db.scalars( select(CommercialBillingPeriod) .where(CommercialBillingPeriod.subscription_id == subscription.id) .order_by(CommercialBillingPeriod.period_sequence) ).all() ) assert [period.period_sequence for period in periods] == [1, 2] assert periods[0].period_start.replace(tzinfo=UTC) == start assert periods[0].period_end.replace(tzinfo=UTC) == due assert periods[1].source == "auto_renew" assert subscription.version == 2 actions = set( db.scalars( select(CommercialAdminEvent.action).where( CommercialAdminEvent.tenant_id == "tenant-rollover" ) ).all() ) assert {"billing_period_created", "subscription_rolled_over"} <= actions @pytest.mark.parametrize( ("interval", "contract_end", "reason_code"), [ ("contract", None, "contract_interval_requires_explicit_renewal"), ( "monthly", datetime(2026, 2, 15, tzinfo=UTC), "contract_boundary_requires_renewal", ), ], ) def test_rollover_fails_closed_at_unprovable_contract_boundary( db: Session, interval: str, contract_end: datetime | None, reason_code: str, ) -> None: tenant_id = f"tenant-{interval}" _, subscription = _seed_subscription( db, tenant_id, start=datetime(2026, 1, 1, tzinfo=UTC), end=datetime(2026, 2, 1, tzinfo=UTC), billing_interval=interval, auto_renew=True, contract_end=contract_end, request_prefix=interval, ) result = CommercialSubscriptionRolloverService(db).rollover_due( tenant_id, subscription.id, as_of=datetime(2026, 2, 1, tzinfo=UTC), ) assert result.status == "ineligible" assert result.reason_code == reason_code assert ( db.scalar( select(CommercialBillingPeriod.period_sequence).where( CommercialBillingPeriod.subscription_id == subscription.id ) ) == 1 ) def _seed_subscription( db: Session, tenant_id: str, *, start: datetime, end: datetime, billing_interval: str = "monthly", auto_renew: bool = False, contract_end: datetime | None = None, request_prefix: str, ): admin = CommercialAdminService(db) plan = admin.create_plan( tenant_id, CommercialPlanCreate( plan_code="billing", name="不可变账期测试套餐", pricing_model="subscription", billing_interval=billing_interval, currency="CNY", base_fee=Decimal("100"), effective_from=start, contract_terms_json={"secret": "must-not-enter-audit"}, ), actor_id="platform-admin", request_id=f"{request_prefix}-plan-create", ) admin.activate_plan( tenant_id, plan.id, expected_version=plan.version, actor_id="platform-admin", request_id=f"{request_prefix}-plan-activate", reason="测试激活套餐", ) subscription = admin.create_subscription( tenant_id, CommercialSubscriptionCreate( subscription_key=f"{tenant_id}-subscription", plan_id=plan.id, starts_at=start, ends_at=contract_end, current_period_start=start, current_period_end=end, seats=3, auto_renew=auto_renew, metadata_json={"token": "must-not-enter-audit"}, ), actor_id="platform-admin", request_id=f"{request_prefix}-subscription-create", ) db.flush() return plan, subscription