import json
from datetime import datetime, timedelta, timezone as dt_timezone
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from django.contrib.auth import get_user_model
from django.apps import apps
from django.conf import settings
from django.test import RequestFactory, TestCase, tag, override_settings
from django.urls import reverse
from django.utils import timezone
from django.contrib.sessions.middleware import SessionMiddleware
from waffle.testutils import override_flag

from api.models import (
    BrowserUseAgent,
    DedicatedProxyAllocation,
    Organization,
    PersistentAgent,
    ProxyServer,
    StripeCheckoutContext,
    UserAttribution,
    UserBilling,
    UserTrialEligibility,
    UserTrialEligibilityAutoStatusChoices,
    UserIdentitySignal,
    UserIdentitySignalTypeChoices,
)
from constants.plans import PlanNames, PlanNamesChoices
from constants.grant_types import GrantTypeChoices
from dateutil.relativedelta import relativedelta
from api.services.trial_abuse import SIGNAL_SOURCE_SIGNUP
from billing.checkout_metadata import (
    STRIPE_CHECKOUT_CUSTOMER_CURRENCY_META_KEY,
    STRIPE_CHECKOUT_CUSTOMER_EVENT_ID_META_KEY,
    STRIPE_CHECKOUT_CUSTOMER_FLOW_TYPE_META_KEY,
    STRIPE_CHECKOUT_CUSTOMER_PLAN_LABEL_META_KEY,
    STRIPE_CHECKOUT_CUSTOMER_PLAN_META_KEY,
    STRIPE_CHECKOUT_CUSTOMER_SOURCE_URL_META_KEY,
    STRIPE_CHECKOUT_CUSTOMER_VALUE_META_KEY,
    clear_checkout_customer_metadata,
)
from pages.signals import (
    handle_checkout_session_event,
    handle_subscription_event,
    handle_user_logged_in,
    handle_user_signed_up,
    handle_invoice_payment_failed,
    handle_setup_intent_created,
    handle_setup_intent_setup_failed,
    handle_setup_intent_succeeded,
    handle_invoice_payment_succeeded,
)
from util.analytics import AnalyticsEvent, AnalyticsSource
from util.subscription_helper import mark_user_billing_with_plan as real_mark_user_billing_with_plan
from api.services.owner_execution_pause import (
    EXECUTION_PAUSE_REASON_ACCOUNT_CANCELLATION,
    EXECUTION_PAUSE_REASON_BILLING_DELINQUENCY,
    EXECUTION_PAUSE_REASON_CUSTOMER_ACCOUNT_PAUSE,
    EXECUTION_PAUSE_REASON_TRIAL_ENDED_NON_RENEWAL,
    resume_owner_execution as real_resume_owner_execution,
)
from constants.stripe import (
    ORG_OVERAGE_STATE_META_KEY,
    ORG_OVERAGE_STATE_DETACHED_PENDING,
)


User = get_user_model()


@tag("batch_pages_signals")
class UserSignedUpSignalTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="signup-user",
            email="signup@example.com",
            password="pw",
        )
        self.factory = RequestFactory()

    def _request_with_session(self, path="/signup", *, cookies=None, referer=""):
        request = self.factory.get(path)
        if referer:
            request.META["HTTP_REFERER"] = referer
        middleware = SessionMiddleware(lambda req: None)
        middleware.process_request(request)
        request.session.save()
        request.COOKIES = cookies or {}
        return request

    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_first_touch_traits_preserved_across_visits(self, mock_identify, mock_track):
        request = self.factory.get("/signup")
        request.META["REMOTE_ADDR"] = "198.51.100.24"
        middleware = SessionMiddleware(lambda req: None)
        middleware.process_request(request)
        request.session.save()

        first_touch_payload = {
            "utm_source": "first-source",
            "utm_medium": "first-medium",
        }
        click_first_payload = {
            "gclid": "first-gclid",
            "gbraid": "first-gbraid",
            "wbraid": "first-wbraid",
            "msclkid": "first-msclkid",
            "ttclid": "first-ttclid",
            "rdt_cid": "first-rdt-cid",
        }
        now = timezone.now()
        later = now + timedelta(minutes=5)
        request.COOKIES = {
            "__utm_first": json.dumps(first_touch_payload),
            "utm_source": "last-source",
            "utm_medium": "last-medium",
            "__landing_first": "LP-100",
            "landing_code": "LP-200",
            "_fbc": "fb.1.123456789.abcdef",
            "fbclid": "fbclid-xyz",
            "__click_first": json.dumps(click_first_payload),
            "gclid": "last-gclid",
            "gbraid": "last-gbraid",
            "wbraid": "last-wbraid",
            "msclkid": "last-msclkid",
            "ttclid": "last-ttclid",
            "rdt_cid": "last-rdt-cid",
            "first_referrer": "https://first.example/",
            "last_referrer": "https://last.example/",
            "first_path": "/landing/first/",
            "last_path": "/pricing/",
            "ajs_anonymous_id": '"anon-123"',
            "_ga": "GA1.2.111.222",
        }
        request.session["landing_code_first"] = "LP-100"
        request.session["landing_code_last"] = "LP-200"
        request.session["landing_first_seen_at"] = now.isoformat()
        request.session["landing_last_seen_at"] = later.isoformat()

        handle_user_signed_up(sender=None, request=request, user=self.user)

        identify_call = mock_identify.call_args.kwargs
        traits = identify_call["traits"]
        self.assertEqual(traits["plan"], PlanNames.FREE)
        self.assertEqual(traits["utm_source_first"], "first-source")
        self.assertEqual(traits["utm_medium_first"], "first-medium")
        self.assertEqual(traits["utm_source_last"], "last-source")
        self.assertEqual(traits["utm_medium_last"], "last-medium")
        self.assertEqual(traits["landing_code_first"], "LP-100")
        self.assertEqual(traits["landing_code_last"], "LP-200")
        self.assertEqual(traits["fbc"], "fb.1.123456789.abcdef")
        self.assertEqual(traits["fbclid"], "fbclid-xyz")
        self.assertEqual(traits["gclid_first"], "first-gclid")
        self.assertEqual(traits["gclid_last"], "last-gclid")
        self.assertEqual(traits["msclkid_first"], "first-msclkid")
        self.assertEqual(traits["msclkid_last"], "last-msclkid")
        self.assertEqual(traits["rdt_cid_first"], "first-rdt-cid")
        self.assertEqual(traits["rdt_cid_last"], "last-rdt-cid")
        self.assertEqual(traits["first_referrer"], "https://first.example/")
        self.assertEqual(traits["last_referrer"], "https://last.example/")
        self.assertEqual(traits["first_landing_path"], "/landing/first/")
        self.assertEqual(traits["last_landing_path"], "/pricing/")
        self.assertEqual(traits["segment_anonymous_id"], "anon-123")
        self.assertEqual(traits["ga_client_id"], "GA1.2.111.222")

        attribution = UserAttribution.objects.get(user=self.user)
        self.assertEqual(attribution.rdt_cid_first, "first-rdt-cid")
        self.assertEqual(attribution.rdt_cid_last, "last-rdt-cid")

        track_call = mock_track.call_args.kwargs
        properties = track_call["properties"]
        context_campaign = track_call["context"]["campaign"]

        self.assertEqual(properties["plan"], PlanNames.FREE)
        self.assertEqual(properties["auth_method"], "email")
        self.assertEqual(properties["utm_source_first"], "first-source")
        self.assertEqual(properties["utm_source_last"], "last-source")
        self.assertEqual(context_campaign["source"], "last-source")
        self.assertEqual(request.session["signup_auth_method"], "email")

    @tag("batch_pages_signals")
    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_social_signup_uses_pre_auth_referrer_over_google_callback(self, mock_identify, _mock_track):
        request = self._request_with_session(
            cookies={
                "first_referrer": "https://agentic.ai/",
                "last_referrer": "https://agentic.ai/pricing/",
            },
            referer="https://accounts.google.com/",
        )
        sociallogin = SimpleNamespace(account=SimpleNamespace(provider="google"))

        handle_user_signed_up(sender=None, request=request, user=self.user, sociallogin=sociallogin)

        attribution = UserAttribution.objects.get(user=self.user)
        self.assertEqual(attribution.first_referrer, "https://agentic.ai/")
        self.assertEqual(attribution.last_referrer, "https://agentic.ai/pricing/")
        traits = mock_identify.call_args.kwargs["traits"]
        self.assertEqual(traits["first_referrer"], "https://agentic.ai/")
        self.assertEqual(traits["last_referrer"], "https://agentic.ai/pricing/")

    @tag("batch_pages_signals")
    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_social_signup_keeps_utm_but_ignores_google_callback_referrer(self, _mock_identify, _mock_track):
        request = self._request_with_session(
            cookies={
                "utm_source": "youtube",
                "utm_medium": "social",
            },
            referer="https://accounts.google.com/",
        )
        sociallogin = SimpleNamespace(account=SimpleNamespace(provider="google"))

        handle_user_signed_up(sender=None, request=request, user=self.user, sociallogin=sociallogin)

        attribution = UserAttribution.objects.get(user=self.user)
        self.assertEqual(attribution.utm_source_first, "youtube")
        self.assertEqual(attribution.utm_medium_first, "social")
        self.assertEqual(attribution.first_referrer, "")
        self.assertEqual(attribution.last_referrer, "")

    @tag("batch_pages_signals")
    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_social_signup_without_prior_attribution_ignores_google_callback_referrer(
        self,
        _mock_identify,
        _mock_track,
    ):
        request = self._request_with_session(referer="https://accounts.google.com/")
        sociallogin = SimpleNamespace(account=SimpleNamespace(provider="google"))

        handle_user_signed_up(sender=None, request=request, user=self.user, sociallogin=sociallogin)

        attribution = UserAttribution.objects.get(user=self.user)
        self.assertEqual(attribution.first_referrer, "")
        self.assertEqual(attribution.last_referrer, "")

    @tag("batch_pages_signals")
    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_social_signup_preserves_existing_referrer_when_callback_referrer_is_auth_provider(
        self,
        _mock_identify,
        _mock_track,
    ):
        UserAttribution.objects.create(
            user=self.user,
            first_referrer="https://agentic.ai/",
            last_referrer="https://agentic.ai/pricing/",
        )
        request = self._request_with_session(referer="https://accounts.google.com/")
        sociallogin = SimpleNamespace(account=SimpleNamespace(provider="google"))

        handle_user_signed_up(sender=None, request=request, user=self.user, sociallogin=sociallogin)

        attribution = UserAttribution.objects.get(user=self.user)
        self.assertEqual(attribution.first_referrer, "https://agentic.ai/")
        self.assertEqual(attribution.last_referrer, "https://agentic.ai/pricing/")

    @patch("pages.signals.evaluate_user_trial_eligibility", return_value=SimpleNamespace(eligible=True))
    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_signup_captures_identity_signals(self, _mock_identify, _mock_track, _mock_trial_eligibility):
        request = self.factory.post(
            "/signup",
            {
                "ufp": "visitor-123",
                "ufpr": "request-456",
                "uga": "GA1.2.333.444",
            },
        )
        request.META["REMOTE_ADDR"] = "198.51.100.24"
        request.META["HTTP_USER_AGENT"] = "SignupSignalTest/1.0"
        middleware = SessionMiddleware(lambda req: None)
        middleware.process_request(request)
        request.session.save()
        request.COOKIES = {
            settings.FBP_COOKIE_NAME: "fb.1.111.abcdef",
        }

        handle_user_signed_up(sender=None, request=request, user=self.user)

        signal_values = set(
            UserIdentitySignal.objects.filter(user=self.user).values_list("signal_type", "signal_value")
        )
        self.assertSetEqual(
            signal_values,
            {
                (UserIdentitySignalTypeChoices.FPJS_VISITOR_ID, "visitor-123"),
                (UserIdentitySignalTypeChoices.FPJS_REQUEST_ID, "request-456"),
                (UserIdentitySignalTypeChoices.FBP, "fb.1.111.abcdef"),
                (UserIdentitySignalTypeChoices.GA_CLIENT_ID, "333.444"),
                (UserIdentitySignalTypeChoices.IP_EXACT, "198.51.100.24"),
                (UserIdentitySignalTypeChoices.IP_PREFIX, "198.51.100.0/24"),
            },
        )

        attribution = UserAttribution.objects.get(user=self.user)
        self.assertEqual(attribution.ga_client_id, "333.444")
        self.assertEqual(attribution.fbp, "fb.1.111.abcdef")
        self.assertEqual(attribution.last_client_ip, "198.51.100.24")
        _mock_trial_eligibility.assert_called_once_with(
            self.user,
            assessment_source=SIGNAL_SOURCE_SIGNUP,
        )

    @patch("pages.signals.evaluate_user_trial_eligibility", return_value=SimpleNamespace(eligible=True))
    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_signup_still_assesses_trial_eligibility_when_enforcement_flag_disabled(
        self,
        _mock_identify,
        _mock_track,
        mock_trial_eligibility,
    ):
        request = self.factory.post("/signup")
        middleware = SessionMiddleware(lambda req: None)
        middleware.process_request(request)
        request.session.save()

        with override_flag("user_trial_eligibility_enforcement", active=False):
            handle_user_signed_up(sender=None, request=request, user=self.user)

        mock_trial_eligibility.assert_called_once_with(
            self.user,
            assessment_source=SIGNAL_SOURCE_SIGNUP,
        )

    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_social_signup_tracks_auth_provider(self, _mock_identify, mock_track):
        request = self.factory.get(reverse("openid_connect_login", kwargs={"provider_id": "linkedin"}))
        middleware = SessionMiddleware(lambda req: None)
        middleware.process_request(request)
        request.session.save()

        sociallogin = SimpleNamespace(
            account=SimpleNamespace(provider="linkedin"),
        )

        handle_user_signed_up(sender=None, request=request, user=self.user, sociallogin=sociallogin)

        properties = mock_track.call_args.kwargs["properties"]
        self.assertEqual(properties["auth_method"], "social")
        self.assertEqual(properties["auth_provider"], "linkedin")
        self.assertEqual(request.session["signup_auth_method"], "social")
        self.assertEqual(request.session["signup_auth_provider"], "linkedin")

    @override_settings(GOBII_PROPRIETARY_MODE=True, CAPI_REGISTRATION_VALUE=12.5)
    @patch("pages.signals.capi")
    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_signup_capi_includes_value_and_currency(self, mock_identify, mock_track, mock_capi):
        request = self.factory.get("/signup")
        middleware = SessionMiddleware(lambda req: None)
        middleware.process_request(request)
        request.session.save()

        with patch("pages.signals.transaction.on_commit", side_effect=lambda fn: fn()):
            handle_user_signed_up(sender=None, request=request, user=self.user)

        mock_capi.assert_called_once()
        capi_kwargs = mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "CompleteRegistration")
        self.assertIs(capi_kwargs["request"], request)
        props = capi_kwargs["properties"]
        self.assertEqual(props["value"], 12.5)
        self.assertEqual(props["currency"], "USD")

    @override_settings(GOBII_PROPRIETARY_MODE=True)
    @patch("pages.signals.record_fbc_synthesized")
    @patch("pages.signals.capi")
    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_signup_capi_synthesizes_fbc_from_session_fbclid(
        self, mock_identify, mock_track, mock_capi, mock_record_fbc_synthesized
    ):
        """When user lands with fbclid but signs up on a page without it, fbc should be synthesized.

        This improves Meta Event Match Quality by ensuring fbc is present even when
        the signup URL doesn't contain fbclid in the querystring.
        """
        # Simulate signup on a page WITHOUT fbclid in URL
        request = self.factory.get("/signup")  # No fbclid param
        request.META["REMOTE_ADDR"] = "198.51.100.24"
        middleware = SessionMiddleware(lambda req: None)
        middleware.process_request(request)
        request.session.save()

        # But fbclid WAS captured in session from earlier landing
        request.session["fbclid_last"] = "test-fbclid-from-session"
        # No _fbc cookie, no fbclid cookie - only session has it
        request.COOKIES = {}

        with patch("pages.signals.transaction.on_commit", side_effect=lambda fn: fn()):
            handle_user_signed_up(sender=None, request=request, user=self.user)

        mock_capi.assert_called_once()
        capi_kwargs = mock_capi.call_args.kwargs
        context = capi_kwargs["context"]
        click_ids = context.get("click_ids", {})

        # fbc should be synthesized from session fbclid
        self.assertIn("fbc", click_ids)
        self.assertTrue(
            click_ids["fbc"].startswith("fb.1."),
            f"fbc should start with 'fb.1.' but was: {click_ids.get('fbc')}"
        )
        self.assertTrue(
            click_ids["fbc"].endswith(".test-fbclid-from-session"),
            f"fbc should end with fbclid but was: {click_ids.get('fbc')}"
        )
        fbc_timestamp = click_ids["fbc"].split(".")[2]
        self.assertTrue(
            fbc_timestamp.isdigit() and len(fbc_timestamp) == 13,
            f"fbc timestamp should be a 13-digit millisecond value but was: {fbc_timestamp}"
        )
        # fbclid should also be included
        self.assertEqual(click_ids.get("fbclid"), "test-fbclid-from-session")
        mock_record_fbc_synthesized.assert_called_once_with(
            source="pages.signals.handle_user_signed_up"
        )

    @override_settings(GOBII_PROPRIETARY_MODE=True)
    @patch("pages.signals.record_fbc_synthesized")
    @patch("pages.signals.capi")
    @patch("pages.signals.Analytics.track")
    @patch("pages.signals.Analytics.identify")
    def test_signup_capi_uses_existing_fbc_cookie_over_synthesis(
        self, mock_identify, mock_track, mock_capi, mock_record_fbc_synthesized
    ):
        """When _fbc cookie exists, use it instead of synthesizing from fbclid."""
        request = self.factory.get("/signup")
        request.META["REMOTE_ADDR"] = "198.51.100.24"
        middleware = SessionMiddleware(lambda req: None)
        middleware.process_request(request)
        request.session.save()

        # Both _fbc cookie and session fbclid exist
        request.COOKIES = {"_fbc": "fb.1.existing.cookie-fbc-value"}
        request.session["fbclid_last"] = "session-fbclid"

        with patch("pages.signals.transaction.on_commit", side_effect=lambda fn: fn()):
            handle_user_signed_up(sender=None, request=request, user=self.user)

        mock_capi.assert_called_once()
        capi_kwargs = mock_capi.call_args.kwargs
        context = capi_kwargs["context"]
        click_ids = context.get("click_ids", {})

        # Should use existing _fbc cookie, not synthesize
        self.assertEqual(click_ids.get("fbc"), "fb.1.existing.cookie-fbc-value")
        mock_record_fbc_synthesized.assert_not_called()


@tag("batch_pages_signals")
class UserLoggedInSignalTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="login-user",
            email="login@example.com",
            password="pw",
        )
        self.factory = RequestFactory()

    @patch("pages.signals.capture_request_identity_signals_and_attribution")
    @patch("pages.signals.Analytics.track_event")
    @patch("pages.signals.Analytics.identify")
    def test_social_login_tracks_auth_provider(
        self,
        mock_identify,
        mock_track_event,
        mock_capture_request_identity_signals,
    ):
        request = self.factory.get(reverse("openid_connect_login", kwargs={"provider_id": "linkedin"}))
        middleware = SessionMiddleware(lambda req: None)
        middleware.process_request(request)
        request.session["account_authentication_methods"] = [
            {"method": "socialaccount", "provider": "linkedin"}
        ]
        request.session.save()

        handle_user_logged_in(sender=None, request=request, user=self.user)

        mock_capture_request_identity_signals.assert_called_once_with(
            self.user,
            request,
            source="login",
            include_fpjs=False,
        )
        mock_identify.assert_called_once()
        mock_track_event.assert_called_once()
        kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(kwargs["user_id"], self.user.id)
        self.assertEqual(kwargs["event"], AnalyticsEvent.LOGGED_IN)
        self.assertEqual(kwargs["source"], AnalyticsSource.WEB)
        self.assertEqual(
            kwargs["properties"],
            {
                "auth_method": "social",
                "auth_provider": "linkedin",
            },
        )


