Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
231 lines
7.0 KiB
Python
231 lines
7.0 KiB
Python
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
|