"""Unit tests for the billing scheduler (ticket 016). Drive scheduler.run_tick with an injected clock and a fake provider; assert state transitions without touching the network. """ from __future__ import annotations import datetime as dt import uuid from dataclasses import dataclass from typing import Any import pytest from sqlalchemy import text from contract_check.core.billing.port import PaymentCreated, PaymentProvider, PaymentStatusInfo from contract_check.core.config import get_settings from contract_check.core.db.session import create_session_factory from contract_check.worker_billing.scheduler import run_tick pytestmark = [pytest.mark.unit, pytest.mark.integration] @dataclass class FakeProvider(PaymentProvider): payments: dict[str, dict[str, Any]] created: list[PaymentCreated] = None # type: ignore[assignment] status_overrides: dict[str, str] | None = None def __post_init__(self) -> None: self.created = [] self.status_overrides = {} async def aclose(self) -> None: pass async def create_payment( self, *, amount_kopecks: int, description: str, return_url: str, metadata: dict[str, str], idempotency_key: str, ) -> PaymentCreated: payment_id = f"pay-{metadata['invoice_id']}" self.payments[payment_id] = { "amount_kopecks": amount_kopecks, "status": "pending", "metadata": metadata, } created = PaymentCreated(payment_id=payment_id, confirmation_url="https://test/pay") self.created.append(created) return created async def get_payment(self, payment_id: str) -> PaymentStatusInfo: data = self.payments.get(payment_id, {}) status = self.status_overrides.get(payment_id, data.get("status", "pending")) return PaymentStatusInfo( payment_id=payment_id, status=status, paid=status == "succeeded", amount_kopecks=data.get("amount_kopecks", 0), metadata={str(k): str(v) for k, v in data.get("metadata", {}).items()}, ) async def refund(self, *, payment_id: str, amount_kopecks: int, idempotency_key: str) -> Any: raise NotImplementedError @pytest.fixture async def factory(): import os os.environ["PLANS_ENABLED"] = "true" os.environ["YOOKASSA_ENABLED"] = "true" os.environ["YOOKASSA_SHOP_ID"] = "123456" os.environ["YOOKASSA_SECRET_KEY"] = "test-secret" os.environ["BILLING_RETURN_JWT_SECRET"] = "pay-secret" get_settings.cache_clear() f = create_session_factory() yield f os.environ.pop("PLANS_ENABLED", None) os.environ.pop("YOOKASSA_ENABLED", None) os.environ.pop("YOOKASSA_SHOP_ID", None) os.environ.pop("YOOKASSA_SECRET_KEY", None) os.environ.pop("BILLING_RETURN_JWT_SECRET", None) get_settings.cache_clear() async def _seed_user(session, telegram_id: int = 123456789) -> uuid.UUID: result = await session.execute( text("SELECT id FROM users WHERE telegram_id = :t"), {"t": telegram_id}, ) row = result.first() if row is not None: return row[0] result = await session.execute( text( "INSERT INTO users (id, telegram_id, credits_left) " "VALUES (gen_random_uuid(), :t, 0) RETURNING id" ), {"t": telegram_id}, ) return result.scalar_one() async def _seed_subscription( session, user_id: uuid.UUID, *, auto_renew: bool = True, period_end_offset_days: int = 2, plan_code: str = "pro", ) -> uuid.UUID: now = dt.datetime.now(tz=dt.UTC) # Clean any existing active/past_due subscription for the user to keep the partial unique index happy. await session.execute( text( "UPDATE subscriptions SET status = 'expired' WHERE user_id = :u AND status IN ('active','past_due')" ), {"u": user_id}, ) result = await session.execute( text( "INSERT INTO subscriptions " "(id, user_id, plan_code, status, current_period_start, current_period_end, auto_renew) " "VALUES (gen_random_uuid(), :u, :plan, 'active', :start, :end, :renew) " "RETURNING id" ), { "u": user_id, "plan": plan_code, "start": now, "end": now + dt.timedelta(days=period_end_offset_days), "renew": auto_renew, }, ) return result.scalar_one() async def test_scheduler_creates_renewal_invoice(factory) -> None: provider = FakeProvider(payments={}) async with factory() as session: user_id = await _seed_user(session) sub_id = await _seed_subscription( session, user_id, auto_renew=True, period_end_offset_days=2 ) await session.commit() now = dt.datetime.now(tz=dt.UTC) settings = get_settings() await run_tick(session, provider, now, settings) invoice = ( await session.execute( text( "SELECT kind, subscription_id, external_id, status " "FROM invoices WHERE subscription_id = :s" ), {"s": sub_id}, ) ).first() assert invoice is not None assert invoice[0] == "renewal" assert invoice[1] == sub_id assert invoice[2].startswith("pay-") assert invoice[3] == "pending" async def test_scheduler_skips_non_autorenew(factory) -> None: provider = FakeProvider(payments={}) async with factory() as session: user_id = await _seed_user(session) sub_id = await _seed_subscription( session, user_id, auto_renew=False, period_end_offset_days=2 ) await session.commit() now = dt.datetime.now(tz=dt.UTC) settings = get_settings() await run_tick(session, provider, now, settings) count = ( await session.execute( text("SELECT count(*) FROM invoices WHERE subscription_id = :s"), {"s": sub_id}, ) ).scalar_one() assert int(count) == 0 async def test_scheduler_expires_without_renewal(factory) -> None: async with factory() as session: user_id = await _seed_user(session) sub_id = await _seed_subscription( session, user_id, auto_renew=False, period_end_offset_days=-1 ) await session.commit() now = dt.datetime.now(tz=dt.UTC) settings = get_settings() await run_tick(session, None, now, settings) status = ( await session.execute( text("SELECT status FROM subscriptions WHERE id = :s"), {"s": sub_id} ) ).scalar_one() assert status == "expired" async def test_scheduler_rolls_after_successful_renewal(factory) -> None: provider = FakeProvider(payments={}) async with factory() as session: user_id = await _seed_user(session) sub_id = await _seed_subscription( session, user_id, auto_renew=True, period_end_offset_days=-1 ) # Pre-create a succeeded renewal invoice. inv_id = uuid.uuid4() now = dt.datetime.now(tz=dt.UTC) await session.execute( text( "INSERT INTO invoices (id, user_id, amount, status, kind, subscription_id, paid_at) " "VALUES (:id, :u, 149000, 'succeeded', 'renewal', :s, :paid)" ), {"id": inv_id, "u": user_id, "s": sub_id, "paid": now - dt.timedelta(minutes=1)}, ) await session.commit() await run_tick(session, provider, now, settings=get_settings()) row = ( await session.execute( text( "SELECT status, current_period_start, current_period_end, origin_invoice_id " "FROM subscriptions WHERE id = :s" ), {"s": sub_id}, ) ).first() assert row[0] == "active" assert row[3] == inv_id assert row[2] > row[1] async def test_scheduler_reconciles_pending_invoice(factory) -> None: provider = FakeProvider(payments={}) async with factory() as session: user_id = await _seed_user(session) inv_id = uuid.uuid4() old = dt.datetime.now(tz=dt.UTC) - dt.timedelta(minutes=20) await session.execute( text( "INSERT INTO invoices (id, user_id, amount, status, kind, external_id, created_at) " "VALUES (:id, :u, 19900, 'pending', 'topup', :ext, :created)" ), {"id": inv_id, "u": user_id, "ext": "pay-old", "created": old}, ) provider.payments["pay-old"] = { "amount_kopecks": 19900, "status": "pending", "metadata": {"invoice_id": str(inv_id), "kind": "topup"}, } await session.commit() provider.status_overrides["pay-old"] = "succeeded" # type: ignore[index] await run_tick(session, provider, dt.datetime.now(tz=dt.UTC), settings=get_settings()) status = ( await session.execute( text("SELECT status FROM invoices WHERE id = :id"), {"id": inv_id} ) ).scalar_one() assert status == "succeeded"