@tag("batch_pages_signals")
class BuildMarketingContextFromUserTests(TestCase):
    """Tests for _build_marketing_context_from_user used by Subscribe events."""

    def setUp(self):
        self.user = User.objects.create_user(
            username="context-user",
            email="context@example.com",
            password="pw",
        )

    def test_synthesizes_fbc_from_fbclid_when_fbc_missing(self):
        """When fbc is missing but fbclid exists, fbc should be synthesized."""
        from pages.signals import _build_marketing_context_from_user

        UserAttribution.objects.create(
            user=self.user,
            fbclid="test-fbclid-value",
            fbc="",  # No fbc stored
        )

        with patch("pages.signals.record_fbc_synthesized") as mock_record_fbc_synthesized:
            context = _build_marketing_context_from_user(self.user)
        click_ids = context.get("click_ids", {})

        # fbc should be synthesized
        self.assertIn("fbc", click_ids)
        self.assertTrue(
            click_ids["fbc"].startswith("fb.1."),
            f"fbc should start with 'fb.1.' but was: {click_ids.get('fbc')}"
        )
        self.assertTrue(
            click_ids["fbc"].endswith(".test-fbclid-value"),
            f"fbc should end with fbclid but was: {click_ids.get('fbc')}"
        )
        fbc_timestamp = click_ids["fbc"].split(".")[2]
        self.assertTrue(
            fbc_timestamp.isdigit() and len(fbc_timestamp) == 13,
            f"fbc timestamp should be a 13-digit millisecond value but was: {fbc_timestamp}"
        )
        # fbclid should also be included
        self.assertEqual(click_ids.get("fbclid"), "test-fbclid-value")
        mock_record_fbc_synthesized.assert_called_once_with(
            source="pages.signals.build_marketing_context_from_user"
        )

    def test_uses_existing_fbc_over_synthesis(self):
        """When fbc already exists, don't synthesize from fbclid."""
        from pages.signals import _build_marketing_context_from_user

        UserAttribution.objects.create(
            user=self.user,
            fbc="fb.1.existing.stored-fbc",
            fbclid="some-fbclid",
        )

        with patch("pages.signals.record_fbc_synthesized") as mock_record_fbc_synthesized:
            context = _build_marketing_context_from_user(self.user)
        click_ids = context.get("click_ids", {})

        # Should use existing fbc
        self.assertEqual(click_ids.get("fbc"), "fb.1.existing.stored-fbc")
        mock_record_fbc_synthesized.assert_not_called()

    def test_includes_fbp_in_context(self):
        """fbp (Browser ID) should be included in click_ids."""
        from pages.signals import _build_marketing_context_from_user

        UserAttribution.objects.create(
            user=self.user,
            fbp="fb.1.1234567890.987654321",
        )

        context = _build_marketing_context_from_user(self.user)
        click_ids = context.get("click_ids", {})

        self.assertEqual(click_ids.get("fbp"), "fb.1.1234567890.987654321")

    def test_includes_user_agent_in_context(self):
        """User agent should be included in context."""
        from pages.signals import _build_marketing_context_from_user

        UserAttribution.objects.create(
            user=self.user,
            last_user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
        )

        context = _build_marketing_context_from_user(self.user)

        self.assertEqual(
            context.get("user_agent"),
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
        )

    def test_includes_client_ip_in_context(self):
        """Client IP should be included in context."""
        from pages.signals import _build_marketing_context_from_user

        UserAttribution.objects.create(
            user=self.user,
            last_client_ip="192.168.1.100",
        )

        context = _build_marketing_context_from_user(self.user)

        self.assertEqual(context.get("client_ip"), "192.168.1.100")

    def test_includes_ga_client_id_in_context(self):
        """GA client ID should be included in context for GA MP events."""
        from pages.signals import _build_marketing_context_from_user

        UserAttribution.objects.create(
            user=self.user,
            ga_client_id="GA1.2.111.222",
        )

        context = _build_marketing_context_from_user(self.user)

        self.assertEqual(context.get("ga_client_id"), "GA1.2.111.222")

    def test_includes_reddit_click_id_in_context(self):
        """Reddit click id should be included for downstream CAPI events."""
        from pages.signals import _build_marketing_context_from_user

        UserAttribution.objects.create(
            user=self.user,
            rdt_cid_last="reddit-last-click",
        )

        context = _build_marketing_context_from_user(self.user)
        click_ids = context.get("click_ids", {})

        self.assertEqual(click_ids.get("rdt_cid"), "reddit-last-click")

    def test_returns_minimal_context_when_no_attribution(self):
        """When user has no attribution, return minimal context with consent."""
        from pages.signals import _build_marketing_context_from_user

        # Don't create attribution
        context = _build_marketing_context_from_user(self.user)

        self.assertEqual(context, {"consent": True})


def _build_event_payload(
    *,
    status="active",
    invoice_id="in_123",
    usage_type="licensed",
    quantity=1,
    billing_reason="subscription_update",
    product="prod_123",
    extra_items=None,
    unit_amount=2999,
    currency="usd",
):
    items_data = [
        {
            "plan": {"usage_type": usage_type},
            "price": {
                "product": product,
                "unit_amount": unit_amount,
                "unit_amount_decimal": str(unit_amount) if unit_amount is not None else None,
                "currency": currency,
            },
            "quantity": quantity,
        }
    ]

    if extra_items:
        items_data.extend(extra_items)

    payload = {
        "object": "subscription",
        "id": "sub_123",
        "latest_invoice": invoice_id,
        "items": {
            "data": items_data,
        },
        "status": status,
        "cancel_at": None,
        "cancel_at_period_end": False,
        "current_period_start": None,
        "current_period_end": None,
    }

    if billing_reason is not None:
        payload["billing_reason"] = billing_reason

    return payload


def _build_djstripe_event(
    payload,
    event_type="customer.subscription.updated",
    previous_attributes=None,
    event_id="evt_test",
):
    data = {"object": payload}
    if previous_attributes is not None:
        data["previous_attributes"] = previous_attributes
    return SimpleNamespace(data=data, type=event_type, id=event_id)


def _build_invoice_payload(
    *,
    invoice_id="in_fail",
    customer_id="cus_fail",
    subscription_id="sub_fail",
    attempt_count=1,
    next_payment_attempt=None,
    livemode=True,
    amount_due=4720,
    amount_paid=0,
    currency="usd",
    billing_reason="subscription_cycle",
    status="open",
    auto_advance=True,
    hosted_invoice_url="https://invoice.example/test",
    invoice_pdf="https://invoice.example/test.pdf",
    price_id="price_fail",
    product_id="prod_fail",
    receipt_number="rcpt-test",
    payment_intent=None,
    payments=None,
):
    payload = {
        "object": "invoice",
        "id": invoice_id,
        "number": "INV-FAIL",
        "customer": customer_id,
        "subscription": subscription_id,
        "attempt_count": attempt_count,
        "attempted": True,
        "next_payment_attempt": next_payment_attempt,
        "livemode": livemode,
        "amount_due": amount_due,
        "total": amount_due,
        "amount_paid": amount_paid,
        "currency": currency,
        "billing_reason": billing_reason,
        "collection_method": "charge_automatically",
        "status": status,
        "auto_advance": auto_advance,
        "hosted_invoice_url": hosted_invoice_url,
        "invoice_pdf": invoice_pdf,
        "receipt_number": receipt_number,
        "lines": {
            "data": [
                {
                    "id": "il_fail",
                    "object": "line_item",
                    "price": {"id": price_id, "product": product_id},
                }
            ]
        },
    }
    if payment_intent is not None:
        payload["payment_intent"] = payment_intent
    if payments is not None:
        payload["payments"] = payments
    return payload


def _build_setup_intent_payload(
    *,
    setup_intent_id="seti_fail",
    customer_id="cus_setup",
    payment_method="pm_setup",
    status="requires_payment_method",
    usage="off_session",
    livemode=True,
    payment_method_types=None,
    last_setup_error=None,
    created=None,
):
    payload = {
        "object": "setup_intent",
        "id": setup_intent_id,
        "status": status,
        "usage": usage,
        "livemode": livemode,
        "payment_method_types": payment_method_types or ["card"],
    }
    if customer_id is not None:
        payload["customer"] = customer_id
    if payment_method is not None:
        payload["payment_method"] = payment_method
    if last_setup_error is not None:
        payload["last_setup_error"] = last_setup_error
    if created is not None:
        payload["created"] = created
    return payload


