"""Daily billing reconciliation: the scheduler asks Stripe for the truth so a single missed webhook can never leave a non-payer with service forever, and the weekly reindex must not spend crawl/embedding resources on paused tenants. """ from __future__ import annotations import httpx import respx from sqlalchemy import select from app.config import get_settings from app.models import KnowledgeSource, Tenant from app.scheduler import reconcile_billing, reindex_all_urls STRIPE_SUB_URL = "https://api.stripe.com/v1/subscriptions/{}" async def _paying_tenant(db, slug: str, sub_id: str, *, enabled: bool = True) -> Tenant: t = Tenant( slug=slug, name=slug, stripe_subscription_id=sub_id, chatbot_enabled=enabled, disabled_reason="", ) db.add(t) await db.commit() return t @respx.mock async def test_canceled_subscription_pauses_tenant(db_session, monkeypatch): """A canceled subscription found by the daily poll disables the tenant with disabled_reason='billing' — exactly what the missed webhook would have done, including unlinking the dead subscription so a late invoice.paid for it can never resurrect the tenant.""" from app import billing monkeypatch.setattr(get_settings(), "stripe_secret_key", "sk_test_123") t = await _paying_tenant(db_session, "moroso", "sub_dead1") t.stripe_customer_id = "cus_moroso" await db_session.commit() assert t.chatbot_enabled is True and t.disabled_reason == "" # live before route = respx.get(STRIPE_SUB_URL.format("sub_dead1")).mock( return_value=httpx.Response(200, json={"id": "sub_dead1", "status": "canceled"}) ) paused = await reconcile_billing(db_session) assert paused == 1 assert route.called # Stripe really was consulted, with our key assert route.calls.last.request.headers["Authorization"] == "Bearer sk_test_123" db_session.expire_all() t = ( await db_session.execute(select(Tenant).where(Tenant.slug == "moroso")) ).scalar_one() assert t.chatbot_enabled is False assert t.disabled_reason == "billing" assert t.stripe_subscription_id == "" # unlinked, same as the webhook # a late invoice.paid for the dead subscription must NOT resurrect it late = {"type": "invoice.paid", "data": {"object": {"customer": "cus_moroso", "subscription": "sub_dead1"}}} r = await billing.handle_subscription_event(db_session, late) assert r["status"] == "subscription_mismatch" await db_session.refresh(t) assert t.chatbot_enabled is False assert t.disabled_reason == "billing" @respx.mock async def test_active_subscription_stays_enabled(db_session, monkeypatch): """A paying client must NOT be touched: Stripe is consulted and the tenant keeps full service (enabled, no disabled_reason).""" monkeypatch.setattr(get_settings(), "stripe_secret_key", "sk_test_123") await _paying_tenant(db_session, "alcorriente", "sub_ok1") route = respx.get(STRIPE_SUB_URL.format("sub_ok1")).mock( return_value=httpx.Response(200, json={"id": "sub_ok1", "status": "active"}) ) paused = await reconcile_billing(db_session) assert paused == 0 assert route.called # the check DID happen; staying enabled is a decision db_session.expire_all() t = ( await db_session.execute(select(Tenant).where(Tenant.slug == "alcorriente")) ).scalar_one() assert t.chatbot_enabled is True assert t.disabled_reason == "" @respx.mock async def test_no_stripe_key_makes_no_http_calls(db_session, monkeypatch): """Without a configured key (dev/tests) the reconcile is a no-op: zero HTTP traffic and the tenant untouched.""" monkeypatch.setattr(get_settings(), "stripe_secret_key", "") await _paying_tenant(db_session, "sinkey", "sub_nokey") paused = await reconcile_billing(db_session) assert paused == 0 assert len(respx.calls) == 0 # not a single request left the process db_session.expire_all() t = ( await db_session.execute(select(Tenant).where(Tenant.slug == "sinkey")) ).scalar_one() assert t.chatbot_enabled is True @respx.mock async def test_network_error_logged_and_other_tenants_still_checked(db_session, monkeypatch): """Stripe failing for one tenant must not crash the tick nor shield the next tenant: the dead one is still found and paused.""" monkeypatch.setattr(get_settings(), "stripe_secret_key", "sk_test_123") await _paying_tenant(db_session, "conred", "sub_neterr") await _paying_tenant(db_session, "muerto", "sub_dead2") down = respx.get(STRIPE_SUB_URL.format("sub_neterr")).mock( side_effect=httpx.ConnectError("stripe caido") ) dead = respx.get(STRIPE_SUB_URL.format("sub_dead2")).mock( return_value=httpx.Response(200, json={"id": "sub_dead2", "status": "unpaid"}) ) paused = await reconcile_billing(db_session) # must not raise assert paused == 1 assert down.called and dead.called # both were attempted despite the error db_session.expire_all() survivor = ( await db_session.execute(select(Tenant).where(Tenant.slug == "conred")) ).scalar_one() assert survivor.chatbot_enabled is True # unverifiable -> keep service assert survivor.disabled_reason == "" gone = ( await db_session.execute(select(Tenant).where(Tenant.slug == "muerto")) ).scalar_one() assert gone.chatbot_enabled is False assert gone.disabled_reason == "billing" # "unpaid" is recoverable: the link stays so actually paying the open # invoice re-enables through the normal invoice.paid path assert gone.stripe_subscription_id == "sub_dead2" async def test_reindex_skips_paused_tenants_sources(db_session, monkeypatch): """The weekly reindex must crawl the ACTIVE tenant's URL source and never touch the paused tenant's one (zero service includes zero crawling).""" from app.rag import index active = await _paying_tenant(db_session, "activo", "sub_a") paused = await _paying_tenant(db_session, "pausado", "sub_p", enabled=False) db_session.add_all([ KnowledgeSource(tenant_id=active.id, kind="url", name="a", location="https://a.example"), KnowledgeSource(tenant_id=paused.id, kind="url", name="p", location="https://p.example"), KnowledgeSource(tenant_id=active.id, kind="file", name="f", location="/tmp/f.pdf"), ]) await db_session.commit() indexed: list[str] = [] async def fake_index_source(db, src): indexed.append(src.location) src.status = "indexed" return src monkeypatch.setattr(index, "index_source", fake_index_source) done = await reindex_all_urls(db_session) # Positive: the active tenant's URL really was refreshed... assert done == 1 assert indexed == ["https://a.example"] # ...and the paused tenant's URL (and the static file) were not. assert "https://p.example" not in indexed async def test_reindex_keeps_legacy_sources_without_tenant(db_session, monkeypatch): """Pre-multitenant URL sources (tenant_id NULL) belong to no paused client and must keep refreshing as before.""" from app.rag import index db_session.add( KnowledgeSource(tenant_id=None, kind="url", name="legacy", location="https://l.example") ) await db_session.commit() indexed: list[str] = [] async def fake_index_source(db, src): indexed.append(src.location) src.status = "indexed" return src monkeypatch.setattr(index, "index_source", fake_index_source) done = await reindex_all_urls(db_session) assert done == 1 assert indexed == ["https://l.example"]