@tag("batch_pages_signals")
class CheckoutSessionSignalTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="checkout-session-user",
            email="checkout-session@example.com",
            password="pw",
        )

    @patch("pages.signals.stripe.Customer.modify")
    @patch("pages.signals.stripe.Customer.retrieve")
    @patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test")
    @patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True))
    def test_checkout_session_completed_clears_matching_customer_checkout_context(
        self,
        _mock_status,
        _mock_key,
        mock_customer_retrieve,
        mock_customer_modify,
    ):
        payload = {
            "object": "checkout.session",
            "id": "cs_trial_complete",
            "customer": "cus_trial_complete",
            "metadata": {"gobii_event_id": "startup-sub-123"},
        }
        event = _build_djstripe_event(
            payload,
            event_type="checkout.session.completed",
            event_id="evt_checkout_completed",
        )
        mock_customer_retrieve.return_value = {
            "id": "cus_trial_complete",
            "metadata": {
                STRIPE_CHECKOUT_CUSTOMER_EVENT_ID_META_KEY: "startup-sub-123",
                STRIPE_CHECKOUT_CUSTOMER_FLOW_TYPE_META_KEY: "trial",
            },
        }

        handle_checkout_session_event(event)

        mock_customer_retrieve.assert_called_once_with("cus_trial_complete", api_key="sk_test")
        mock_customer_modify.assert_called_once_with(
            "cus_trial_complete",
            metadata=clear_checkout_customer_metadata(),
            api_key="sk_test",
        )

    @patch("pages.signals.stripe.Customer.modify")
    @patch("pages.signals.stripe.Customer.retrieve")
    @patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test")
    @patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True))
    def test_checkout_session_expired_does_not_clear_newer_customer_checkout_context(
        self,
        _mock_status,
        _mock_key,
        mock_customer_retrieve,
        mock_customer_modify,
    ):
        payload = {
            "object": "checkout.session",
            "id": "cs_trial_expired",
            "customer": "cus_trial_expired",
            "metadata": {"gobii_event_id": "startup-sub-old"},
        }
        event = _build_djstripe_event(
            payload,
            event_type="checkout.session.expired",
            event_id="evt_checkout_expired",
        )
        mock_customer_retrieve.return_value = {
            "id": "cus_trial_expired",
            "metadata": {
                STRIPE_CHECKOUT_CUSTOMER_EVENT_ID_META_KEY: "startup-sub-new",
                STRIPE_CHECKOUT_CUSTOMER_FLOW_TYPE_META_KEY: "purchase",
            },
        }

        handle_checkout_session_event(event)

        mock_customer_retrieve.assert_called_once_with("cus_trial_expired", api_key="sk_test")
        mock_customer_modify.assert_not_called()

    def test_checkout_session_completed_emits_add_payment_info_for_purchase_flow(self):
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_purchase_complete",
            stripe_checkout_session_id="cs_purchase_complete",
            event_id="startup-sub-purchase",
            flow_type="purchase",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
        )
        payload = {
            "object": "checkout.session",
            "id": "cs_purchase_complete",
            "customer": "cus_purchase_complete",
            "mode": "subscription",
            "payment_method_types": ["card"],
            "metadata": {
                "gobii_event_id": "startup-sub-purchase",
                "flow_type": "purchase",
                "plan": PlanNames.STARTUP,
                "checkout_source_url": "https://www.gobii.ai/pricing",
            },
        }
        event = _build_djstripe_event(
            payload,
            event_type="checkout.session.completed",
            event_id="evt_checkout_purchase_complete",
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._resolve_setup_intent_owner",
                return_value=(self.user, "user", None, "cus_purchase_complete"),
            ), \
            patch("pages.signals._build_marketing_context_from_user", return_value={"consent": True}), \
            patch("pages.signals._clear_customer_checkout_context_if_matches", return_value=True) as mock_clear, \
            patch("pages.signals.capi") as mock_capi:

            handle_checkout_session_event(event)

        mock_capi.assert_called_once_with(
            user=self.user,
            event_name="AddPaymentInfo",
            properties={
                "event_id": "startup-sub-purchase",
                "plan": PlanNames.STARTUP,
                "plan_label": "Pro",
                "flow_type": "purchase",
                "value": 120.0,
                "currency": "USD",
                "payment_method_type": "card",
            },
            request=None,
            context={
                "consent": True,
                "page": {"url": "https://www.gobii.ai/pricing"},
            },
        )
        mock_clear.assert_called_once_with(
            customer_id="cus_purchase_complete",
            expected_event_id="startup-sub-purchase",
        )
        checkout_context = StripeCheckoutContext.objects.get(
            stripe_checkout_session_id="cs_purchase_complete",
        )
        self.assertIsNotNone(checkout_context.add_payment_info_sent_at)

    def test_checkout_session_completed_does_not_emit_add_payment_info_for_trial_flow(self):
        payload = {
            "object": "checkout.session",
            "id": "cs_trial_complete",
            "customer": "cus_trial_complete",
            "mode": "subscription",
            "payment_method_types": ["card"],
            "metadata": {
                "gobii_event_id": "startup-sub-trial",
                "flow_type": "trial",
            },
        }
        event = _build_djstripe_event(
            payload,
            event_type="checkout.session.completed",
            event_id="evt_checkout_trial_complete",
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals._resolve_setup_intent_owner") as mock_resolve_owner, \
            patch("pages.signals._clear_customer_checkout_context_if_matches", return_value=True) as mock_clear, \
            patch("pages.signals.capi") as mock_capi:

            handle_checkout_session_event(event)

        mock_resolve_owner.assert_not_called()
        mock_capi.assert_not_called()
        mock_clear.assert_called_once_with(
            customer_id="cus_trial_complete",
            expected_event_id="startup-sub-trial",
        )


@tag("batch_pages_signals")
class SubscriptionSignalTests(TestCase):
    maxDiff = None

    def setUp(self):
        self.user = User.objects.create_user(username="stripe-user", email="stripe@example.com", password="pw")
        self.billing = UserBilling.objects.get(user=self.user)
        self.billing.billing_cycle_anchor = 1
        self.billing.save(update_fields=["billing_cycle_anchor"])

        self._capi_patcher = patch("pages.signals.capi")
        self.mock_capi = self._capi_patcher.start()
        self.addCleanup(self._capi_patcher.stop)
        self.mock_capi.reset_mock()

    def _mock_subscription(self, current_period_day: int, *, subscriber=None):
        aware_start = timezone.make_aware(datetime(2025, 9, current_period_day, 8, 0, 0), timezone=dt_timezone.utc)
        aware_end = timezone.make_aware(datetime(2025, 10, current_period_day, 8, 0, 0), timezone=dt_timezone.utc)
        subscriber = subscriber or self.user
        sub = MagicMock()
        sub.status = "active"
        sub.id = "sub_123"
        sub.customer = SimpleNamespace(subscriber=subscriber)
        sub.billing_reason = None
        sub.stripe_data = _build_event_payload()
        sub.stripe_data['current_period_start'] = str(aware_start)
        sub.stripe_data['current_period_end'] = str(aware_end)
        return sub

    @tag("batch_pages_signals")
    @override_settings(CAPI_LTV_MULTIPLE=1.0)
    def test_subscription_anchor_updates_from_stripe(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(billing_reason="subscription_create")
        event = _build_djstripe_event(payload, event_type="customer.subscription.created")

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=17, subscriber=fresh_user)
        sub.stripe_data['billing_reason'] = "subscription_create"
        sub.billing_reason = "subscription_create"
        event_id = "sub-evt-123"
        sub.stripe_data['metadata'] = {"gobii_event_id": event_id}

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan) as mock_mark_plan, \
            patch("pages.signals.Analytics.identify") as mock_identify, \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.logger.exception") as mock_logger_exception:

            handle_subscription_event(event)

        self.user.refresh_from_db()
        updated_billing = self.user.billing
        self.assertEqual(updated_billing.billing_cycle_anchor, 17)

        mock_mark_plan.assert_called_once()
        _, kwargs = mock_mark_plan.call_args
        call_user = mock_mark_plan.call_args[0][0]
        self.assertEqual(call_user.pk, self.user.pk)
        self.assertFalse(kwargs.get("update_anchor", True))
        mock_identify.assert_called_once()
        mock_track_event.assert_called_once()
        track_kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(track_kwargs["event"], AnalyticsEvent.SUBSCRIPTION_CREATED)
        self.assertEqual(track_kwargs["properties"]["plan"], PlanNamesChoices.STARTUP.value)
        mock_logger_exception.assert_not_called()

        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    def test_subscription_event_resumes_signup_preview_agents_after_personal_plan_activation(self):
        payload = _build_event_payload(billing_reason="subscription_create")
        event = _build_djstripe_event(payload, event_type="customer.subscription.created")

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=17, subscriber=fresh_user)
        sub.stripe_data["billing_reason"] = "subscription_create"
        sub.billing_reason = "subscription_create"

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.resume_signup_preview_agents_for_user_if_eligible") as mock_resume_preview, \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        mock_resume_preview.assert_called_once_with(
            fresh_user,
            reconcile_plan=False,
            resume_source="webhook",
            plan_before=PlanNamesChoices.FREE.value,
            plan_after=PlanNamesChoices.STARTUP.value,
        )

    @tag("batch_pages_signals")
    @override_settings(CAPI_LTV_MULTIPLE=2.0, CAPI_START_TRIAL_CONV_RATE=0.5)
    def test_subscription_capi_value_applies_ltv_multiple(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="trialing", billing_reason="subscription_create", invoice_id=None)
        event = _build_djstripe_event(payload, event_type="customer.subscription.created")

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=17, subscriber=fresh_user)
        sub.status = "trialing"
        sub.latest_invoice = None
        sub.stripe_data["latest_invoice"] = None
        sub.stripe_data["billing_reason"] = "subscription_create"
        sub.billing_reason = "subscription_create"

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        self.mock_capi.assert_called_once()
        props = self.mock_capi.call_args.kwargs["properties"]
        # Base value from payload is 29.99; with 2x multiplier expect ~59.98
        self.assertAlmostEqual(props["predicted_ltv"], 59.98, places=2)
        self.assertAlmostEqual(props["value"], 29.99, places=2)
        self.assertEqual(props["currency"], "USD")

    @tag("batch_pages_signals")
    @patch("pages.signals.ensure_single_individual_subscription")
    def test_subscription_created_dedupes_individual_plan(self, mock_ensure):
        payload = _build_event_payload(billing_reason="subscription_create")
        payload_items = payload["items"]["data"]
        payload_items[0]["price"]["id"] = "price_base"
        payload_items[0]["price"]["usage_type"] = "licensed"
        payload_items[0]["price"]["product"] = "prod_plan"
        payload_items.append(
            {
                "plan": {"usage_type": "metered"},
                "price": {"id": "price_meter", "usage_type": "metered", "product": "prod_meter"},
                "quantity": None,
            }
        )
        event = _build_djstripe_event(payload, event_type="customer.subscription.created")

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(subscriber=fresh_user, current_period_day=12)
        sub.stripe_data["billing_reason"] = "subscription_create"
        sub.customer.id = "cus_test"
        sub.stripe_data = payload

        plan_products = {"prod_plan"}

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals._individual_plan_product_ids", return_value=plan_products):

            handle_subscription_event(event)

        mock_ensure.assert_called_once_with(
            customer_id="cus_test",
            licensed_price_id="price_base",
            metered_price_id="price_meter",
            metadata=payload.get("metadata"),
            idempotency_key=f"sub-webhook-upsert-{payload.get('id', '')}",
            create_if_missing=False,
        )

    @tag("batch_pages_signals")
    def test_subscription_event_includes_client_ip_from_attribution(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="trialing", billing_reason="subscription_create", invoice_id=None)
        event = _build_djstripe_event(payload, event_type="customer.subscription.created")

        UserAttribution.objects.update_or_create(
            user=self.user,
            defaults={"last_client_ip": "203.0.113.5"},
        )

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=12, subscriber=fresh_user)
        sub.status = "trialing"
        sub.latest_invoice = None
        sub.stripe_data["latest_invoice"] = None
        sub.stripe_data['billing_reason'] = "subscription_create"
        sub.billing_reason = "subscription_create"

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        self.mock_capi.assert_called_once()
        context = self.mock_capi.call_args.kwargs["context"]
        self.assertEqual(context.get("client_ip"), "203.0.113.5")

    @tag("batch_pages_signals")
    def test_subscription_create_update_event_does_not_emit_duplicate_marketing(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(billing_reason="subscription_create")
        event = _build_djstripe_event(payload, event_type="customer.subscription.updated")

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=12, subscriber=fresh_user)
        sub.stripe_data['billing_reason'] = "subscription_create"
        sub.billing_reason = "subscription_create"

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event") as mock_track_event:

            handle_subscription_event(event)

        mock_track_event.assert_called_once()
        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    def test_subscription_event_refreshes_live_subscription_when_local_status_is_stale(self):
        payload = _build_event_payload(status="active", billing_reason="subscription_create")
        event = _build_djstripe_event(payload, event_type="customer.subscription.updated")

        fresh_user = User.objects.get(pk=self.user.pk)
        stale_sub = self._mock_subscription(current_period_day=12, subscriber=fresh_user)
        stale_sub.status = "incomplete"
        stale_sub.stripe_data["status"] = "incomplete"
        stale_sub.stripe_data["billing_reason"] = "subscription_create"
        stale_sub.billing_reason = "subscription_create"

        refreshed_sub = self._mock_subscription(current_period_day=12, subscriber=fresh_user)
        refreshed_sub.status = "active"
        refreshed_sub.stripe_data["status"] = "active"
        refreshed_sub.stripe_data["billing_reason"] = "subscription_create"
        refreshed_sub.billing_reason = "subscription_create"

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", side_effect=[stale_sub, refreshed_sub]) as mock_sync, \
            patch("pages.signals.stripe.Subscription.retrieve", return_value={"id": "sub_123", "status": "active", "items": {"data": payload["items"]["data"]}}) as mock_retrieve, \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        self.assertEqual(mock_sync.call_count, 2)
        mock_retrieve.assert_called_once_with("sub_123", expand=["items.data.price"])

    @tag("batch_pages_signals")
    def test_subscription_cycle_emits_renewed_event(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(billing_reason="subscription_cycle")
        event = _build_djstripe_event(payload)

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=15, subscriber=fresh_user)
        sub.stripe_data['billing_reason'] = "subscription_cycle"
        sub.billing_reason = "subscription_cycle"

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify") as mock_identify, \
            patch("pages.signals.Analytics.track_event") as mock_track_event:

            handle_subscription_event(event)

        mock_identify.assert_called_once()
        mock_track_event.assert_called_once()
        track_kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(track_kwargs["event"], AnalyticsEvent.SUBSCRIPTION_RENEWED)
        self.assertEqual(track_kwargs["properties"]["plan"], PlanNamesChoices.STARTUP.value)

        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    def test_subscription_update_without_plan_change_skips_credit_grant(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(billing_reason="subscription_update")
        event = _build_djstripe_event(payload, event_type="customer.subscription.updated")

        self.billing.subscription = PlanNamesChoices.STARTUP.value
        self.billing.save(update_fields=["subscription"])

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=15, subscriber=fresh_user)
        sub.stripe_data["billing_reason"] = "subscription_update"
        sub.billing_reason = "subscription_update"

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits") as mock_grant, \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        mock_grant.assert_not_called()
        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    def test_subscription_update_plan_change_grants_topoff(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(billing_reason=None, invoice_id="in_upgrade")
        event = _build_djstripe_event(payload, event_type="customer.subscription.updated")

        self.billing.subscription = PlanNamesChoices.STARTUP.value
        self.billing.save(update_fields=["subscription"])

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=15, subscriber=fresh_user)
        sub.stripe_data["latest_invoice"] = payload["latest_invoice"]
        sub.stripe_data["items"]["data"][0]["price"]["product"] = "prod_scale"
        sub.stripe_data["items"]["data"][0]["price"]["unit_amount"] = 25000
        sub.stripe_data["items"]["data"][0]["price"]["unit_amount_decimal"] = "25000"
        sub.stripe_data.pop("billing_reason", None)
        sub.billing_reason = None

        current_period_end = timezone.make_aware(datetime(2025, 10, 15, 8, 0, 0), timezone=dt_timezone.utc)
        TaskCredit = apps.get_model("api", "TaskCredit")
        TaskCredit.objects.create(
            user=fresh_user,
            credits=500,
            credits_used=100,
            expiration_date=current_period_end,
            stripe_invoice_id="in_prev",
            granted_date=timezone.make_aware(datetime(2025, 9, 15, 8, 0, 0), timezone=dt_timezone.utc),
            plan=PlanNamesChoices.STARTUP,
            grant_type=GrantTypeChoices.PLAN,
            additional_task=False,
        )

        invoice_payload = {
            "id": payload["latest_invoice"],
            "object": "invoice",
            "billing_reason": "subscription_update",
        }
        invoice_obj = SimpleNamespace(billing_reason="subscription_update", stripe_data=invoice_payload)
        as_of = timezone.make_aware(datetime(2025, 9, 25, 8, 0, 0), timezone=dt_timezone.utc)

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch(
                "pages.signals.get_plan_by_product_id",
                return_value={"id": PlanNamesChoices.SCALE.value, "monthly_task_credits": 10000},
            ), \
            patch("pages.signals.timezone.now", return_value=as_of), \
            patch("pages.signals.stripe.Invoice.retrieve", return_value=invoice_payload) as mock_invoice_retrieve, \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits") as mock_grant, \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        mock_invoice_retrieve.assert_called_once_with(payload["latest_invoice"])
        mock_grant.assert_called_once()
        _, grant_kwargs = mock_grant.call_args
        self.assertEqual(grant_kwargs["invoice_id"], payload["latest_invoice"])
        self.assertEqual(grant_kwargs["credit_override"], Decimal("9600"))
        self.assertEqual(grant_kwargs["expiration_date"], current_period_end)

        self.user.refresh_from_db()
        self.assertEqual(self.user.billing.subscription, PlanNamesChoices.SCALE.value)
        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    def test_subscription_update_plan_change_includes_prior_midcycle_topoff(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(billing_reason=None, invoice_id="in_upgrade_2")
        event = _build_djstripe_event(payload, event_type="customer.subscription.updated")

        self.billing.subscription = PlanNamesChoices.STARTUP.value
        self.billing.save(update_fields=["subscription"])

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=15, subscriber=fresh_user)
        sub.stripe_data["latest_invoice"] = payload["latest_invoice"]
        sub.stripe_data["items"]["data"][0]["price"]["product"] = "prod_scale"
        sub.stripe_data["items"]["data"][0]["price"]["unit_amount"] = 25000
        sub.stripe_data["items"]["data"][0]["price"]["unit_amount_decimal"] = "25000"
        sub.stripe_data.pop("billing_reason", None)
        sub.billing_reason = None

        current_period_end = timezone.make_aware(datetime(2025, 10, 15, 8, 0, 0), timezone=dt_timezone.utc)
        as_of = timezone.make_aware(datetime(2025, 9, 25, 8, 0, 0), timezone=dt_timezone.utc)
        TaskCredit = apps.get_model("api", "TaskCredit")
        TaskCredit.objects.create(
            user=fresh_user,
            credits=500,
            credits_used=100,
            expiration_date=current_period_end,
            stripe_invoice_id="in_prev_base",
            granted_date=timezone.make_aware(datetime(2025, 9, 15, 8, 0, 0), timezone=dt_timezone.utc),
            plan=PlanNamesChoices.STARTUP,
            grant_type=GrantTypeChoices.PLAN,
            additional_task=False,
        )
        TaskCredit.objects.create(
            user=fresh_user,
            credits=1000,
            credits_used=200,
            expiration_date=current_period_end,
            stripe_invoice_id="plan-topoff:sub_123:2025-09-20:startup",
            granted_date=timezone.make_aware(datetime(2025, 9, 20, 8, 0, 0), timezone=dt_timezone.utc),
            plan=PlanNamesChoices.STARTUP,
            grant_type=GrantTypeChoices.PLAN,
            additional_task=False,
        )

        invoice_payload = {
            "id": payload["latest_invoice"],
            "object": "invoice",
            "billing_reason": "subscription_update",
        }
        invoice_obj = SimpleNamespace(billing_reason="subscription_update", stripe_data=invoice_payload)

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch(
                "pages.signals.get_plan_by_product_id",
                return_value={"id": PlanNamesChoices.SCALE.value, "monthly_task_credits": 10000},
            ), \
            patch("pages.signals.timezone.now", return_value=as_of), \
            patch("pages.signals.stripe.Invoice.retrieve", return_value=invoice_payload), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits") as mock_grant, \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        mock_grant.assert_called_once()
        _, grant_kwargs = mock_grant.call_args
        self.assertEqual(grant_kwargs["invoice_id"], payload["latest_invoice"])
        self.assertEqual(grant_kwargs["credit_override"], Decimal("8800"))
        self.assertEqual(grant_kwargs["expiration_date"], current_period_end)
        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    def test_subscription_update_plan_change_survives_identify_failure(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="trialing", billing_reason=None, invoice_id="in_upgrade_500")
        event = _build_djstripe_event(payload, event_type="customer.subscription.updated")

        self.billing.subscription = PlanNamesChoices.STARTUP.value
        self.billing.save(update_fields=["subscription"])

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=15, subscriber=fresh_user)
        sub.status = "trialing"
        sub.stripe_data["latest_invoice"] = payload["latest_invoice"]
        sub.stripe_data["default_payment_method"] = "pm_new"
        sub.stripe_data["items"]["data"][0]["price"]["product"] = "prod_scale"
        sub.stripe_data["items"]["data"][0]["price"]["id"] = "price_scale"
        sub.stripe_data["items"]["data"][0]["price"]["unit_amount"] = 25000
        sub.stripe_data["items"]["data"][0]["price"]["unit_amount_decimal"] = "25000"
        sub.stripe_data.pop("billing_reason", None)
        sub.billing_reason = None

        invoice_payload = {
            "id": payload["latest_invoice"],
            "object": "invoice",
            "billing_reason": "subscription_update",
        }
        invoice_obj = SimpleNamespace(billing_reason="subscription_update", stripe_data=invoice_payload)
        as_of = timezone.make_aware(datetime(2025, 9, 25, 8, 0, 0), timezone=dt_timezone.utc)

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch(
                "pages.signals.get_plan_by_product_id",
                return_value={"id": PlanNamesChoices.SCALE.value, "monthly_task_credits": 10000},
            ), \
            patch("pages.signals.timezone.now", return_value=as_of), \
            patch("pages.signals.stripe.Subscription.retrieve") as mock_subscription_retrieve, \
            patch("pages.signals.stripe.Invoice.retrieve", return_value=invoice_payload), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits") as mock_grant, \
            patch("pages.signals.Analytics.identify", side_effect=RuntimeError("analytics down")), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.logger.exception") as mock_logger_exception:

            handle_subscription_event(event)

        self.user.refresh_from_db()
        self.assertEqual(self.user.billing.subscription, PlanNamesChoices.SCALE.value)
        mock_subscription_retrieve.assert_not_called()
        mock_grant.assert_called_once()
        mock_track_event.assert_not_called()
        self.assertTrue(any("Failed to identify subscription analytics state" in str(call.args[0]) for call in mock_logger_exception.call_args_list))
        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    @override_settings(CAPI_START_TRIAL_CONV_RATE=0.3)
    def test_trialing_subscription_grants_full_credits(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="trialing", billing_reason="subscription_create", invoice_id=None)
        event = _build_djstripe_event(payload, event_type="customer.subscription.created")

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=1, subscriber=fresh_user)
        sub.status = "trialing"
        sub.latest_invoice = None
        sub.stripe_data["latest_invoice"] = None

        start_dt = timezone.make_aware(datetime(2025, 9, 1, 8, 0, 0), timezone=dt_timezone.utc)
        end_dt = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        sub.stripe_data["current_period_start"] = str(start_dt)
        sub.stripe_data["current_period_end"] = str(end_dt)
        sub.stripe_data["billing_reason"] = "subscription_create"
        sub.billing_reason = "subscription_create"

        plan_payload = {"id": PlanNamesChoices.STARTUP.value, "monthly_task_credits": 300}

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value=plan_payload), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits") as mock_grant, \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify") as mock_identify, \
            patch("pages.signals.Analytics.track_event") as mock_track_event:

            handle_subscription_event(event)

        self.assertTrue(mock_grant.called)
        grant_kwargs = mock_grant.call_args.kwargs
        self.assertIsNone(grant_kwargs["credit_override"])
        self.assertTrue(grant_kwargs["invoice_id"].startswith("trial:sub_123"))
        self.assertEqual(grant_kwargs["expiration_date"], end_dt + relativedelta(months=1))
        self.assertTrue(grant_kwargs["free_trial_start"])

        self.mock_capi.assert_called_once()
        capi_kwargs = self.mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "StartTrial")
        props = capi_kwargs["properties"]
        self.assertAlmostEqual(props["value"], props["predicted_ltv"] * 0.3, places=6)
        self.assertEqual(props["currency"], "USD")

        events = [call.kwargs.get("event") for call in mock_track_event.call_args_list]
        self.assertIn(AnalyticsEvent.BILLING_TRIAL_STARTED, events)
        identify_args, _identify_kwargs = mock_identify.call_args
        self.assertTrue(identify_args[1].get("is_trial"))

    @tag("batch_pages_signals")
    @override_settings(
        CAPI_LTV_MULTIPLE=5.0,
        CAPI_START_TRIAL_CONV_RATE=0.322,
        CAPI_START_TRIAL_SCALE_CONV_RATE=0.22,
    )
    def test_scale_trialing_subscription_grants_quarter_credits(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="trialing", billing_reason="subscription_create", invoice_id=None)
        event = _build_djstripe_event(payload, event_type="customer.subscription.created")

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=1, subscriber=fresh_user)
        sub.status = "trialing"
        sub.latest_invoice = None
        sub.stripe_data["latest_invoice"] = None

        start_dt = timezone.make_aware(datetime(2025, 9, 1, 8, 0, 0), timezone=dt_timezone.utc)
        end_dt = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        sub.stripe_data["current_period_start"] = str(start_dt)
        sub.stripe_data["current_period_end"] = str(end_dt)
        sub.stripe_data["billing_reason"] = "subscription_create"
        sub.billing_reason = "subscription_create"
        sub.stripe_data["items"]["data"][0]["price"]["product"] = "prod_scale"

        plan_payload = {"id": PlanNamesChoices.SCALE.value, "monthly_task_credits": 10000}

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value=plan_payload), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits") as mock_grant, \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        self.assertTrue(mock_grant.called)
        grant_kwargs = mock_grant.call_args.kwargs
        self.assertEqual(grant_kwargs["credit_override"], Decimal("2500"))
        self.assertTrue(grant_kwargs["invoice_id"].startswith("trial:sub_123"))
        self.assertEqual(grant_kwargs["expiration_date"], end_dt + relativedelta(months=1))
        self.assertTrue(grant_kwargs["free_trial_start"])
        self.mock_capi.assert_called_once()
        capi_kwargs = self.mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "StartTrial")
        props = capi_kwargs["properties"]
        self.assertAlmostEqual(props["predicted_ltv"], 149.95, places=2)
        self.assertAlmostEqual(props["value"], props["predicted_ltv"] * 0.22, places=6)

    @tag("batch_pages_signals")
    def test_trial_conversion_topoffs_credits(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="active", billing_reason="subscription_cycle", invoice_id="in_paid")
        event = _build_djstripe_event(payload, event_type="customer.subscription.updated")

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=8, subscriber=fresh_user)
        sub.status = "active"

        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        period_start = trial_end
        period_end = timezone.make_aware(datetime(2025, 10, 8, 8, 0, 0), timezone=dt_timezone.utc)

        sub.stripe_data["trial_end"] = str(trial_end)
        sub.stripe_data["current_period_start"] = str(period_start)
        sub.stripe_data["current_period_end"] = str(period_end)
        sub.stripe_data["latest_invoice"] = "in_paid"
        sub.stripe_data["billing_reason"] = "subscription_cycle"
        sub.billing_reason = "subscription_cycle"

        plan_payload = {"id": PlanNamesChoices.STARTUP.value, "monthly_task_credits": 300}

        TaskCredit = apps.get_model("api", "TaskCredit")
        TaskCredit.objects.create(
            user=fresh_user,
            credits=300,
            credits_used=200,
            expiration_date=period_end,
            stripe_invoice_id="trial:sub_123:2025-09-01",
            granted_date=period_start,
            plan=PlanNamesChoices.STARTUP,
            grant_type=GrantTypeChoices.PLAN,
            additional_task=False,
        )

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value=plan_payload), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits") as mock_grant, \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        self.assertTrue(mock_grant.called)
        grant_kwargs = mock_grant.call_args.kwargs
        self.assertEqual(grant_kwargs["credit_override"], Decimal(200))
        self.assertEqual(grant_kwargs["invoice_id"], "in_paid")
        self.assertEqual(grant_kwargs["expiration_date"], period_end)
        self.assertFalse(grant_kwargs["free_trial_start"])

    @tag("batch_pages_signals")
    def test_missing_user_billing_logs_exception(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload()
        event = _build_djstripe_event(payload)

        fresh_user = User.objects.get(pk=self.user.pk)
        sub = self._mock_subscription(current_period_day=20, subscriber=fresh_user)

        # Remove billing record to trigger DoesNotExist branch
        UserBilling.objects.filter(user=self.user).delete()
        self.user.__dict__.pop("billing", None)

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan"), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.logger.exception") as mock_logger:

            handle_subscription_event(event)

        mock_logger.assert_called_once()
        self.assertFalse(UserBilling.objects.filter(user=self.user).exists())
        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    def test_subscription_cancellation_updates_plan_trait(self):
        self.mock_capi.reset_mock()
        self.billing.subscription = PlanNames.STARTUP
        self.billing.save(update_fields=["subscription"])
        payload = _build_event_payload(status="canceled")
        event = _build_djstripe_event(payload, event_type="customer.subscription.deleted")

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.status = "canceled"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.downgrade_owner_to_free_plan") as mock_downgrade, \
            patch("pages.signals.DedicatedProxyService.release_for_owner") as mock_release, \
            patch("pages.signals.Analytics.identify") as mock_identify, \
            patch("pages.signals.Analytics.track_event") as mock_track_event:

            handle_subscription_event(event)

        mock_downgrade.assert_called_once_with(self.user)
        mock_release.assert_called_once_with(self.user)

        mock_identify.assert_called_once()
        identify_args, identify_kwargs = mock_identify.call_args
        self.assertEqual(identify_args[0], self.user.id)
        self.assertIn("plan", identify_args[1])
        self.assertEqual(identify_args[1]["plan"], PlanNames.FREE)
        self.assertFalse(identify_args[1]["is_trial"])
        self.assertEqual(identify_kwargs, {})

        subscription_cancelled_call = next(
            call for call in mock_track_event.call_args_list
            if call.kwargs.get("event") == AnalyticsEvent.SUBSCRIPTION_CANCELLED
        )
        track_kwargs = subscription_cancelled_call.kwargs
        self.assertEqual(track_kwargs["properties"]["plan"], PlanNames.FREE)

        self.mock_capi.assert_called_once()
        capi_kwargs = self.mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "CancelSubscription")
        self.assertIsNone(capi_kwargs["request"])
        props = capi_kwargs["properties"]
        self.assertEqual(props["plan"], PlanNames.STARTUP)
        self.assertEqual(props["subscription_id"], "sub_123")
        self.assertEqual(props["status"], "canceled")
        self.assertEqual(props["churn_stage"], "voluntary")
        self.assertNotIn("currency", props)
        self.assertNotIn("value", props)
        self.assertNotIn("event_id", props)
        self.assertTrue(capi_kwargs["context"].get("consent"))

    @tag("batch_pages_signals")
    def test_subscription_cancellation_pauses_owner_execution(self):
        self.mock_capi.reset_mock()
        self.billing.subscription = PlanNames.STARTUP
        self.billing.save(update_fields=["subscription"])

        with patch.object(BrowserUseAgent, "select_random_proxy", return_value=None):
            browser_agent = BrowserUseAgent.objects.create(user=self.user, name="Stripe Browser")
        agent = PersistentAgent.objects.create(
            user=self.user,
            name="Stripe Agent",
            charter="charter",
            browser_use_agent=browser_agent,
        )

        payload = _build_event_payload(status="canceled")
        event = _build_djstripe_event(payload, event_type="customer.subscription.deleted")

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.status = "canceled"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_active_subscription", return_value=None), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.DedicatedProxyService.release_for_owner"), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("api.services.owner_execution_pause.AgentLifecycleService.shutdown") as mock_shutdown:

            handle_subscription_event(event)

        self.billing.refresh_from_db()
        self.assertEqual(self.billing.subscription, PlanNames.FREE)
        self.assertTrue(self.billing.execution_paused)
        self.assertEqual(
            self.billing.execution_pause_reason,
            EXECUTION_PAUSE_REASON_ACCOUNT_CANCELLATION,
        )
        mock_shutdown.assert_called_once()
        self.assertEqual(mock_shutdown.call_args.args[:2], (str(agent.id), "PAUSE"))

    @tag("batch_pages_signals")
    def test_trial_cancel_scheduled_emits_lifecycle_event(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="trialing", billing_reason="subscription_update")
        payload["cancel_at_period_end"] = True
        event = _build_djstripe_event(
            payload,
            event_type="customer.subscription.updated",
            previous_attributes={"cancel_at_period_end": False},
            event_id="evt_trial_cancel_scheduled",
        )

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.status = "trialing"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.emit_billing_lifecycle_event") as mock_emit:

            handle_subscription_event(event)

        emitted_names = [call.args[0] for call in mock_emit.call_args_list]
        self.assertIn("trial_cancel_scheduled", emitted_names)

    @tag("batch_pages_signals")
    def test_subscription_delinquency_entered_emits_lifecycle_event(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="past_due", billing_reason="subscription_update")
        event = _build_djstripe_event(
            payload,
            event_type="customer.subscription.updated",
            previous_attributes={"status": "active"},
            event_id="evt_delinquency_entered",
        )

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.status = "past_due"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.emit_billing_lifecycle_event") as mock_emit:

            handle_subscription_event(event)

        emitted_names = [call.args[0] for call in mock_emit.call_args_list]
        self.assertIn("subscription_delinquency_entered", emitted_names)

    @tag("batch_pages_signals")
    def test_active_subscription_update_resumes_paused_owner(self):
        self.billing.execution_paused = True
        self.billing.execution_pause_reason = "billing_delinquency"
        self.billing.execution_paused_at = timezone.now()
        self.billing.save(
            update_fields=[
                "execution_paused",
                "execution_pause_reason",
                "execution_paused_at",
            ]
        )

        payload = _build_event_payload(status="active", billing_reason="subscription_update")
        event = _build_djstripe_event(
            payload,
            event_type="customer.subscription.updated",
            previous_attributes={"status": "past_due"},
            event_id="evt_recovered_active",
        )

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.status = "active"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.resume_owner_execution", wraps=real_resume_owner_execution) as mock_resume_owner:

            handle_subscription_event(event)

        mock_resume_owner.assert_called_once_with(self.user, source="stripe.customer.subscription.updated")
        self.billing.refresh_from_db()
        self.assertFalse(self.billing.execution_paused)
        self.assertEqual(self.billing.execution_pause_reason, "")
        self.assertIsNone(self.billing.execution_paused_at)

    @tag("batch_pages")
    def test_active_subscription_update_with_pause_collection_keeps_owner_customer_paused(self):
        resume_at = (timezone.now() + timedelta(days=21)).replace(microsecond=0)
        payload = _build_event_payload(status="active", billing_reason="subscription_update")
        payload["pause_collection"] = {
            "behavior": "mark_uncollectible",
            "resumes_at": int(resume_at.timestamp()),
        }
        event = _build_djstripe_event(
            payload,
            event_type="customer.subscription.updated",
            previous_attributes={"pause_collection": None},
            event_id="evt_customer_pause_started",
        )

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.status = "active"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.resume_owner_execution", wraps=real_resume_owner_execution) as mock_resume_owner:

            handle_subscription_event(event)

        mock_resume_owner.assert_not_called()
        self.billing.refresh_from_db()
        self.assertTrue(self.billing.execution_paused)
        self.assertEqual(self.billing.execution_pause_reason, EXECUTION_PAUSE_REASON_CUSTOMER_ACCOUNT_PAUSE)
        self.assertIsNotNone(self.billing.execution_paused_at)
        self.assertEqual(self.billing.execution_pause_resume_at, resume_at)

    @tag("batch_pages")
    def test_active_subscription_update_clears_customer_pause_when_pause_collection_removed(self):
        resume_at = (timezone.now() + timedelta(days=7)).replace(microsecond=0)
        self.billing.execution_paused = True
        self.billing.execution_pause_reason = EXECUTION_PAUSE_REASON_CUSTOMER_ACCOUNT_PAUSE
        self.billing.execution_paused_at = timezone.now()
        self.billing.execution_pause_resume_at = resume_at
        self.billing.save(
            update_fields=[
                "execution_paused",
                "execution_pause_reason",
                "execution_paused_at",
                "execution_pause_resume_at",
            ]
        )

        payload = _build_event_payload(status="active", billing_reason="subscription_update")
        event = _build_djstripe_event(
            payload,
            event_type="customer.subscription.updated",
            previous_attributes={
                "pause_collection": {
                    "behavior": "mark_uncollectible",
                    "resumes_at": int(resume_at.timestamp()),
                }
            },
            event_id="evt_customer_pause_ended",
        )

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.status = "active"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        self.billing.refresh_from_db()
        self.assertFalse(self.billing.execution_paused)
        self.assertEqual(self.billing.execution_pause_reason, "")
        self.assertIsNone(self.billing.execution_paused_at)
        self.assertIsNone(self.billing.execution_pause_resume_at)

    @tag("batch_pages")
    def test_secondary_active_subscription_update_does_not_clear_customer_pause(self):
        resume_at = (timezone.now() + timedelta(days=7)).replace(microsecond=0)
        self.billing.execution_paused = True
        self.billing.execution_pause_reason = EXECUTION_PAUSE_REASON_CUSTOMER_ACCOUNT_PAUSE
        self.billing.execution_paused_at = timezone.now()
        self.billing.execution_pause_resume_at = resume_at
        self.billing.save(
            update_fields=[
                "execution_paused",
                "execution_pause_reason",
                "execution_paused_at",
                "execution_pause_resume_at",
            ]
        )

        payload = _build_event_payload(status="active", billing_reason="subscription_update")
        payload["id"] = "sub_secondary"
        event = _build_djstripe_event(
            payload,
            event_type="customer.subscription.updated",
            previous_attributes={"pause_collection": {"behavior": "mark_uncollectible"}},
            event_id="evt_secondary_active_update",
        )

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.id = "sub_secondary"
        sub.status = "active"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_active_subscription", return_value=SimpleNamespace(id="sub_primary")), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.mark_user_billing_with_plan", wraps=real_mark_user_billing_with_plan), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        self.billing.refresh_from_db()
        self.assertTrue(self.billing.execution_paused)
        self.assertEqual(self.billing.execution_pause_reason, EXECUTION_PAUSE_REASON_CUSTOMER_ACCOUNT_PAUSE)
        self.assertEqual(self.billing.execution_pause_resume_at, resume_at)

    @tag("batch_pages")
    def test_secondary_active_subscription_update_does_not_resume_billing_pause(self):
        self.billing.execution_paused = True
        self.billing.execution_pause_reason = EXECUTION_PAUSE_REASON_BILLING_DELINQUENCY
        self.billing.execution_paused_at = timezone.now()
        self.billing.save(
            update_fields=[
                "execution_paused",
                "execution_pause_reason",
                "execution_paused_at",
            ]
        )

        payload = _build_event_payload(status="active", billing_reason="subscription_update")
        payload["id"] = "sub_secondary"
        event = _build_djstripe_event(
            payload,
            event_type="customer.subscription.updated",
            event_id="evt_secondary_active_billing_pause",
        )

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.id = "sub_secondary"
        sub.status = "active"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_active_subscription", return_value=SimpleNamespace(id="sub_primary")), \
            patch("pages.signals.resume_owner_execution") as mock_resume_owner_execution, \
            patch("pages.signals.mark_user_billing_with_plan") as mock_mark_user_billing_with_plan, \
            patch("pages.signals.TaskCreditService.grant_subscription_credits") as mock_grant_subscription_credits, \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"):

            handle_subscription_event(event)

        self.billing.refresh_from_db()
        self.assertTrue(self.billing.execution_paused)
        self.assertEqual(self.billing.execution_pause_reason, EXECUTION_PAUSE_REASON_BILLING_DELINQUENCY)
        mock_resume_owner_execution.assert_not_called()
        mock_mark_user_billing_with_plan.assert_not_called()
        mock_grant_subscription_credits.assert_not_called()

    @tag("batch_pages_signals")
    def test_trial_ended_non_renewal_emits_lifecycle_event(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="canceled")
        payload["cancel_at_period_end"] = True
        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        payload["trial_end"] = str(trial_end)
        payload["current_period_end"] = str(trial_end)
        event = _build_djstripe_event(
            payload,
            event_type="customer.subscription.deleted",
            previous_attributes={"status": "trialing", "cancel_at_period_end": True},
            event_id="evt_trial_ended_non_renewal",
        )

        sub = self._mock_subscription(current_period_day=8, subscriber=self.user)
        sub.status = "canceled"
        sub.stripe_data = payload

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_active_subscription", return_value=None), \
            patch("pages.signals.downgrade_owner_to_free_plan"), \
            patch("pages.signals.DedicatedProxyService.release_for_owner"), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.emit_billing_lifecycle_event") as mock_emit:

            handle_subscription_event(event)

        emitted_names = [call.args[0] for call in mock_emit.call_args_list]
        self.assertIn("trial_ended_non_renewal", emitted_names)
        self.billing.refresh_from_db()
        self.assertTrue(self.billing.execution_paused)
        self.assertEqual(
            self.billing.execution_pause_reason,
            EXECUTION_PAUSE_REASON_TRIAL_ENDED_NON_RENEWAL,
        )

    @tag("batch_pages_signals")
    def test_dedicated_ip_allocation_from_subscription(self):
        self.mock_capi.reset_mock()
        dedicated_item = {
            "plan": {"usage_type": "licensed"},
            "price": {"id": "price_dedicated", "product": "prod_dedicated"},
            "quantity": 2,
        }
        payload = _build_event_payload(extra_items=[dedicated_item])
        payload["items"]["data"][0]["price"]["id"] = "price_startup"
        payload["items"]["data"][0]["price"]["product"] = "prod_startup"
        event = _build_djstripe_event(payload)

        sub = self._mock_subscription(current_period_day=15, subscriber=self.user)
        sub.stripe_data = payload

        custom_settings = SimpleNamespace(
            org_team_additional_task_price_id="",
            startup_dedicated_ip_price_id="price_dedicated",
            startup_dedicated_ip_product_id="prod_dedicated",
            org_team_dedicated_ip_price_id="",
            org_team_dedicated_ip_product_id="",
        )

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.DedicatedProxyService.allocate_proxy") as mock_allocate, \
            patch("pages.signals.DedicatedProxyService.release_for_owner") as mock_release, \
            patch("pages.signals.get_stripe_settings", return_value=custom_settings):

            handle_subscription_event(event)

        self.assertEqual(mock_allocate.call_count, 2)
        mock_release.assert_not_called()
        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    def test_dedicated_ip_release_on_quantity_decrease(self):
        self.mock_capi.reset_mock()
        proxy = ProxyServer.objects.create(
            name="Dedicated",
            proxy_type=ProxyServer.ProxyType.HTTP,
            host="dedicated.example.com",
            port=8080,
            username="user",
            password="pass",
            static_ip="203.0.113.10",
            is_active=True,
            is_dedicated=True,
        )
        DedicatedProxyAllocation.objects.assign_to_owner(proxy, self.user)

        payload = _build_event_payload()
        payload["items"]["data"][0]["price"]["id"] = "price_startup"
        payload["items"]["data"][0]["price"]["product"] = "prod_startup"
        event = _build_djstripe_event(payload)

        sub = self._mock_subscription(current_period_day=12, subscriber=self.user)
        sub.stripe_data = payload

        custom_settings = SimpleNamespace(
            org_team_additional_task_price_id="",
            startup_dedicated_ip_price_id="price_dedicated",
            startup_dedicated_ip_product_id="prod_dedicated",
            org_team_dedicated_ip_price_id="",
            org_team_dedicated_ip_product_id="",
        )

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.DedicatedProxyService.allocate_proxy") as mock_allocate, \
            patch("pages.signals.DedicatedProxyService.release_for_owner") as mock_release, \
            patch("pages.signals.get_stripe_settings", return_value=custom_settings):

            handle_subscription_event(event)

        mock_allocate.assert_not_called()
        mock_release.assert_called_once()
        self.assertEqual(mock_release.call_args.kwargs.get("limit"), 1)
        self.mock_capi.assert_not_called()

    @tag("batch_pages_signals")
    def test_dedicated_ip_release_on_cancellation(self):
        self.mock_capi.reset_mock()
        payload = _build_event_payload(status="canceled")
        event = _build_djstripe_event(payload, event_type="customer.subscription.deleted")

        sub = self._mock_subscription(current_period_day=10, subscriber=self.user)
        sub.status = "canceled"
        sub.stripe_data = payload

        with patch("pages.signals.Subscription.sync_from_stripe_data", return_value=sub), \
            patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.stripe.Subscription.retrieve") as mock_subscription_retrieve, \
            patch("pages.signals.TaskCreditService.grant_subscription_credits"), \
            patch("pages.signals.Analytics.identify"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.DedicatedProxyService.release_for_owner") as mock_release:

            handle_subscription_event(event)

        mock_release.assert_called_once_with(self.user)
        mock_subscription_retrieve.assert_not_called()
        self.mock_capi.assert_called_once()


@tag("batch_pages_signals")
class SubscriptionSignalOrganizationTests(TestCase):
    def setUp(self):
        owner = User.objects.create_user(username="org-owner", email="org@example.com", password="pw")
        self.owner = owner
        self.org = Organization.objects.create(name="Org", slug="org", created_by=owner)
        billing = self.org.billing
        billing.stripe_customer_id = "cus_org"
        billing.subscription = PlanNamesChoices.ORG_TEAM.value
        billing.save(update_fields=["stripe_customer_id", "subscription"])
        capi_patcher = patch("pages.signals.capi")
        self.addCleanup(capi_patcher.stop)
        self.mock_capi = capi_patcher.start()
        self.mock_capi.reset_mock()
        patcher = patch("pages.signals.stripe.Subscription.retrieve")
        self.addCleanup(patcher.stop)
        self.mock_subscription_retrieve = patcher.start()
        self.mock_subscription_retrieve.return_value = {
            "items": {"data": []},
            "metadata": {},
        }

    def _mock_subscription(self, *, quantity, billing_reason, payload_invoice="in_org"):
        aware_start = timezone.make_aware(datetime(2025, 9, 1, 0, 0, 0), timezone=dt_timezone.utc)
        aware_end = timezone.make_aware(datetime(2025, 10, 1, 0, 0, 0), timezone=dt_timezone.utc)
        sub = MagicMock()
        sub.status = "active"
        sub.id = "sub_org"
        sub.customer = SimpleNamespace(id="cus_org", subscriber=None)
        sub.billing_reason = billing_reason
        payload = _build_event_payload(
            invoice_id=payload_invoice,
            quantity=quantity,
            billing_reason=billing_reason,
            product="prod_org",
        )
        sub.stripe_data = payload
        sub.stripe_data['current_period_start'] = aware_start
        sub.stripe_data['current_period_end'] = aware_end
        sub.stripe_data['cancel_at'] = None
        sub.stripe_data['cancel_at_period_end'] = False

        return sub, payload

    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_cancellation_pauses_organization_execution(self, mock_sync):
        billing = self.org.billing
        billing.purchased_seats = 2
        billing.stripe_subscription_id = "sub_org"
        billing.cancel_at_period_end = True
        billing.save(update_fields=["purchased_seats", "stripe_subscription_id", "cancel_at_period_end"])

        with patch.object(BrowserUseAgent, "select_random_proxy", return_value=None):
            browser_agent = BrowserUseAgent.objects.create(user=self.owner, name="Org Browser")
        agent = PersistentAgent.objects.create(
            user=self.owner,
            organization=self.org,
            name="Org Agent",
            charter="charter",
            browser_use_agent=browser_agent,
        )

        sub, payload = self._mock_subscription(quantity=2, billing_reason=None)
        sub.status = "canceled"
        payload["status"] = "canceled"
        sub.stripe_data = payload
        mock_sync.return_value = sub
        event = _build_djstripe_event(payload, event_type="customer.subscription.deleted")

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.get_active_subscription", return_value=None), \
            patch("pages.signals.DedicatedProxyService.release_for_owner"), \
            patch("pages.signals.Analytics.track_event"), \
            patch("api.services.owner_execution_pause.AgentLifecycleService.shutdown") as mock_shutdown:

            handle_subscription_event(event)

        billing.refresh_from_db()
        self.assertEqual(billing.subscription, PlanNames.FREE)
        self.assertEqual(billing.purchased_seats, 0)
        self.assertIsNone(billing.stripe_subscription_id)
        self.assertFalse(billing.cancel_at_period_end)
        self.assertTrue(billing.execution_paused)
        self.assertEqual(
            billing.execution_pause_reason,
            EXECUTION_PAUSE_REASON_ACCOUNT_CANCELLATION,
        )
        mock_shutdown.assert_called_once()
        self.assertEqual(mock_shutdown.call_args.args[:2], (str(agent.id), "PAUSE"))

    @patch("pages.signals.stripe.SubscriptionItem.create")
    @patch("pages.signals.TaskCreditService.grant_subscription_credits_for_organization")
    @patch("pages.signals.get_plan_by_product_id")
    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_create_sets_seats_and_grants(self, mock_sync, mock_plan, mock_grant, mock_item_create):
        self.mock_capi.reset_mock()
        sub, payload = self._mock_subscription(quantity=2, billing_reason=None)
        mock_sync.return_value = sub
        mock_plan.return_value = {"id": PlanNamesChoices.ORG_TEAM.value, "credits_per_seat": 500}
        event = _build_djstripe_event(payload, event_type="customer.subscription.created")

        invoice_payload = {
            "id": payload["latest_invoice"],
            "object": "invoice",
            "billing_reason": "subscription_create",
        }
        invoice_obj = SimpleNamespace(billing_reason="subscription_create", stripe_data=invoice_payload)

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.stripe.Invoice.retrieve", return_value=invoice_payload) as mock_invoice_retrieve, \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj) as mock_invoice_sync:

            handle_subscription_event(event)

        mock_invoice_retrieve.assert_called_once_with(payload["latest_invoice"])
        mock_invoice_sync.assert_called_once()

        billing = self.org.billing
        billing.refresh_from_db()
        self.assertEqual(billing.purchased_seats, 2)
        mock_grant.assert_called_once()
        _, kwargs = mock_grant.call_args
        self.assertEqual(kwargs.get("seats"), 2)
        self.assertEqual(kwargs.get("invoice_id"), invoice_payload["id"])
        self.mock_capi.assert_not_called()

    @patch("pages.signals.stripe.SubscriptionItem.create")
    @patch("pages.signals.TaskCreditService.grant_subscription_credits_for_organization")
    @patch("pages.signals.get_plan_by_product_id")
    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_create_with_existing_seats_grants_delta(self, mock_sync, mock_plan, mock_grant, mock_item_create):
        self.mock_capi.reset_mock()
        billing = self.org.billing
        billing.purchased_seats = 3
        billing.save(update_fields=["purchased_seats"])

        sub, payload = self._mock_subscription(quantity=5, billing_reason=None, payload_invoice="in_seat_add")
        mock_sync.return_value = sub
        mock_plan.return_value = {"id": PlanNamesChoices.ORG_TEAM.value, "credits_per_seat": 500}
        event = _build_djstripe_event(payload, event_type="customer.subscription.created")

        invoice_payload = {
            "id": payload["latest_invoice"],
            "object": "invoice",
            "billing_reason": "subscription_create",
        }
        invoice_obj = SimpleNamespace(billing_reason="subscription_create", stripe_data=invoice_payload)

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.stripe.Invoice.retrieve", return_value=invoice_payload), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj):

            handle_subscription_event(event)

        billing.refresh_from_db()
        self.assertEqual(billing.purchased_seats, 5)
        mock_grant.assert_called_once()
        _, kwargs = mock_grant.call_args
        self.assertEqual(kwargs.get("seats"), 2)
        self.assertEqual(kwargs.get("invoice_id"), "")
        self.mock_capi.assert_not_called()

    @patch("pages.signals.stripe.SubscriptionItem.create")
    @patch("pages.signals.TaskCreditService.grant_subscription_credits_for_organization")
    @patch("pages.signals.get_plan_by_product_id")
    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_update_grants_difference(self, mock_sync, mock_plan, mock_grant, mock_item_create):
        self.mock_capi.reset_mock()
        billing = self.org.billing
        billing.purchased_seats = 2
        billing.save(update_fields=["purchased_seats"])

        sub, payload = self._mock_subscription(quantity=3, billing_reason=None, payload_invoice="in_upgrade")
        mock_sync.return_value = sub
        mock_plan.return_value = {"id": PlanNamesChoices.ORG_TEAM.value, "credits_per_seat": 500}
        event = _build_djstripe_event(payload)

        invoice_payload = {
            "id": payload["latest_invoice"],
            "object": "invoice",
            "billing_reason": "subscription_update",
        }
        invoice_obj = SimpleNamespace(billing_reason="subscription_update", stripe_data=invoice_payload)

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.stripe.Invoice.retrieve", return_value=invoice_payload) as mock_invoice_retrieve, \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj):

            handle_subscription_event(event)

        mock_invoice_retrieve.assert_called_once()

        billing.refresh_from_db()
        self.assertEqual(billing.purchased_seats, 3)
        mock_grant.assert_called_once()
        _, kwargs = mock_grant.call_args
        self.assertEqual(kwargs.get("seats"), 1)
        self.assertEqual(kwargs.get("invoice_id"), "")
        self.mock_capi.assert_not_called()

    @patch("pages.signals.stripe.SubscriptionItem.create")
    @patch("pages.signals.TaskCreditService.grant_subscription_credits_for_organization")
    @patch("pages.signals.get_plan_by_product_id")
    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_update_decrease_no_grant(self, mock_sync, mock_plan, mock_grant, mock_item_create):
        self.mock_capi.reset_mock()
        billing = self.org.billing
        billing.purchased_seats = 3
        billing.save(update_fields=["purchased_seats"])

        sub, payload = self._mock_subscription(quantity=1, billing_reason=None, payload_invoice="in_downgrade")
        mock_sync.return_value = sub
        mock_plan.return_value = {"id": PlanNamesChoices.ORG_TEAM.value, "credits_per_seat": 500}
        event = _build_djstripe_event(payload)

        invoice_payload = {
            "id": payload["latest_invoice"],
            "object": "invoice",
            "billing_reason": "subscription_update",
        }
        invoice_obj = SimpleNamespace(billing_reason="subscription_update", stripe_data=invoice_payload)

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.stripe.Invoice.retrieve", return_value=invoice_payload), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj):

            handle_subscription_event(event)

        billing.refresh_from_db()
        self.assertEqual(billing.purchased_seats, 1)
        mock_grant.assert_not_called()
        self.mock_capi.assert_not_called()

    @patch("pages.signals.stripe.SubscriptionItem.create")
    @patch("pages.signals.TaskCreditService.grant_subscription_credits_for_organization")
    @patch("pages.signals.get_plan_by_product_id")
    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_cycle_renews_with_replace_current(self, mock_sync, mock_plan, mock_grant, mock_item_create):
        self.mock_capi.reset_mock()
        billing = self.org.billing
        billing.purchased_seats = 3
        billing.billing_cycle_anchor = 17
        billing.save(update_fields=["purchased_seats", "billing_cycle_anchor"])

        sub, payload = self._mock_subscription(quantity=3, billing_reason="subscription_cycle", payload_invoice="in_cycle")
        mock_sync.return_value = sub
        mock_plan.return_value = {"id": PlanNamesChoices.ORG_TEAM.value, "credits_per_seat": 500}
        event = _build_djstripe_event(payload)

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.stripe.Invoice.retrieve") as mock_invoice_retrieve, \
            patch("pages.signals.Invoice.sync_from_stripe_data") as mock_invoice_sync:

            handle_subscription_event(event)

        mock_invoice_retrieve.assert_not_called()
        mock_invoice_sync.assert_not_called()

        billing.refresh_from_db()
        self.assertEqual(billing.purchased_seats, 3)
        self.assertEqual(billing.billing_cycle_anchor, 1)

        mock_plan.assert_called_once()
        mock_grant.assert_called_once()
        call_args, call_kwargs = mock_grant.call_args
        self.assertEqual(call_args[0], self.org)
        self.assertEqual(call_kwargs.get("seats"), 3)
        self.assertEqual(call_kwargs.get("invoice_id"), payload["latest_invoice"])
        self.assertTrue(call_kwargs.get("replace_current"))
        self.assertIs(call_kwargs.get("subscription"), sub)
        self.mock_capi.assert_not_called()

    @patch("pages.signals.TaskCreditService.grant_subscription_credits_for_organization")
    @patch("pages.signals.get_plan_by_product_id")
    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_adds_overage_item_when_missing(self, mock_sync, mock_plan, mock_grant):
        self.mock_capi.reset_mock()
        sub, payload = self._mock_subscription(quantity=2, billing_reason="subscription_update")
        payload["items"]["data"][0]["price"]["id"] = "price_org_team"
        mock_sync.return_value = sub
        mock_plan.return_value = {"id": PlanNamesChoices.ORG_TEAM.value, "credits_per_seat": 500}

        event = _build_djstripe_event(payload)

        custom_settings = SimpleNamespace(
            org_team_additional_task_price_id="price_overage",
            startup_dedicated_ip_price_id="",
            startup_dedicated_ip_product_id="",
            org_team_dedicated_ip_price_id="",
            org_team_dedicated_ip_product_id="",
        )

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.get_stripe_settings", return_value=custom_settings), \
            patch("pages.signals.stripe.Subscription.retrieve", return_value={"items": {"data": payload["items"]["data"]}}) as mock_sub_retrieve, \
            patch("pages.signals.stripe.SubscriptionItem.create") as mock_item_create:

            handle_subscription_event(event)

        mock_sub_retrieve.assert_called_once_with(sub.id, expand=["items.data.price"])
        mock_item_create.assert_called_once_with(subscription=sub.id, price="price_overage")
        mock_grant.assert_called_once()
        self.mock_capi.assert_not_called()

    @patch("pages.signals.TaskCreditService.grant_subscription_credits_for_organization")
    @patch("pages.signals.get_plan_by_product_id")
    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_skips_overage_item_when_present(self, mock_sync, mock_plan, mock_grant):
        self.mock_capi.reset_mock()
        sub, payload = self._mock_subscription(quantity=2, billing_reason="subscription_update")
        payload_items = payload["items"]["data"]
        payload_items[0]["price"]["id"] = "price_org_team"
        payload_items.append({
            "plan": {"usage_type": "metered"},
            "price": {"id": "price_overage"},
            "quantity": None,
        })

        mock_sync.return_value = sub
        mock_plan.return_value = {"id": PlanNamesChoices.ORG_TEAM.value, "credits_per_seat": 500}

        event = _build_djstripe_event(payload)

        custom_settings = SimpleNamespace(
            org_team_additional_task_price_id="price_overage",
            startup_dedicated_ip_price_id="",
            startup_dedicated_ip_product_id="",
            org_team_dedicated_ip_price_id="",
            org_team_dedicated_ip_product_id="",
        )

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.get_stripe_settings", return_value=custom_settings), \
            patch("pages.signals.stripe.SubscriptionItem.create") as mock_item_create:

            handle_subscription_event(event)

        mock_item_create.assert_not_called()
        self.mock_capi.assert_not_called()

    @patch("pages.signals.TaskCreditService.grant_subscription_credits_for_organization")
    @patch("pages.signals.get_plan_by_product_id")
    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_detach_pending_skips_overage_create(self, mock_sync, mock_plan, mock_grant):
        self.mock_capi.reset_mock()
        sub, payload = self._mock_subscription(quantity=2, billing_reason="subscription_update")
        payload["items"]["data"][0]["price"]["id"] = "price_org_team"
        payload["metadata"] = {ORG_OVERAGE_STATE_META_KEY: ORG_OVERAGE_STATE_DETACHED_PENDING}

        mock_sync.return_value = sub
        mock_plan.return_value = {"id": PlanNamesChoices.ORG_TEAM.value, "credits_per_seat": 500}

        billing = self.org.billing
        billing.purchased_seats = 2
        billing.save(update_fields=["purchased_seats"])

        event = _build_djstripe_event(payload)

        custom_settings = SimpleNamespace(
            org_team_additional_task_price_id="price_overage",
            startup_dedicated_ip_price_id="",
            startup_dedicated_ip_product_id="",
            org_team_dedicated_ip_price_id="",
            org_team_dedicated_ip_product_id="",
        )

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.get_stripe_settings", return_value=custom_settings), \
            patch("pages.signals.stripe.SubscriptionItem.create") as mock_item_create, \
            patch("pages.signals.stripe.Subscription.modify") as mock_modify:

            handle_subscription_event(event)

        mock_item_create.assert_not_called()
        mock_modify.assert_not_called()
        self.mock_capi.assert_not_called()

    @patch("pages.signals.TaskCreditService.grant_subscription_credits_for_organization")
    @patch("pages.signals.get_plan_by_product_id")
    @patch("pages.signals.Subscription.sync_from_stripe_data")
    def test_subscription_detach_pending_clears_flag_when_item_present(self, mock_sync, mock_plan, mock_grant):
        self.mock_capi.reset_mock()
        sub, payload = self._mock_subscription(quantity=2, billing_reason="subscription_update")
        payload_items = payload["items"]["data"]
        payload_items[0]["price"]["id"] = "price_org_team"
        payload_items.append({
            "plan": {"usage_type": "metered"},
            "price": {"id": "price_overage"},
            "quantity": None,
        })
        payload["metadata"] = {ORG_OVERAGE_STATE_META_KEY: ORG_OVERAGE_STATE_DETACHED_PENDING}

        mock_sync.return_value = sub
        mock_plan.return_value = {"id": PlanNamesChoices.ORG_TEAM.value, "credits_per_seat": 500}

        billing = self.org.billing
        billing.purchased_seats = 2
        billing.save(update_fields=["purchased_seats"])

        event = _build_djstripe_event(payload)

        custom_settings = SimpleNamespace(
            org_team_additional_task_price_id="price_overage",
            startup_dedicated_ip_price_id="",
            startup_dedicated_ip_product_id="",
            org_team_dedicated_ip_price_id="",
            org_team_dedicated_ip_product_id="",
        )

        with patch("pages.signals.PaymentsHelper.get_stripe_key"), \
            patch("pages.signals.get_stripe_settings", return_value=custom_settings), \
            patch("pages.signals.stripe.SubscriptionItem.create") as mock_item_create, \
            patch("pages.signals.stripe.Subscription.modify") as mock_modify:

            handle_subscription_event(event)

        mock_item_create.assert_not_called()
        mock_modify.assert_called_once_with(sub.id, metadata={ORG_OVERAGE_STATE_META_KEY: ""})
        self.mock_capi.assert_not_called()


@tag("batch_pages_signals")
class PaymentFailedSignalTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(username="fail-user", email="fail@example.com", password="pw")

    def test_invoice_payment_failed_for_user_tracks_event(self):
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            attempt_count=2,
            next_payment_attempt=None,
            auto_advance=False,
            amount_due=2500,
            payment_intent={
                "id": "pi_user_fail",
                "payment_method_types": ["card"],
                "last_payment_error": {
                    "type": "card_error",
                    "code": "card_declined",
                    "decline_code": "insufficient_funds",
                    "message": "Your card has insufficient funds.",
                    "payment_method": {"type": "card"},
                    "charge": "ch_user_fail",
                },
            },
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user"),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value=None), \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_failed(event)

        mock_track_anonymous.assert_not_called()
        mock_capi.assert_not_called()
        mock_track_event.assert_called_once()
        kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(kwargs["user_id"], self.user.id)
        self.assertEqual(kwargs["event"], AnalyticsEvent.BILLING_PAYMENT_FAILED)
        props = kwargs["properties"]
        self.assertEqual(props["attempt_number"], 2)
        self.assertTrue(props["final_attempt"])
        self.assertEqual(props["stripe.invoice_id"], payload["id"])
        self.assertEqual(props["stripe.subscription_id"], payload["subscription"])
        self.assertEqual(props["stripe.payment_intent_id"], "pi_user_fail")
        self.assertEqual(props["stripe.charge_id"], "ch_user_fail")
        self.assertEqual(props["failure_reason"], "Your card has insufficient funds.")
        self.assertEqual(props["failure_message"], "Your card has insufficient funds.")
        self.assertEqual(props["failure_code"], "card_declined")
        self.assertEqual(props["decline_code"], "insufficient_funds")
        self.assertEqual(props["failure_type"], "card_error")
        self.assertEqual(props["payment_method_type"], "card")
        self.assertFalse(props["trial_conversion_invoice"])

    def test_invoice_payment_failed_emits_trial_conversion_failed_lifecycle_event(self):
        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            attempt_count=1,
            next_payment_attempt=timezone.now().timestamp() + 3600,
            auto_advance=True,
            amount_due=2500,
            billing_reason="subscription_cycle",
            payment_intent={
                "id": "pi_trial_fail",
                "payment_method_types": ["card"],
                "last_payment_error": {
                    "type": "card_error",
                    "code": "card_declined",
                    "decline_code": "do_not_honor",
                    "message": "The card was declined.",
                    "payment_method": {"type": "card"},
                    "charge": "ch_trial_fail",
                },
            },
        )
        payload["lines"]["data"][0]["period"] = {
            "start": int(trial_end.timestamp()),
            "end": int((trial_end + timedelta(days=30)).timestamp()),
        }
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed", event_id="evt_trial_fail")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(
                id="sub_user",
                stripe_data={
                    "status": "past_due",
                    "trial_end": str(trial_end),
                    "current_period_start": str(trial_end),
                },
            ),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous"), \
            patch("pages.signals.get_plan_by_product_id", return_value=None), \
            patch("pages.signals.emit_billing_lifecycle_event") as mock_emit, \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_failed(event)

        emitted_names = [call.args[0] for call in mock_emit.call_args_list]
        self.assertIn("trial_conversion_failed", emitted_names)
        self.assertEqual(mock_capi.call_args.kwargs["event_name"], "TrialConversionPaymentFailed")
        track_kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(track_kwargs["event"], AnalyticsEvent.BILLING_PAYMENT_FAILED)
        self.assertTrue(track_kwargs["properties"]["trial_conversion_invoice"])
        self.assertEqual(track_kwargs["properties"]["attempt_number"], 1)
        lifecycle_payload = mock_emit.call_args.kwargs["payload"]
        self.assertEqual(lifecycle_payload.metadata["stripe.invoice_id"], payload["id"])
        self.assertEqual(lifecycle_payload.metadata["stripe.payment_intent_id"], "pi_trial_fail")
        self.assertEqual(lifecycle_payload.metadata["stripe.charge_id"], "ch_trial_fail")
        self.assertEqual(lifecycle_payload.metadata["amount_due"], 25.0)
        self.assertEqual(lifecycle_payload.metadata["currency"], "USD")
        self.assertEqual(lifecycle_payload.metadata["failure_reason"], "The card was declined.")
        self.assertEqual(lifecycle_payload.metadata["failure_code"], "card_declined")
        self.assertEqual(lifecycle_payload.metadata["decline_code"], "do_not_honor")
        self.assertEqual(lifecycle_payload.metadata["payment_method_type"], "card")
        self.assertTrue(lifecycle_payload.metadata["trial_conversion_invoice"])
        self.assertFalse(lifecycle_payload.metadata["organization"])

    def test_invoice_payment_failed_retry_on_trial_conversion_invoice_tracks_flag_without_lifecycle_event(self):
        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            attempt_count=2,
            next_payment_attempt=timezone.now().timestamp() + 3600,
            auto_advance=True,
            amount_due=2500,
            billing_reason="subscription_cycle",
            payment_intent={
                "id": "pi_trial_retry",
                "payment_method_types": ["card"],
                "last_payment_error": {
                    "type": "card_error",
                    "code": "card_declined",
                    "decline_code": "try_again_later",
                    "message": "Your card was declined.",
                    "payment_method": {"type": "card"},
                    "charge": "ch_trial_retry",
                },
            },
        )
        payload["lines"]["data"][0]["period"] = {
            "start": int(trial_end.timestamp()),
            "end": int((trial_end + timedelta(days=30)).timestamp()),
        }
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed", event_id="evt_trial_retry")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(
                id="sub_user",
                stripe_data={
                    "status": "past_due",
                    "trial_end": str(trial_end),
                    "current_period_start": str(trial_end),
                },
            ),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous"), \
            patch("pages.signals.get_plan_by_product_id", return_value=None), \
            patch("pages.signals.emit_billing_lifecycle_event") as mock_emit, \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_failed(event)

        mock_emit.assert_not_called()
        self.assertEqual(mock_capi.call_args.kwargs["event_name"], "TrialConversionPaymentFailed")
        mock_track_event.assert_called_once()
        props = mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(mock_track_event.call_args.kwargs["event"], AnalyticsEvent.BILLING_PAYMENT_FAILED)
        self.assertTrue(props["trial_conversion_invoice"])
        self.assertEqual(props["attempt_number"], 2)

    def test_invoice_payment_failed_fetches_reason_from_stripe_when_webhook_has_only_ids(self):
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            payment_intent="pi_sparse_fail",
        )
        payload["charge"] = "ch_sparse_fail"
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user"),
            number=payload["number"],
        )

        payment_intent_obj = {
            "id": "pi_sparse_fail",
            "latest_charge": "ch_sparse_fail",
            "payment_method_types": ["card"],
            "last_payment_error": {
                "type": "card_error",
                "code": "card_declined",
                "decline_code": "insufficient_funds",
                "message": "Your card has insufficient funds.",
                "payment_method": {"type": "card"},
                "charge": "ch_sparse_fail",
            },
        }
        charge_obj = {
            "id": "ch_sparse_fail",
            "failure_code": "card_declined",
            "failure_message": "Your card has insufficient funds.",
            "outcome": {"reason": "insufficient_funds"},
        }

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.stripe.PaymentIntent.retrieve", return_value=payment_intent_obj) as mock_pi_retrieve, \
            patch("pages.signals.stripe.Charge.retrieve", return_value=charge_obj) as mock_charge_retrieve, \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value=None):

            handle_invoice_payment_failed(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        mock_pi_retrieve.assert_called_once_with("pi_sparse_fail")
        mock_charge_retrieve.assert_not_called()
        props = mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(props["stripe.payment_intent_id"], "pi_sparse_fail")
        self.assertEqual(props["stripe.charge_id"], "ch_sparse_fail")
        self.assertEqual(props["failure_reason"], "Your card has insufficient funds.")
        self.assertEqual(props["failure_message"], "Your card has insufficient funds.")
        self.assertEqual(props["failure_code"], "card_declined")
        self.assertEqual(props["decline_code"], "insufficient_funds")
        self.assertEqual(props["payment_method_type"], "card")

    def test_invoice_payment_failed_prefers_djstripe_payment_intent_for_reason(self):
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            payment_intent="pi_local_fail",
        )
        payload["charge"] = "ch_local_fail"
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user"),
            number=payload["number"],
        )

        payment_intent_obj = {
            "id": "pi_local_fail",
            "latest_charge": "ch_local_fail",
            "payment_method_types": ["card"],
            "last_payment_error": {
                "type": "card_error",
                "code": "card_declined",
                "decline_code": "try_again_later",
                "message": "Your card was declined.",
                "payment_method": {"type": "card"},
                "charge": "ch_local_fail",
            },
        }

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals._get_djstripe_payment_intent_data", return_value=payment_intent_obj) as mock_local_pi, \
            patch("pages.signals._get_djstripe_charge_data", return_value={}) as mock_local_charge, \
            patch("pages.signals.stripe.PaymentIntent.retrieve") as mock_pi_retrieve, \
            patch("pages.signals.stripe.Charge.retrieve") as mock_charge_retrieve, \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value=None):

            handle_invoice_payment_failed(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        mock_local_pi.assert_called_once_with("pi_local_fail")
        mock_local_charge.assert_not_called()
        mock_pi_retrieve.assert_not_called()
        mock_charge_retrieve.assert_not_called()
        props = mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(props["stripe.payment_intent_id"], "pi_local_fail")
        self.assertEqual(props["stripe.charge_id"], "ch_local_fail")
        self.assertEqual(props["failure_reason"], "Your card was declined.")
        self.assertEqual(props["failure_message"], "Your card was declined.")
        self.assertEqual(props["failure_code"], "card_declined")
        self.assertEqual(props["decline_code"], "try_again_later")
        self.assertEqual(props["payment_method_type"], "card")

    def test_invoice_payment_failed_prefers_djstripe_charge_for_reason_when_webhook_has_only_ids(self):
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            payment_intent="pi_local_charge_fail",
        )
        payload["charge"] = "ch_local_charge_fail"
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user"),
            number=payload["number"],
        )

        payment_intent_obj = {
            "id": "pi_local_charge_fail",
            "latest_charge": "ch_local_charge_fail",
            "payment_method_types": ["card"],
        }
        charge_obj = {
            "id": "ch_local_charge_fail",
            "failure_code": "card_declined",
            "failure_message": "Your card has insufficient funds.",
            "outcome": {"reason": "insufficient_funds"},
        }

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals._get_djstripe_payment_intent_data", return_value=payment_intent_obj) as mock_local_pi, \
            patch("pages.signals._get_djstripe_charge_data", return_value=charge_obj) as mock_local_charge, \
            patch("pages.signals.stripe.PaymentIntent.retrieve") as mock_pi_retrieve, \
            patch("pages.signals.stripe.Charge.retrieve") as mock_charge_retrieve, \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value=None):

            handle_invoice_payment_failed(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        mock_local_pi.assert_called_once_with("pi_local_charge_fail")
        mock_local_charge.assert_called_once_with("ch_local_charge_fail")
        mock_pi_retrieve.assert_not_called()
        mock_charge_retrieve.assert_not_called()
        props = mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(props["stripe.payment_intent_id"], "pi_local_charge_fail")
        self.assertEqual(props["stripe.charge_id"], "ch_local_charge_fail")
        self.assertEqual(props["failure_reason"], "Your card has insufficient funds.")
        self.assertEqual(props["failure_message"], "Your card has insufficient funds.")
        self.assertEqual(props["failure_code"], "card_declined")
        self.assertEqual(props["decline_code"], "insufficient_funds")
        self.assertEqual(props["payment_method_type"], "card")

    def test_invoice_payment_failed_tracks_with_fallback_properties_when_enrichment_fails(self):
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            attempt_count=3,
            amount_due=2500,
            status="open",
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user"),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals._build_invoice_properties", side_effect=RuntimeError("boom")), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value=None):

            handle_invoice_payment_failed(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        props = mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(props["stripe.invoice_id"], payload["id"])
        self.assertEqual(props["stripe.subscription_id"], payload["subscription"])
        self.assertEqual(props["stripe.customer_id"], payload["customer"])
        self.assertEqual(props["amount_due"], 25.0)
        self.assertEqual(props["attempt_number"], 3)
        self.assertFalse(props["trial_conversion_invoice"])

    def test_invoice_payment_succeeded_tracks_with_fallback_properties_when_enrichment_fails(self):
        payload = _build_invoice_payload(
            customer_id="cus_user_succeeded",
            subscription_id="sub_user_succeeded",
            amount_due=3000,
            amount_paid=3000,
            status="paid",
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user_succeeded", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user_succeeded"),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals._build_invoice_properties", side_effect=RuntimeError("boom")), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value=None):

            handle_invoice_payment_succeeded(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        props = mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(props["stripe.invoice_id"], payload["id"])
        self.assertEqual(props["stripe.subscription_id"], payload["subscription"])
        self.assertEqual(props["stripe.customer_id"], payload["customer"])
        self.assertEqual(props["amount_due"], 30.0)
        self.assertEqual(props["amount_paid"], 30.0)

    def test_invoice_payment_failed_emits_trial_conversion_failure_capi_before_final_attempt(self):
        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            attempt_count=1,
            next_payment_attempt=timezone.now().timestamp() + 3600,
            auto_advance=True,
            amount_due=2500,
            billing_reason="subscription_cycle",
        )
        payload["lines"]["data"][0]["period"] = {
            "start": int(trial_end.timestamp()),
            "end": int((trial_end + timedelta(days=30)).timestamp()),
        }
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed", event_id="evt_trial_retryable")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(
                id="sub_user",
                stripe_data={
                    "status": "past_due",
                    "trial_end": str(trial_end),
                    "current_period_start": str(trial_end),
                },
            ),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.Analytics.track_event_anonymous"), \
            patch("pages.signals.get_plan_by_product_id", return_value=None), \
            patch("pages.signals.emit_billing_lifecycle_event"), \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_failed(event)

        mock_capi.assert_called_once()
        capi_kwargs = mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "TrialConversionPaymentFailed")
        self.assertEqual(capi_kwargs["provider_targets"], ["meta", "reddit", "tiktok"])
        props = capi_kwargs["properties"]
        self.assertFalse(props["final_attempt"])
        self.assertTrue(props["trial_conversion_invoice"])

    def test_invoice_payment_failed_emits_trial_conversion_failure_capi_for_final_attempt(self):
        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            attempt_count=2,
            next_payment_attempt=None,
            auto_advance=False,
            amount_due=2500,
            billing_reason="subscription_cycle",
        )
        payload["lines"]["data"][0]["period"] = {
            "start": int(trial_end.timestamp()),
            "end": int((trial_end + timedelta(days=30)).timestamp()),
        }
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed", event_id="evt_trial_terminal")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(
                id="sub_user",
                stripe_data={
                    "status": "past_due",
                    "trial_end": str(trial_end),
                    "current_period_start": str(trial_end),
                    "metadata": {"checkout_source_url": "https://gobii.ai/pricing"},
                },
            ),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.Analytics.track_event_anonymous"), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.emit_billing_lifecycle_event") as mock_emit, \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_failed(event)

        mock_emit.assert_not_called()
        mock_capi.assert_called_once()
        capi_kwargs = mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "TrialConversionPaymentFailedFinal")
        self.assertEqual(capi_kwargs["provider_targets"], ["meta", "reddit", "tiktok"])
        self.assertEqual(capi_kwargs["context"]["page"]["url"], "https://gobii.ai/pricing")
        props = capi_kwargs["properties"]
        self.assertEqual(props["plan"], PlanNamesChoices.STARTUP.value)
        self.assertEqual(props["subscription_id"], "sub_user")
        self.assertEqual(props["stripe.invoice_id"], payload["id"])
        self.assertEqual(props["event_id"], "evt_trial_terminal")
        self.assertEqual(props["attempt_number"], 2)
        self.assertTrue(props["final_attempt"])
        self.assertTrue(props["trial_conversion_invoice"])
        self.assertEqual(props["value"], 25.0)
        self.assertEqual(props["amount_due"], 25.0)
        self.assertEqual(props["currency"], "USD")

    def test_invoice_payment_failed_still_emits_trial_conversion_failure_capi_when_lifecycle_emit_fails(self):
        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            attempt_count=1,
            next_payment_attempt=None,
            auto_advance=False,
            amount_due=2500,
            billing_reason="subscription_cycle",
        )
        payload["lines"]["data"][0]["period"] = {
            "start": int(trial_end.timestamp()),
            "end": int((trial_end + timedelta(days=30)).timestamp()),
        }
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed", event_id="evt_trial_both")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(
                id="sub_user",
                stripe_data={
                    "status": "past_due",
                    "trial_end": str(trial_end),
                    "current_period_start": str(trial_end),
                },
            ),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.Analytics.track_event_anonymous"), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.emit_billing_lifecycle_event", side_effect=RuntimeError("boom")), \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_failed(event)

        mock_capi.assert_called_once()
        self.assertEqual(
            mock_capi.call_args.kwargs["event_name"],
            "TrialConversionPaymentFailedFinal",
        )

    def test_invoice_payment_failed_emits_subscription_payment_failed_capi_for_retryable_subscription_failure(self):
        payload = _build_invoice_payload(
            customer_id="cus_user",
            subscription_id="sub_user",
            attempt_count=2,
            next_payment_attempt=timezone.now().timestamp() + 3600,
            auto_advance=True,
            amount_due=2500,
            billing_reason="subscription_cycle",
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed", event_id="evt_subscription_retry")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user", subscriber=self.user),
            subscription=SimpleNamespace(
                id="sub_user",
                stripe_data={
                    "status": "past_due",
                },
            ),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.Analytics.track_event_anonymous"), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.emit_billing_lifecycle_event") as mock_emit, \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_failed(event)

        mock_emit.assert_not_called()
        mock_capi.assert_called_once()
        capi_kwargs = mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "SubscriptionPaymentFailed")
        self.assertEqual(capi_kwargs["provider_targets"], ["meta", "reddit", "tiktok"])
        props = capi_kwargs["properties"]
        self.assertEqual(props["plan"], PlanNamesChoices.STARTUP.value)
        self.assertEqual(props["subscription_id"], "sub_user")
        self.assertEqual(props["stripe.invoice_id"], payload["id"])
        self.assertEqual(props["event_id"], "evt_subscription_retry")
        self.assertEqual(props["attempt_number"], 2)
        self.assertFalse(props["final_attempt"])
        self.assertFalse(props["trial_conversion_invoice"])
        self.assertEqual(props["value"], 25.0)
        self.assertEqual(props["currency"], "USD")

    def test_invoice_payment_failed_for_org_tracks_creator(self):
        owner = User.objects.create_user(username="org-owner-fail", email="org-fail@example.com", password="pw")
        org = Organization.objects.create(name="Fail Org", slug="fail-org", created_by=owner)
        billing = org.billing
        billing.stripe_customer_id = "cus_org_fail"
        billing.save(update_fields=["stripe_customer_id"])

        payload = _build_invoice_payload(
            customer_id="cus_org_fail",
            subscription_id="sub_org",
            attempt_count=1,
            next_payment_attempt=timezone.now().timestamp() + 3600,
            auto_advance=True,
            product_id="prod_org",
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_org_fail", subscriber=None),
            subscription=None,
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.ORG_TEAM.value}):

            handle_invoice_payment_failed(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(kwargs["user_id"], owner.id)
        props = kwargs["properties"]
        self.assertFalse(props.get("final_attempt", True))
        self.assertEqual(props["organization_id"], str(org.id))
        self.assertEqual(props["plan"], PlanNamesChoices.ORG_TEAM.value)

    def test_invoice_payment_failed_resolves_user_from_customer_lookup_when_missing_subscriber(self):
        payload = _build_invoice_payload(
            customer_id="cus_lookup_failed",
            subscription_id="sub_lookup_failed",
            attempt_count=1,
            next_payment_attempt=None,
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_failed")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_lookup_failed", subscriber=None),
            subscription=SimpleNamespace(id="sub_lookup_failed"),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals._get_customer_with_subscriber", return_value=SimpleNamespace(id="cus_lookup_failed", subscriber=self.user)), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value=None):

            handle_invoice_payment_failed(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(kwargs["user_id"], self.user.id)
        self.assertEqual(kwargs["event"], AnalyticsEvent.BILLING_PAYMENT_FAILED)


@tag("batch_pages_signals")
class PaymentSetupIntentFailedSignalTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="setup-fail-user",
            email="setup-fail@example.com",
            password="pw",
            first_name="Setup",
            last_name="User",
        )

    def test_setup_intent_failed_tracks_user_with_payment_method_details(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_fail",
            customer_id="cus_setup_user",
            payment_method="pm_setup_user",
            last_setup_error={
                "type": "card_error",
                "code": "card_declined",
                "decline_code": "do_not_honor",
                "message": "Your card was declined.",
                "payment_method": {
                    "id": "pm_setup_user",
                    "customer": "cus_setup_user",
                    "type": "card",
                    "billing_details": {
                        "address": {
                            "country": "US",
                            "postal_code": "95825",
                        },
                        "email": "setup-fail@example.com",
                        "name": "Setup User",
                    },
                    "card": {
                        "brand": "visa",
                        "display_brand": "visa",
                        "fingerprint": "j0UyCwqiJdhXcPc0",
                        "last4": "4242",
                        "funding": "credit",
                        "country": "US",
                        "generated_from": {
                            "payment_method_details": {
                                "type": "card_present",
                                "card_present": {
                                    "issuer": "Chase Bank",
                                },
                            }
                        },
                    },
                },
            },
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.setup_failed")

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._get_customer_with_subscriber",
                return_value=SimpleNamespace(id="cus_setup_user", subscriber=self.user),
            ), \
            patch("pages.signals.Analytics.identify") as mock_identify, \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous:

            handle_setup_intent_setup_failed(event)

        mock_track_anonymous.assert_not_called()
        mock_identify.assert_called_once()
        identify_args = mock_identify.call_args.args
        self.assertEqual(identify_args[0], self.user.id)
        self.assertEqual(identify_args[1]["email"], self.user.email)

        mock_track_event.assert_called_once()
        kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(kwargs["user_id"], self.user.id)
        self.assertEqual(kwargs["event"], AnalyticsEvent.PAYMENT_SETUP_INTENT_FAILED)
        props = kwargs["properties"]
        self.assertEqual(props["stripe.setup_intent_id"], "seti_user_fail")
        self.assertEqual(props["stripe.customer_id"], "cus_setup_user")
        self.assertEqual(props["stripe.payment_method_id"], "pm_setup_user")
        self.assertEqual(props["failure_reason"], "Your card was declined.")
        self.assertEqual(props["failure_message"], "Your card was declined.")
        self.assertEqual(props["failure_code"], "card_declined")
        self.assertEqual(props["decline_code"], "do_not_honor")
        self.assertEqual(props["failure_type"], "card_error")
        self.assertEqual(props["payment_method_type"], "card")
        self.assertEqual(props["payment_method_brand"], "visa")
        self.assertEqual(props["payment_method_last4"], "4242")
        self.assertEqual(props["payment_method_fingerprint"], "j0UyCwqiJdhXcPc0")
        self.assertEqual(props["payment_method_funding"], "credit")
        self.assertEqual(props["payment_method_country"], "US")
        self.assertEqual(props["payment_method_issuer"], "Chase Bank")
        self.assertEqual(props["payment_method_billing_name"], "Setup User")
        self.assertEqual(props["payment_method_billing_email"], "setup-fail@example.com")
        self.assertEqual(props["payment_method_billing_country"], "US")
        self.assertEqual(props["payment_method_billing_postal_code"], "95825")
        self.assertEqual(props["actor_user_id"], str(self.user.id))
        self.assertEqual(props["actor_user_email"], self.user.email)
        self.assertFalse(props["organization"])

    def test_setup_intent_failed_fetches_customer_and_payment_method_details_from_stripe(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_lookup_fail",
            customer_id=None,
            payment_method="pm_lookup_fail",
            last_setup_error={
                "type": "card_error",
                "code": "card_declined",
                "decline_code": "insufficient_funds",
                "message": "Your card has insufficient funds.",
            },
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.setup_failed")

        retrieved_setup_intent = {
            "id": "seti_lookup_fail",
            "customer": "cus_lookup_fail",
            "payment_method": {
                "id": "pm_lookup_fail",
                "customer": None,
                "type": "card",
                "card": {
                    "brand": "mastercard",
                    "fingerprint": "lookupfingerprint123",
                    "last4": "4444",
                    "funding": "debit",
                    "country": "US",
                },
            },
            "last_setup_error": {
                "type": "card_error",
                "code": "card_declined",
                "decline_code": "insufficient_funds",
                "message": "Your card has insufficient funds.",
                "payment_method": {
                    "id": "pm_lookup_fail",
                    "customer": None,
                    "type": "card",
                    "card": {
                        "brand": "mastercard",
                        "fingerprint": "lookupfingerprint123",
                        "last4": "4444",
                        "funding": "debit",
                        "country": "US",
                    },
                },
            },
        }

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._get_customer_with_subscriber",
                return_value=SimpleNamespace(id="cus_lookup_fail", subscriber=self.user),
            ) as mock_get_customer_with_subscriber, \
            patch("pages.signals.stripe.SetupIntent.retrieve", return_value=retrieved_setup_intent) as mock_setup_intent_retrieve, \
            patch("pages.signals.stripe.PaymentMethod.retrieve") as mock_payment_method_retrieve, \
            patch("pages.signals.Analytics.identify") as mock_identify, \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous:

            handle_setup_intent_setup_failed(event)

        mock_track_anonymous.assert_not_called()
        mock_identify.assert_called_once()
        mock_track_event.assert_called_once()
        mock_get_customer_with_subscriber.assert_called_once_with("cus_lookup_fail")
        mock_setup_intent_retrieve.assert_called_once_with(
            "seti_lookup_fail",
            expand=["payment_method", "last_setup_error.payment_method"],
        )
        mock_payment_method_retrieve.assert_not_called()
        props = mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(props["stripe.customer_id"], "cus_lookup_fail")
        self.assertEqual(props["stripe.payment_method_id"], "pm_lookup_fail")
        self.assertEqual(props["payment_method_brand"], "mastercard")
        self.assertEqual(props["payment_method_last4"], "4444")
        self.assertEqual(props["payment_method_fingerprint"], "lookupfingerprint123")
        self.assertEqual(props["payment_method_funding"], "debit")
        self.assertEqual(props["failure_reason"], "Your card has insufficient funds.")

    def test_setup_intent_failed_tracks_link_payment_method_details_when_present(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_link_fail",
            customer_id="cus_link_fail",
            payment_method=None,
            payment_method_types=["card", "link", "amazon_pay"],
            last_setup_error={
                "type": "card_error",
                "code": "",
                "decline_code": "generic_decline",
                "message": "Your payment method was declined.",
                "payment_method": {
                    "id": "pm_link_fail",
                    "customer": None,
                    "type": "link",
                    "billing_details": {
                        "address": {
                            "country": "US",
                            "postal_code": "90249",
                        },
                        "email": "philr@imperiummktg.com",
                        "name": "Phil Reed",
                    },
                    "link": {
                        "email": "philr@imperiummktg.com",
                    },
                },
            },
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.setup_failed")

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._get_customer_with_subscriber",
                return_value=SimpleNamespace(id="cus_link_fail", subscriber=self.user),
            ), \
            patch("pages.signals.Analytics.identify") as mock_identify, \
            patch("pages.signals.Analytics.track_event") as mock_track_event:

            handle_setup_intent_setup_failed(event)

        mock_identify.assert_called_once()
        mock_track_event.assert_called_once()
        props = mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(props["stripe.customer_id"], "cus_link_fail")
        self.assertEqual(props["stripe.payment_method_id"], "pm_link_fail")
        self.assertEqual(props["payment_method_type"], "link")
        self.assertEqual(props["failure_reason"], "Your payment method was declined.")
        self.assertEqual(props["decline_code"], "generic_decline")
        self.assertEqual(props["payment_method_billing_name"], "Phil Reed")
        self.assertEqual(props["payment_method_billing_email"], "philr@imperiummktg.com")
        self.assertEqual(props["payment_method_billing_country"], "US")
        self.assertEqual(props["payment_method_billing_postal_code"], "90249")
        self.assertEqual(props["payment_method_link_email"], "philr@imperiummktg.com")
        self.assertNotIn("payment_method_fingerprint", props)
        self.assertNotIn("payment_method_funding", props)
        self.assertNotIn("payment_method_issuer", props)

    def test_setup_intent_failed_skips_when_user_cannot_be_resolved(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_unresolved_fail",
            customer_id="cus_unresolved",
            payment_method="pm_unresolved",
            last_setup_error={
                "type": "card_error",
                "code": "card_declined",
                "message": "Your card was declined.",
            },
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.setup_failed")

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals._get_customer_with_subscriber", return_value=None), \
            patch("pages.signals._retrieve_setup_intent_data", return_value={}), \
            patch("pages.signals._retrieve_payment_method_data", return_value={}), \
            patch("pages.signals.Analytics.identify") as mock_identify, \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous:

            handle_setup_intent_setup_failed(event)

        mock_identify.assert_not_called()
        mock_track_event.assert_not_called()
        mock_track_anonymous.assert_not_called()


@tag("batch_pages_signals")
class PaymentSetupIntentSucceededSignalTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="setup-success-user",
            email="setup-success@example.com",
            password="pw",
        )
        self.identify_patcher = patch("pages.signals.Analytics.identify")
        self.mock_identify = self.identify_patcher.start()
        self.addCleanup(self.identify_patcher.stop)
        self.track_event_patcher = patch("pages.signals.Analytics.track_event")
        self.mock_track_event = self.track_event_patcher.start()
        self.addCleanup(self.track_event_patcher.stop)
        self.track_anonymous_patcher = patch("pages.signals.Analytics.track_event_anonymous")
        self.mock_track_event_anonymous = self.track_anonymous_patcher.start()
        self.addCleanup(self.track_anonymous_patcher.stop)
        self.retrieve_payment_method_patcher = patch(
            "pages.signals._retrieve_payment_method_data",
            return_value={},
        )
        self.mock_retrieve_payment_method_data = self.retrieve_payment_method_patcher.start()
        self.addCleanup(self.retrieve_payment_method_patcher.stop)

    def test_setup_intent_succeeded_tracks_user_with_payment_method_details(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_success_analytics",
            customer_id="cus_setup_user",
            payment_method={
                "id": "pm_setup_success",
                "customer": "cus_setup_user",
                "type": "card",
                "billing_details": {
                    "address": {
                        "country": "US",
                        "postal_code": "95825",
                    },
                    "email": "setup-success@example.com",
                    "name": "Setup Success",
                },
                "card": {
                    "brand": "visa",
                    "display_brand": "visa",
                    "fingerprint": "a1b2c3d4",
                    "last4": "4242",
                    "funding": "credit",
                    "country": "US",
                },
            },
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._get_customer_with_subscriber",
                return_value=SimpleNamespace(id="cus_setup_user", subscriber=self.user),
            ), \
            patch("pages.signals.get_active_subscription", return_value=None):

            handle_setup_intent_succeeded(event)

        self.mock_track_event_anonymous.assert_not_called()
        self.mock_identify.assert_called_once()
        identify_args = self.mock_identify.call_args.args
        self.assertEqual(identify_args[0], self.user.id)
        self.assertEqual(identify_args[1]["email"], self.user.email)
        self.assertEqual(identify_args[1]["username"], self.user.username)
        self.mock_track_event.assert_called_once()
        kwargs = self.mock_track_event.call_args.kwargs
        self.assertEqual(kwargs["user_id"], self.user.id)
        self.assertEqual(kwargs["event"], AnalyticsEvent.PAYMENT_SETUP_INTENT_SUCCEEDED)
        props = kwargs["properties"]
        self.assertEqual(props["stripe.setup_intent_id"], "seti_user_success_analytics")
        self.assertEqual(props["stripe.customer_id"], "cus_setup_user")
        self.assertEqual(props["stripe.payment_method_id"], "pm_setup_success")
        self.assertEqual(props["payment_method_type"], "card")
        self.assertEqual(props["payment_method_brand"], "visa")
        self.assertEqual(props["payment_method_last4"], "4242")
        self.assertEqual(props["payment_method_fingerprint"], "a1b2c3d4")
        self.assertEqual(props["payment_method_funding"], "credit")
        self.assertEqual(props["payment_method_country"], "US")
        self.assertEqual(props["payment_method_billing_name"], "Setup Success")
        self.assertEqual(props["payment_method_billing_email"], "setup-success@example.com")
        self.assertEqual(props["payment_method_billing_country"], "US")
        self.assertEqual(props["payment_method_billing_postal_code"], "95825")
        self.assertEqual(props["actor_user_id"], str(self.user.id))
        self.assertEqual(props["actor_user_email"], self.user.email)
        self.assertFalse(props["organization"])

    def test_setup_intent_succeeded_fetches_customer_and_payment_method_details_from_stripe(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_lookup_success",
            customer_id=None,
            payment_method=None,
            payment_method_types=["card"],
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        retrieved_setup_intent = {
            "id": "seti_lookup_success",
            "customer": "cus_lookup_success",
            "payment_method": {
                "id": "pm_lookup_success",
                "customer": "cus_lookup_success",
                "type": "card",
                "card": {
                    "brand": "mastercard",
                    "fingerprint": "lookupsuccess123",
                    "last4": "4444",
                    "funding": "debit",
                    "country": "US",
                },
            },
        }

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._get_customer_with_subscriber",
                return_value=SimpleNamespace(id="cus_lookup_success", subscriber=self.user),
            ) as mock_get_customer_with_subscriber, \
            patch("pages.signals.stripe.SetupIntent.retrieve", return_value=retrieved_setup_intent) as mock_setup_intent_retrieve, \
            patch("pages.signals.stripe.PaymentMethod.retrieve") as mock_payment_method_retrieve, \
            patch("pages.signals.get_active_subscription", return_value=None):

            handle_setup_intent_succeeded(event)

        self.mock_track_event_anonymous.assert_not_called()
        self.mock_identify.assert_called_once()
        identify_args = self.mock_identify.call_args.args
        self.assertEqual(identify_args[0], self.user.id)
        self.assertEqual(identify_args[1]["email"], self.user.email)
        self.assertEqual(identify_args[1]["username"], self.user.username)
        self.mock_track_event.assert_called_once()
        mock_get_customer_with_subscriber.assert_called_once_with("cus_lookup_success")
        mock_setup_intent_retrieve.assert_called_once_with(
            "seti_lookup_success",
            expand=["payment_method", "last_setup_error.payment_method"],
        )
        mock_payment_method_retrieve.assert_not_called()
        props = self.mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(props["stripe.customer_id"], "cus_lookup_success")
        self.assertEqual(props["stripe.payment_method_id"], "pm_lookup_success")
        self.assertEqual(props["payment_method_type"], "card")
        self.assertEqual(props["payment_method_brand"], "mastercard")
        self.assertEqual(props["payment_method_last4"], "4444")
        self.assertEqual(props["payment_method_fingerprint"], "lookupsuccess123")
        self.assertEqual(props["payment_method_funding"], "debit")

    def test_setup_intent_succeeded_fetches_payment_method_details_for_id_only_payload(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_id_only_success",
            customer_id="cus_setup_user",
            payment_method="pm_setup_id_only",
            payment_method_types=["card"],
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        self.mock_retrieve_payment_method_data.return_value = {
            "id": "pm_setup_id_only",
            "customer": "cus_setup_user",
            "type": "card",
            "billing_details": {
                "address": {
                    "country": "US",
                    "postal_code": "10001",
                },
                "email": "setup-success@example.com",
                "name": "Setup Success",
            },
            "card": {
                "brand": "visa",
                "fingerprint": "idonlyfingerprint123",
                "last4": "4242",
                "funding": "credit",
                "country": "US",
            },
        }

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._get_customer_with_subscriber",
                return_value=SimpleNamespace(id="cus_setup_user", subscriber=self.user),
            ), \
            patch("pages.signals.get_active_subscription", return_value=None):

            handle_setup_intent_succeeded(event)

        self.mock_retrieve_payment_method_data.assert_called_once_with("pm_setup_id_only")
        self.mock_track_event.assert_called_once()
        props = self.mock_track_event.call_args.kwargs["properties"]
        self.assertEqual(props["stripe.payment_method_id"], "pm_setup_id_only")
        self.assertEqual(props["payment_method_type"], "card")
        self.assertEqual(props["payment_method_brand"], "visa")
        self.assertEqual(props["payment_method_last4"], "4242")
        self.assertEqual(props["payment_method_fingerprint"], "idonlyfingerprint123")
        self.assertEqual(props["payment_method_billing_email"], "setup-success@example.com")
        self.assertEqual(props["payment_method_billing_postal_code"], "10001")

    def test_setup_intent_succeeded_updates_active_subscription_default_payment_method(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_success",
            customer_id="cus_setup_user",
            payment_method="pm_setup_new",
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        subscription = SimpleNamespace(
            id="sub_user_success",
            default_payment_method_id="pm_setup_old",
            stripe_data={"default_payment_method": "pm_setup_old"},
        )
        updated_subscription = {"id": "sub_user_success", "default_payment_method": "pm_setup_new"}
        call_order = []

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._resolve_setup_intent_owner",
                return_value=(self.user, "user", None, "cus_setup_user"),
            ), \
            patch(
                "pages.signals._emit_add_payment_info_for_setup_intent",
                side_effect=lambda **_kwargs: call_order.append("emit"),
            ) as mock_emit_add_payment_info, \
            patch("pages.signals.get_active_subscription", return_value=subscription), \
            patch(
                "pages.signals.stripe.Subscription.modify",
                side_effect=lambda *args, **kwargs: (
                    call_order.append("modify"),
                    updated_subscription,
                )[1],
            ) as mock_modify, \
            patch(
                "pages.signals.sync_subscription_after_direct_update",
                side_effect=lambda _updated_subscription: call_order.append("sync"),
            ) as mock_sync:

            handle_setup_intent_succeeded(event)

        mock_modify.assert_called_once_with(
            "sub_user_success",
            default_payment_method="pm_setup_new",
            idempotency_key="setup-intent-default-payment-method-seti_user_success-sub_user_success",
            api_key="sk_test",
        )
        mock_emit_add_payment_info.assert_called_once_with(
            owner=self.user,
            owner_type="user",
            payload=payload,
        )
        mock_sync.assert_called_once_with(updated_subscription)
        self.assertEqual(call_order, ["emit", "modify", "sync"])

    def test_setup_intent_succeeded_emits_add_payment_info_when_subscription_already_uses_payment_method(self):
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_current_payment_method",
            stripe_setup_intent_id="seti_user_current",
            event_id="startup-sub-current-payment-method",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
        )
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_current",
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        subscription = SimpleNamespace(
            id="sub_user_current",
            default_payment_method_id="pm_setup_current",
            stripe_data={"default_payment_method": "pm_setup_current"},
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._resolve_setup_intent_owner",
                return_value=(self.user, "user", None, "cus_setup_user"),
            ), \
            patch("pages.signals._build_marketing_context_from_user", return_value={"consent": True}), \
            patch("pages.signals.get_active_subscription", return_value=subscription), \
            patch("pages.signals.stripe.Subscription.modify") as mock_modify, \
            patch("pages.signals.sync_subscription_after_direct_update") as mock_sync, \
            patch("pages.signals.capi") as mock_capi:

            handle_setup_intent_succeeded(event)

        mock_capi.assert_called_once_with(
            user=self.user,
            event_name="AddPaymentInfo",
            properties={
                "event_id": "startup-sub-current-payment-method",
                "plan": PlanNames.STARTUP,
                "plan_label": "Pro",
                "flow_type": "trial",
                "value": 120.0,
                "currency": "USD",
                "payment_method_type": "card",
            },
            request=None,
            context={
                "consent": True,
                "page": {"url": "https://www.gobii.ai/pricing"},
            },
        )
        checkout_context = StripeCheckoutContext.objects.get(
            stripe_checkout_session_id="cs_trial_current_payment_method",
        )
        self.assertIsNotNone(checkout_context.add_payment_info_sent_at)
        mock_modify.assert_not_called()
        mock_sync.assert_not_called()

    def test_setup_intent_created_binds_trial_checkout_context(self):
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_context",
            event_id="startup-sub-created-bind",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
            stripe_session_created_at=timezone.make_aware(
                datetime(2026, 4, 8, 16, 0, 0),
                timezone=dt_timezone.utc,
            ),
        )
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_bound_from_created",
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
            created=int(datetime(2026, 4, 8, 16, 1, 0, tzinfo=dt_timezone.utc).timestamp()),
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.created")

        handle_setup_intent_created(event)

        checkout_context = StripeCheckoutContext.objects.get(
            stripe_checkout_session_id="cs_trial_context",
        )
        self.assertEqual(checkout_context.stripe_setup_intent_id, "seti_user_bound_from_created")

    def test_setup_intent_succeeded_emits_add_payment_info_from_bound_checkout_context(self):
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_bound",
            stripe_setup_intent_id="seti_user_add_payment",
            event_id="startup-sub-add-payment",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
        )
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_add_payment",
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        subscription = SimpleNamespace(
            id="sub_user_current",
            default_payment_method_id="pm_setup_old",
            stripe_data={"default_payment_method": "pm_setup_old"},
        )
        updated_subscription = {"id": "sub_user_current", "default_payment_method": "pm_setup_current"}

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._resolve_setup_intent_owner",
                return_value=(self.user, "user", None, "cus_setup_user"),
            ), \
            patch(
                "pages.signals._build_marketing_context_from_user",
                return_value={"consent": True, "click_ids": {"fbp": "fb.1.1700000000000.123456789"}},
            ), \
            patch("pages.signals.get_active_subscription", return_value=subscription), \
            patch("pages.signals.stripe.Subscription.modify", return_value=updated_subscription) as mock_modify, \
            patch("pages.signals.sync_subscription_after_direct_update") as mock_sync, \
            patch("pages.signals.capi") as mock_capi:

            handle_setup_intent_succeeded(event)

        mock_capi.assert_called_once_with(
            user=self.user,
            event_name="AddPaymentInfo",
            properties={
                "event_id": "startup-sub-add-payment",
                "plan": PlanNames.STARTUP,
                "plan_label": "Pro",
                "flow_type": "trial",
                "value": 120.0,
                "currency": "USD",
                "payment_method_type": "card",
            },
            request=None,
            context={
                "consent": True,
                "click_ids": {"fbp": "fb.1.1700000000000.123456789"},
                "page": {"url": "https://www.gobii.ai/pricing"},
            },
        )
        mock_modify.assert_called_once_with(
            "sub_user_current",
            default_payment_method="pm_setup_current",
            idempotency_key="setup-intent-default-payment-method-seti_user_add_payment-sub_user_current",
            api_key="sk_test",
        )
        mock_sync.assert_called_once_with(updated_subscription)
        checkout_context = StripeCheckoutContext.objects.get(
            stripe_checkout_session_id="cs_trial_bound",
        )
        self.assertIsNotNone(checkout_context.add_payment_info_sent_at)

    def test_setup_intent_succeeded_binds_checkout_context_on_the_fly(self):
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_on_the_fly",
            event_id="startup-sub-on-the-fly",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
            stripe_session_created_at=timezone.make_aware(
                datetime(2026, 4, 8, 16, 0, 0),
                timezone=dt_timezone.utc,
            ),
        )
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_on_the_fly",
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
            created=int(datetime(2026, 4, 8, 16, 1, 0, tzinfo=dt_timezone.utc).timestamp()),
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        subscription = SimpleNamespace(
            id="sub_user_current",
            default_payment_method_id="pm_setup_old",
            stripe_data={"default_payment_method": "pm_setup_old"},
        )
        updated_subscription = {"id": "sub_user_current", "default_payment_method": "pm_setup_current"}

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._resolve_setup_intent_owner",
                return_value=(self.user, "user", None, "cus_setup_user"),
            ), \
            patch("pages.signals._build_marketing_context_from_user", return_value={"consent": True}), \
            patch("pages.signals.get_active_subscription", return_value=subscription), \
            patch("pages.signals.stripe.Subscription.modify", return_value=updated_subscription) as mock_modify, \
            patch("pages.signals.sync_subscription_after_direct_update") as mock_sync, \
            patch("pages.signals.capi") as mock_capi:

            handle_setup_intent_succeeded(event)

        mock_capi.assert_called_once()
        checkout_context = StripeCheckoutContext.objects.get(
            stripe_checkout_session_id="cs_trial_on_the_fly",
        )
        self.assertEqual(checkout_context.stripe_setup_intent_id, "seti_user_on_the_fly")
        self.assertIsNotNone(checkout_context.add_payment_info_sent_at)
        mock_modify.assert_called_once_with(
            "sub_user_current",
            default_payment_method="pm_setup_current",
            idempotency_key="setup-intent-default-payment-method-seti_user_on_the_fly-sub_user_current",
            api_key="sk_test",
        )
        mock_sync.assert_called_once_with(updated_subscription)

    def test_setup_intent_succeeded_emits_add_payment_info_without_active_subscription(self):
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_without_subscription",
            stripe_setup_intent_id="seti_user_without_subscription",
            event_id="startup-sub-without-subscription",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
        )
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_without_subscription",
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._resolve_setup_intent_owner",
                return_value=(self.user, "user", None, "cus_setup_user"),
            ), \
            patch("pages.signals._build_marketing_context_from_user", return_value={"consent": True}), \
            patch("pages.signals.get_active_subscription", return_value=None), \
            patch("pages.signals.stripe.Subscription.modify") as mock_modify, \
            patch("pages.signals.sync_subscription_after_direct_update") as mock_sync, \
            patch("pages.signals.capi") as mock_capi:

            handle_setup_intent_succeeded(event)

        mock_capi.assert_called_once_with(
            user=self.user,
            event_name="AddPaymentInfo",
            properties={
                "event_id": "startup-sub-without-subscription",
                "plan": PlanNames.STARTUP,
                "plan_label": "Pro",
                "flow_type": "trial",
                "value": 120.0,
                "currency": "USD",
                "payment_method_type": "card",
            },
            request=None,
            context={
                "consent": True,
                "page": {"url": "https://www.gobii.ai/pricing"},
            },
        )
        checkout_context = StripeCheckoutContext.objects.get(
            stripe_checkout_session_id="cs_trial_without_subscription",
        )
        self.assertIsNotNone(checkout_context.add_payment_info_sent_at)
        mock_modify.assert_not_called()
        mock_sync.assert_not_called()

    def test_setup_intent_succeeded_does_not_reemit_add_payment_info_after_context_marked_sent(self):
        existing_sent_at = timezone.make_aware(
            datetime(2026, 4, 8, 16, 5, 0),
            timezone=dt_timezone.utc,
        )
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_already_sent",
            stripe_setup_intent_id="seti_user_already_sent",
            event_id="startup-sub-already-sent",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
            add_payment_info_sent_at=existing_sent_at,
        )
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_already_sent",
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        subscription = SimpleNamespace(
            id="sub_user_current",
            default_payment_method_id="pm_setup_current",
            stripe_data={"default_payment_method": "pm_setup_current"},
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._resolve_setup_intent_owner",
                return_value=(self.user, "user", None, "cus_setup_user"),
            ), \
            patch("pages.signals._build_marketing_context_from_user", return_value={"consent": True}), \
            patch("pages.signals.get_active_subscription", return_value=subscription), \
            patch("pages.signals.stripe.Subscription.modify") as mock_modify, \
            patch("pages.signals.sync_subscription_after_direct_update") as mock_sync, \
            patch("pages.signals.capi") as mock_capi:

            handle_setup_intent_succeeded(event)

        mock_capi.assert_not_called()
        checkout_context = StripeCheckoutContext.objects.get(
            stripe_checkout_session_id="cs_trial_already_sent",
        )
        self.assertEqual(checkout_context.add_payment_info_sent_at, existing_sent_at)
        mock_modify.assert_not_called()
        mock_sync.assert_not_called()

    def test_setup_intent_succeeded_without_id_does_not_consume_unbound_context(self):
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_unbound_missing_setup_intent_id",
            event_id="startup-sub-missing-setup-intent-id",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
        )
        payload = _build_setup_intent_payload(
            setup_intent_id=None,
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
        )
        payload.pop("id", None)
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        subscription = SimpleNamespace(
            id="sub_user_current",
            default_payment_method_id="pm_setup_old",
            stripe_data={"default_payment_method": "pm_setup_old"},
        )
        updated_subscription = {"id": "sub_user_current", "default_payment_method": "pm_setup_current"}

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._resolve_setup_intent_owner",
                return_value=(self.user, "user", None, "cus_setup_user"),
            ), \
            patch("pages.signals._build_marketing_context_from_user", return_value={"consent": True}), \
            patch("pages.signals.get_active_subscription", return_value=subscription), \
            patch("pages.signals.stripe.Subscription.modify", return_value=updated_subscription) as mock_modify, \
            patch("pages.signals.sync_subscription_after_direct_update") as mock_sync, \
            patch("pages.signals.capi") as mock_capi:

            handle_setup_intent_succeeded(event)

        mock_capi.assert_not_called()
        checkout_context = StripeCheckoutContext.objects.get(
            stripe_checkout_session_id="cs_trial_unbound_missing_setup_intent_id",
        )
        self.assertIsNone(checkout_context.stripe_setup_intent_id)
        self.assertIsNone(checkout_context.add_payment_info_sent_at)
        mock_modify.assert_called_once_with(
            "sub_user_current",
            default_payment_method="pm_setup_current",
            idempotency_key="setup-intent-default-payment-method--sub_user_current",
            api_key="sk_test",
        )
        mock_sync.assert_called_once_with(updated_subscription)

    def test_setup_intent_succeeded_does_not_bind_future_checkout_context(self):
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_future_context",
            event_id="startup-sub-future-context",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
            stripe_session_created_at=timezone.make_aware(
                datetime(2026, 4, 8, 16, 2, 0),
                timezone=dt_timezone.utc,
            ),
        )
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_future_context",
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
            created=int(datetime(2026, 4, 8, 16, 1, 0, tzinfo=dt_timezone.utc).timestamp()),
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        subscription = SimpleNamespace(
            id="sub_user_current",
            default_payment_method_id="pm_setup_old",
            stripe_data={"default_payment_method": "pm_setup_old"},
        )
        updated_subscription = {"id": "sub_user_current", "default_payment_method": "pm_setup_current"}

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch(
                "pages.signals._resolve_setup_intent_owner",
                return_value=(self.user, "user", None, "cus_setup_user"),
            ), \
            patch("pages.signals._build_marketing_context_from_user", return_value={"consent": True}), \
            patch("pages.signals.get_active_subscription", return_value=subscription), \
            patch("pages.signals.stripe.Subscription.modify", return_value=updated_subscription) as mock_modify, \
            patch("pages.signals.sync_subscription_after_direct_update") as mock_sync, \
            patch("pages.signals.capi") as mock_capi:

            handle_setup_intent_succeeded(event)

        mock_capi.assert_not_called()
        checkout_context = StripeCheckoutContext.objects.get(
            stripe_checkout_session_id="cs_trial_future_context",
        )
        self.assertIsNone(checkout_context.stripe_setup_intent_id)
        mock_modify.assert_called_once_with(
            "sub_user_current",
            default_payment_method="pm_setup_current",
            idempotency_key="setup-intent-default-payment-method-seti_user_future_context-sub_user_current",
            api_key="sk_test",
        )
        mock_sync.assert_called_once_with(updated_subscription)

    def test_setup_intent_succeeded_suppresses_add_payment_info_for_no_trial_decision(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_no_trial",
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        subscription = SimpleNamespace(
            id="sub_user_current",
            default_payment_method_id="pm_setup_old",
            stripe_data={"default_payment_method": "pm_setup_old"},
        )
        updated_subscription = {"id": "sub_user_current", "default_payment_method": "pm_setup_current"}
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_no_trial",
            stripe_setup_intent_id="seti_user_no_trial",
            event_id="startup-sub-no-trial",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
        )
        UserTrialEligibility.objects.create(
            user=self.user,
            auto_status=UserTrialEligibilityAutoStatusChoices.NO_TRIAL,
            reason_codes=["fpjs_history_match"],
        )

        with override_flag("add_payment_info_capi_trial_eligibility_enforcement", active=True):
            with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
                patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
                patch(
                    "pages.signals._resolve_setup_intent_owner",
                    return_value=(self.user, "user", None, "cus_setup_user"),
                ), \
                patch("pages.signals._build_marketing_context_from_user", return_value={"consent": True}), \
                patch("pages.signals.get_active_subscription", return_value=subscription), \
                patch("pages.signals.stripe.Subscription.modify", return_value=updated_subscription) as mock_modify, \
                patch("pages.signals.sync_subscription_after_direct_update") as mock_sync, \
                patch("pages.signals.capi") as mock_capi, \
                patch("pages.signals.Analytics.track") as mock_track:

                handle_setup_intent_succeeded(event)

        mock_capi.assert_not_called()
        mock_track.assert_called_once_with(
            user_id=self.user.id,
            event=AnalyticsEvent.CAPI_EVENT_SKIPPED,
            properties={
                "event_name": "AddPaymentInfo",
                "reason": "trial_eligibility_disallowed",
                "decision_source": "stored_trial_eligibility_snapshot",
                "trial_eligibility_decision": UserTrialEligibilityAutoStatusChoices.NO_TRIAL,
                "trial_eligibility_manual_action": "inherit",
                "trial_eligibility_reason_codes": ["fpjs_history_match"],
                "trial_eligibility_policy_send_allowed": False,
                "value": 120.0,
            },
        )
        mock_modify.assert_called_once_with(
            "sub_user_current",
            default_payment_method="pm_setup_current",
            idempotency_key="setup-intent-default-payment-method-seti_user_no_trial-sub_user_current",
            api_key="sk_test",
        )
        mock_sync.assert_called_once_with(updated_subscription)

    def test_setup_intent_succeeded_allows_add_payment_info_for_review_when_override_enabled(self):
        payload = _build_setup_intent_payload(
            setup_intent_id="seti_user_review",
            customer_id="cus_setup_user",
            payment_method="pm_setup_current",
            status="succeeded",
        )
        event = _build_djstripe_event(payload, event_type="setup_intent.succeeded")
        subscription = SimpleNamespace(
            id="sub_user_current",
            default_payment_method_id="pm_setup_old",
            stripe_data={"default_payment_method": "pm_setup_old"},
        )
        updated_subscription = {"id": "sub_user_current", "default_payment_method": "pm_setup_current"}
        StripeCheckoutContext.objects.create(
            stripe_customer_id="cus_setup_user",
            stripe_checkout_session_id="cs_trial_review",
            stripe_setup_intent_id="seti_user_review",
            event_id="startup-sub-review",
            flow_type="trial",
            plan=PlanNames.STARTUP,
            plan_label="Pro",
            value=Decimal("120.00"),
            currency="USD",
            checkout_source_url="https://www.gobii.ai/pricing",
        )
        UserTrialEligibility.objects.create(
            user=self.user,
            auto_status=UserTrialEligibilityAutoStatusChoices.REVIEW,
            reason_codes=["multi_signal_history_match"],
        )

        with override_flag("add_payment_info_capi_trial_eligibility_enforcement", active=True):
            with override_flag("add_payment_info_capi_send_review", active=True):
                with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
                    patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
                    patch(
                        "pages.signals._resolve_setup_intent_owner",
                        return_value=(self.user, "user", None, "cus_setup_user"),
                    ), \
                    patch(
                        "pages.signals._build_marketing_context_from_user",
                        return_value={"consent": True},
                    ), \
                    patch("pages.signals.get_active_subscription", return_value=subscription), \
                    patch("pages.signals.stripe.Subscription.modify", return_value=updated_subscription) as mock_modify, \
                    patch("pages.signals.sync_subscription_after_direct_update") as mock_sync, \
                    patch("pages.signals.capi") as mock_capi:

                    handle_setup_intent_succeeded(event)

        mock_capi.assert_called_once()
        self.assertEqual(mock_capi.call_args.kwargs["event_name"], "AddPaymentInfo")
        self.assertEqual(
            mock_capi.call_args.kwargs["properties"]["event_id"],
            "startup-sub-review",
        )
        mock_modify.assert_called_once_with(
            "sub_user_current",
            default_payment_method="pm_setup_current",
            idempotency_key="setup-intent-default-payment-method-seti_user_review-sub_user_current",
            api_key="sk_test",
        )
        mock_sync.assert_called_once_with(updated_subscription)


@tag("batch_pages_signals")
class PaymentSucceededSignalTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(username="success-user", email="success@example.com", password="pw")

    def _seed_last_touch_attribution(self):
        UserAttribution.objects.update_or_create(
            user=self.user,
            defaults={
                "fbc": "fb.1.1700000000000.latest-fbclid",
                "fbclid": "latest-fbclid",
                "fbp": "fb.1.1700000000000.123456789",
                "rdt_cid_last": "reddit-latest-click",
                "utm_source_last": "retargeting-campaign",
                "utm_campaign_last": "renewal-promo",
                "last_client_ip": "203.0.113.5",
                "last_user_agent": "pytest-renewal-agent",
                "ga_client_id": "GA1.2.111.222",
            },
        )

    def test_invoice_payment_succeeded_does_not_resume_paused_owner(self):
        UserBilling.objects.update_or_create(
            user=self.user,
            defaults={
                "execution_paused": True,
                "execution_pause_reason": "billing_delinquency",
                "execution_paused_at": timezone.now(),
            },
        )
        payload = _build_invoice_payload(
            customer_id="cus_user_succeeded",
            subscription_id="sub_user_succeeded",
            amount_due=3000,
            amount_paid=3000,
            status="paid",
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user_succeeded", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user_succeeded"),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.resume_owner_execution", wraps=real_resume_owner_execution) as mock_resume_owner, \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.Analytics.track_event_anonymous"), \
            patch("pages.signals.get_plan_by_product_id", return_value=None):

            handle_invoice_payment_succeeded(event)

        mock_resume_owner.assert_not_called()
        billing = UserBilling.objects.get(user=self.user)
        self.assertTrue(billing.execution_paused)
        self.assertEqual(billing.execution_pause_reason, "billing_delinquency")
        self.assertIsNotNone(billing.execution_paused_at)

    def test_invoice_payment_succeeded_for_user_tracks_event(self):
        payload = _build_invoice_payload(
            customer_id="cus_user_succeeded",
            subscription_id="sub_user_succeeded",
            attempt_count=1,
            next_payment_attempt=None,
            auto_advance=False,
            amount_due=3000,
            amount_paid=3000,
            status="paid",
            receipt_number="rcpt-123",
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user_succeeded", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user_succeeded"),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value=None):

            handle_invoice_payment_succeeded(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(kwargs["user_id"], self.user.id)
        self.assertEqual(kwargs["event"], AnalyticsEvent.BILLING_PAYMENT_SUCCEEDED)
        props = kwargs["properties"]
        self.assertEqual(props["attempt_number"], 1)
        self.assertTrue(props["final_attempt"])
        self.assertEqual(props["stripe.invoice_id"], payload["id"])
        self.assertEqual(props["stripe.subscription_id"], payload["subscription"])
        self.assertEqual(props["amount_paid"], 30.0)
        self.assertEqual(props["receipt_number"], "rcpt-123")

    def test_invoice_payment_succeeded_emits_subscribe_capi_for_first_payment(self):
        payload = _build_invoice_payload(
            customer_id="cus_user_succeeded",
            subscription_id="sub_user_succeeded",
            amount_paid=3000,
            status="paid",
            billing_reason="subscription_create",
            product_id="prod_plan",
        )
        payload["lines"]["data"][0]["amount"] = 3000
        payload["lines"]["data"][0]["price"]["unit_amount"] = 3000
        payload["lines"]["data"][0]["price"]["currency"] = "usd"
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user_succeeded", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user_succeeded", stripe_data={"metadata": {"gobii_event_id": "evt-123"}}),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_succeeded(event)

        mock_capi.assert_called_once()
        capi_kwargs = mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "Subscribe")
        props = capi_kwargs["properties"]
        self.assertEqual(props["plan"], PlanNamesChoices.STARTUP.value)
        self.assertEqual(props["subscription_id"], "sub_user_succeeded")
        self.assertEqual(props["stripe.invoice_id"], payload["id"])
        self.assertEqual(props["transaction_value"], 30.0)
        self.assertEqual(props["currency"], "USD")
        self.assertEqual(props["event_id"], "evt-123")

    def test_invoice_payment_succeeded_emits_subscribe_for_standard_renewal_with_real_value(self):
        self._seed_last_touch_attribution()
        payload = _build_invoice_payload(
            customer_id="cus_user_succeeded",
            subscription_id="sub_user_succeeded",
            amount_paid=3000,
            status="paid",
            billing_reason="subscription_cycle",
            product_id="prod_plan",
        )
        payload["lines"]["data"][0]["amount"] = 3000
        payload["lines"]["data"][0]["price"]["unit_amount"] = 3000
        payload["lines"]["data"][0]["price"]["currency"] = "usd"
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user_succeeded", subscriber=self.user),
            subscription=SimpleNamespace(
                id="sub_user_succeeded",
                stripe_data={
                    "metadata": {
                        "gobii_event_id": "evt-renew",
                        "checkout_source_url": "https://app.gobii.ai/billing/checkout?src=ads",
                    }
                },
            ),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_succeeded(event)

        mock_capi.assert_called_once()
        capi_kwargs = mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "Subscribe")
        self.assertNotIn("provider_targets", capi_kwargs)
        props = capi_kwargs["properties"]
        self.assertEqual(props["plan"], PlanNamesChoices.STARTUP.value)
        self.assertEqual(props["subscription_id"], "sub_user_succeeded")
        self.assertEqual(props["stripe.invoice_id"], payload["id"])
        self.assertEqual(props["transaction_value"], 30.0)
        self.assertEqual(props["value"], 30.0)
        self.assertEqual(props["currency"], "USD")
        self.assertEqual(props["event_id"], payload["id"])
        self.assertNotIn("predicted_ltv", props)
        context = capi_kwargs["context"]
        self.assertTrue(context["consent"])
        self.assertEqual(context["ga_client_id"], "GA1.2.111.222")
        self.assertNotIn("click_ids", context)
        self.assertNotIn("utm", context)
        self.assertNotIn("client_ip", context)
        self.assertNotIn("user_agent", context)
        self.assertNotIn("page", context)

    def test_invoice_payment_succeeded_does_not_emit_subscribe_for_trial_start(self):
        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        payload = _build_invoice_payload(
            customer_id="cus_user_succeeded",
            subscription_id="sub_user_succeeded",
            amount_paid=0,
            status="paid",
            billing_reason="subscription_create",
            product_id="prod_plan",
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user_succeeded", subscriber=self.user),
            subscription=SimpleNamespace(
                id="sub_user_succeeded",
                stripe_data={
                    "status": "trialing",
                    "trial_end": str(trial_end),
                },
            ),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event"), \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_succeeded(event)

        mock_capi.assert_not_called()

    def test_invoice_payment_succeeded_emits_trial_converted_event(self):
        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        payload = _build_invoice_payload(
            customer_id="cus_user_succeeded",
            subscription_id="sub_user_succeeded",
            amount_paid=3000,
            status="paid",
            billing_reason="subscription_cycle",
            product_id="prod_plan",
        )
        payload["lines"]["data"][0]["period"] = {
            "start": int(trial_end.timestamp()),
            "end": int((trial_end + timedelta(days=30)).timestamp()),
        }
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user_succeeded", subscriber=self.user),
            subscription=SimpleNamespace(id="sub_user_succeeded", stripe_data={"trial_end": str(trial_end)}),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}):

            handle_invoice_payment_succeeded(event)

        events = [call.kwargs.get("event") for call in mock_track_event.call_args_list]
        self.assertIn(AnalyticsEvent.BILLING_PAYMENT_SUCCEEDED, events)
        self.assertIn(AnalyticsEvent.BILLING_TRIAL_CONVERTED, events)

    def test_invoice_payment_succeeded_treats_missing_line_period_as_trial_conversion(self):
        self._seed_last_touch_attribution()
        trial_end = timezone.make_aware(datetime(2025, 9, 8, 8, 0, 0), timezone=dt_timezone.utc)
        payload = _build_invoice_payload(
            customer_id="cus_user_succeeded",
            subscription_id="sub_user_succeeded",
            amount_paid=3000,
            status="paid",
            billing_reason="subscription_cycle",
            product_id="prod_plan",
        )
        payload["lines"]["data"][0]["amount"] = 3000
        payload["lines"]["data"][0]["price"]["unit_amount"] = 3000
        payload["lines"]["data"][0]["price"]["currency"] = "usd"
        payload["lines"]["data"][0].pop("period", None)
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_user_succeeded", subscriber=self.user),
            subscription=SimpleNamespace(
                id="sub_user_succeeded",
                stripe_data={
                    "trial_end": str(trial_end),
                    "current_period_start": str(trial_end),
                    "metadata": {
                        "gobii_event_id": "evt-trial-conversion",
                        "checkout_source_url": "https://app.gobii.ai/billing/checkout?src=ads",
                    },
                },
            ),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.identify") as mock_identify, \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.STARTUP.value}), \
            patch("pages.signals.capi") as mock_capi:

            handle_invoice_payment_succeeded(event)

        mock_capi.assert_called_once()
        capi_kwargs = mock_capi.call_args.kwargs
        self.assertEqual(capi_kwargs["event_name"], "Subscribe")
        self.assertNotIn("provider_targets", capi_kwargs)
        props = capi_kwargs["properties"]
        self.assertEqual(props["transaction_value"], 30.0)
        self.assertEqual(props["value"], 30.0 * settings.CAPI_LTV_MULTIPLE)
        self.assertEqual(props["currency"], "USD")
        self.assertEqual(props["event_id"], "evt-trial-conversion")
        self.assertNotIn("predicted_ltv", props)
        context = capi_kwargs["context"]
        self.assertTrue(context["consent"])
        self.assertEqual(context["ga_client_id"], "GA1.2.111.222")
        self.assertEqual(context["click_ids"]["rdt_cid"], "reddit-latest-click")
        self.assertEqual(context["utm"]["utm_source"], "retargeting-campaign")
        self.assertEqual(context["client_ip"], "203.0.113.5")
        self.assertEqual(context["user_agent"], "pytest-renewal-agent")
        self.assertEqual(context["page"]["url"], "https://app.gobii.ai/billing/checkout?src=ads")

        events = [call.kwargs.get("event") for call in mock_track_event.call_args_list]
        self.assertIn(AnalyticsEvent.BILLING_PAYMENT_SUCCEEDED, events)
        self.assertIn(AnalyticsEvent.BILLING_TRIAL_CONVERTED, events)
        mock_identify.assert_called_once_with(self.user.id, {"is_trial": False, "plan": PlanNamesChoices.STARTUP.value})

    def test_invoice_payment_succeeded_for_org_tracks_creator(self):
        owner = User.objects.create_user(username="org-owner-success", email="org-success@example.com", password="pw")
        org = Organization.objects.create(name="Success Org", slug="success-org", created_by=owner)
        billing = org.billing
        billing.stripe_customer_id = "cus_org_success"
        billing.save(update_fields=["stripe_customer_id"])

        payload = _build_invoice_payload(
            customer_id="cus_org_success",
            subscription_id="sub_org_success",
            attempt_count=1,
            next_payment_attempt=None,
            auto_advance=False,
            amount_due=3500,
            amount_paid=3500,
            status="paid",
            product_id="prod_org",
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_org_success", subscriber=None),
            subscription=None,
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value={"id": PlanNamesChoices.ORG_TEAM.value}):

            handle_invoice_payment_succeeded(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(kwargs["user_id"], owner.id)
        props = kwargs["properties"]
        self.assertTrue(props.get("final_attempt"))
        self.assertEqual(props["organization_id"], str(org.id))
        self.assertEqual(props["plan"], PlanNamesChoices.ORG_TEAM.value)
        self.assertEqual(props["amount_paid"], 35.0)

    def test_invoice_payment_succeeded_resolves_user_from_customer_lookup_when_missing_subscriber(self):
        payload = _build_invoice_payload(
            customer_id="cus_lookup_succeeded",
            subscription_id="sub_lookup_succeeded",
            attempt_count=1,
            next_payment_attempt=None,
            amount_paid=3200,
            status="paid",
        )
        event = _build_djstripe_event(payload, event_type="invoice.payment_succeeded")

        invoice_obj = SimpleNamespace(
            id=payload["id"],
            customer=SimpleNamespace(id="cus_lookup_succeeded", subscriber=None),
            subscription=SimpleNamespace(id="sub_lookup_succeeded"),
            number=payload["number"],
        )

        with patch("pages.signals.stripe_status", return_value=SimpleNamespace(enabled=True)), \
            patch("pages.signals.PaymentsHelper.get_stripe_key", return_value="sk_test"), \
            patch("pages.signals.Invoice.sync_from_stripe_data", return_value=invoice_obj), \
            patch("pages.signals._get_customer_with_subscriber", return_value=SimpleNamespace(id="cus_lookup_succeeded", subscriber=self.user)), \
            patch("pages.signals.Analytics.track_event") as mock_track_event, \
            patch("pages.signals.Analytics.track_event_anonymous") as mock_track_anonymous, \
            patch("pages.signals.get_plan_by_product_id", return_value=None):

            handle_invoice_payment_succeeded(event)

        mock_track_anonymous.assert_not_called()
        mock_track_event.assert_called_once()
        kwargs = mock_track_event.call_args.kwargs
        self.assertEqual(kwargs["user_id"], self.user.id)
        self.assertEqual(kwargs["event"], AnalyticsEvent.BILLING_PAYMENT_SUCCEEDED)
