petter2025 commited on
Commit
390a689
·
1 Parent(s): f4fbb05

Upload folder using huggingface_hub

Browse files
app/api/routes_payments.py CHANGED
@@ -31,13 +31,24 @@ async def create_checkout_session(req: CheckoutRequest):
31
  """Create a Stripe Checkout session for the Pro tier."""
32
  if not stripe.api_key:
33
  raise HTTPException(status_code=500, detail="Stripe not configured")
 
 
34
 
35
  # Verify the API key exists and is free tier
36
- tier = tracker.get_tier(req.api_key) if tracker else None
37
  if tier != Tier.FREE:
38
  raise HTTPException(status_code=400,
39
  detail="Only free tier keys can be upgraded")
40
 
 
 
 
 
 
 
 
 
 
41
  try:
42
  checkout_session = stripe.checkout.Session.create(
43
  payment_method_types=["card"],
@@ -51,15 +62,15 @@ async def create_checkout_session(req: CheckoutRequest):
51
  mode="subscription",
52
  success_url=req.success_url,
53
  cancel_url=req.cancel_url,
54
- metadata={"api_key": req.api_key},
55
- client_reference_id=req.api_key,
56
  # checkout.session.completed carries this metadata via
57
  # session.metadata (handled below), but customer.subscription.*
58
  # events only carry the *subscription's own* metadata -- Stripe
59
  # does not copy Session.metadata onto the Subscription it
60
  # creates. Without this, cancellations can't be traced back to
61
- # an api_key and PRO tier never downgrades.
62
- subscription_data={"metadata": {"api_key": req.api_key}},
63
  )
64
  return {"sessionId": checkout_session.id, "url": checkout_session.url}
65
  except Exception:
 
31
  """Create a Stripe Checkout session for the Pro tier."""
32
  if not stripe.api_key:
33
  raise HTTPException(status_code=500, detail="Stripe not configured")
34
+ if not tracker:
35
+ raise HTTPException(status_code=503, detail="Usage tracking service not initialised")
36
 
37
  # Verify the API key exists and is free tier
38
+ tier = tracker.get_tier(req.api_key)
39
  if tier != Tier.FREE:
40
  raise HTTPException(status_code=400,
41
  detail="Only free tier keys can be upgraded")
42
 
43
+ # tenant_id, not the raw api_key, is what travels to Stripe from here
44
+ # on. api_key is a bearer secret -- usage_tracker.py exists specifically
45
+ # to never store it in plaintext (pepper-HMAC lookup + salted
46
+ # verification hash), and Stripe's dashboard/webhook logs/API are a
47
+ # third party with no reason to ever see it. tenant_id is an opaque,
48
+ # non-secret row identifier and is exactly what the webhook needs to
49
+ # look up which tenant's keys to retier.
50
+ tenant_id = tracker.get_tenant_id(req.api_key)
51
+
52
  try:
53
  checkout_session = stripe.checkout.Session.create(
54
  payment_method_types=["card"],
 
62
  mode="subscription",
63
  success_url=req.success_url,
64
  cancel_url=req.cancel_url,
65
+ metadata={"tenant_id": tenant_id},
66
+ client_reference_id=tenant_id,
67
  # checkout.session.completed carries this metadata via
68
  # session.metadata (handled below), but customer.subscription.*
69
  # events only carry the *subscription's own* metadata -- Stripe
70
  # does not copy Session.metadata onto the Subscription it
71
  # creates. Without this, cancellations can't be traced back to
72
+ # a tenant and PRO tier never downgrades.
73
+ subscription_data={"metadata": {"tenant_id": tenant_id}},
74
  )
75
  return {"sessionId": checkout_session.id, "url": checkout_session.url}
76
  except Exception:
app/api/webhooks.py CHANGED
@@ -2,16 +2,24 @@
2
  Stripe webhook handler – updates API key tier on subscription events.
3
  """
4
 
 
5
  import os
6
  import stripe
7
  from fastapi import APIRouter, Request, HTTPException
8
- from app.core.usage_tracker import update_key_tier, Tier
 
 
9
 
10
  router = APIRouter(prefix="/webhooks", tags=["webhooks"])
11
 
12
  STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
13
  stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
14
 
 
 
 
 
 
15
 
16
  @router.post("/stripe")
17
  async def stripe_webhook(request: Request):
@@ -30,20 +38,33 @@ async def stripe_webhook(request: Request):
30
  except stripe.error.SignatureVerificationError:
31
  raise HTTPException(status_code=400, detail="Invalid signature")
32
 
33
- # Handle subscription events
 
 
34
  if event["type"] == "checkout.session.completed":
35
  session = event["data"]["object"]
36
- api_key = session.get("client_reference_id") or session.get(
37
- "metadata", {}).get("api_key")
38
- if api_key:
39
- update_key_tier(api_key, Tier.PRO)
40
- elif event["type"] == "customer.subscription.deleted":
 
 
 
 
 
 
 
 
 
 
41
  subscription = event["data"]["object"]
42
- # You need to store a mapping from subscription ID to API key.
43
- # For simplicity, we assume you stored it in metadata during checkout.
44
- # Alternatively, look up by customer ID.
45
- api_key = subscription.get("metadata", {}).get("api_key")
46
- if api_key:
47
- update_key_tier(api_key, Tier.FREE)
 
48
 
49
  return {"status": "ok"}
 
2
  Stripe webhook handler – updates API key tier on subscription events.
3
  """
4
 
5
+ import logging
6
  import os
7
  import stripe
8
  from fastapi import APIRouter, Request, HTTPException
9
+ from app.core.usage_tracker import update_key_tier_by_tenant_id, Tier
10
+
11
+ logger = logging.getLogger(__name__)
12
 
13
  router = APIRouter(prefix="/webhooks", tags=["webhooks"])
14
 
15
  STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
16
  stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
17
 
18
+ # Subscription statuses that mean "not entitled to Pro anymore". Deliberately
19
+ # does NOT include "past_due" -- whether a failed-payment grace period keeps
20
+ # Pro access is a dunning-policy decision, not a bug, and isn't decided here.
21
+ _DOWNGRADE_STATUSES = {"canceled", "unpaid", "incomplete_expired"}
22
+
23
 
24
  @router.post("/stripe")
25
  async def stripe_webhook(request: Request):
 
38
  except stripe.error.SignatureVerificationError:
39
  raise HTTPException(status_code=400, detail="Invalid signature")
40
 
41
+ # tenant_id (not api_key) is what Checkout was given -- see
42
+ # routes_payments.py's create_checkout_session for why the raw bearer
43
+ # key must never round-trip through a third party.
44
  if event["type"] == "checkout.session.completed":
45
  session = event["data"]["object"]
46
+ # For card payments this is already "paid" by the time this event
47
+ # fires; for delayed-notification payment methods (bank debits,
48
+ # etc.) it can still be "unpaid" here. Upgrading on an unpaid
49
+ # session would grant Pro access before payment actually clears.
50
+ if session.get("payment_status") != "paid":
51
+ logger.info(
52
+ "checkout.session.completed with payment_status=%r; not upgrading yet",
53
+ session.get("payment_status"),
54
+ )
55
+ return {"status": "ok"}
56
+ tenant_id = session.get("client_reference_id") or session.get(
57
+ "metadata", {}).get("tenant_id")
58
+ if tenant_id:
59
+ update_key_tier_by_tenant_id(tenant_id, Tier.PRO)
60
+ elif event["type"] in ("customer.subscription.deleted", "customer.subscription.updated"):
61
  subscription = event["data"]["object"]
62
+ tenant_id = subscription.get("metadata", {}).get("tenant_id")
63
+ if not tenant_id:
64
+ return {"status": "ok"}
65
+ if event["type"] == "customer.subscription.deleted" or (
66
+ subscription.get("status") in _DOWNGRADE_STATUSES
67
+ ):
68
+ update_key_tier_by_tenant_id(tenant_id, Tier.FREE)
69
 
70
  return {"status": "ok"}
app/core/usage_tracker.py CHANGED
@@ -90,6 +90,14 @@ class UsageRecord:
90
  processing_ms: Optional[float] = None
91
 
92
 
 
 
 
 
 
 
 
 
93
  class UsageTracker:
94
  """
95
  Thread‑safe usage tracker with atomic quota consumption and idempotency.
@@ -171,10 +179,30 @@ class UsageTracker:
171
  def _get_pg_conn(self):
172
  """Get a thread-local Postgres connection for the api_keys table.
173
  Rows come back as dict-like objects (row["col"]) via RealDictCursor,
174
- matching the sqlite3.Row access pattern used elsewhere in this file."""
 
 
 
 
 
 
 
 
 
175
  if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
176
- self._local.pg_conn = psycopg2.connect(
177
- self._pg_dsn, cursor_factory=psycopg2.extras.RealDictCursor)
 
 
 
 
 
 
 
 
 
 
 
178
  yield self._local.pg_conn
179
 
180
  @staticmethod
@@ -327,6 +355,25 @@ class UsageTracker:
327
  conn.commit()
328
  return True
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  # --------------------------------------------------------------------------
331
  # Atomic quota consumption (unchanged, but uses api_key which links to tenant)
332
  # --------------------------------------------------------------------------
@@ -524,6 +571,12 @@ def update_key_tier(api_key: str, new_tier: Tier) -> bool:
524
  return tracker.update_api_key_tier(api_key, new_tier)
525
 
526
 
 
 
 
 
 
 
527
  async def enforce_quota(request: Request, api_key: str = None):
528
  """
529
  FastAPI dependency that enforces quota and attaches tenant_id to request state.
 
90
  processing_ms: Optional[float] = None
91
 
92
 
93
+ # Bounded retry for the initial Postgres connect -- see _get_pg_conn's
94
+ # docstring. 5 attempts with exponential backoff (1+2+4+8 = 15s of sleep,
95
+ # worst case) comfortably fits inside a container's normal boot window
96
+ # without turning a real outage into a long hang.
97
+ _PG_CONNECT_MAX_ATTEMPTS = 5
98
+ _PG_CONNECT_BACKOFF_BASE = 1.0
99
+
100
+
101
  class UsageTracker:
102
  """
103
  Thread‑safe usage tracker with atomic quota consumption and idempotency.
 
179
  def _get_pg_conn(self):
180
  """Get a thread-local Postgres connection for the api_keys table.
181
  Rows come back as dict-like objects (row["col"]) via RealDictCursor,
182
+ matching the sqlite3.Row access pattern used elsewhere in this file.
183
+
184
+ Retries the initial connect with backoff -- Render's internal
185
+ "dpg-*" hostname has been observed unresolvable for the first
186
+ couple of seconds after a cold container boot (2026-08-25 incident:
187
+ two consecutive "could not translate host name" crashes, then a
188
+ clean connect on the third restart with no config change). A DNS
189
+ race should not require a crash loop to self-heal; other genuine
190
+ connection errors (bad credentials, wrong host) still raise after
191
+ the attempts below are exhausted, preserving fail-closed."""
192
  if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
193
+ last_exc: Optional[psycopg2.OperationalError] = None
194
+ for attempt in range(_PG_CONNECT_MAX_ATTEMPTS):
195
+ try:
196
+ self._local.pg_conn = psycopg2.connect(
197
+ self._pg_dsn, cursor_factory=psycopg2.extras.RealDictCursor)
198
+ last_exc = None
199
+ break
200
+ except psycopg2.OperationalError as exc:
201
+ last_exc = exc
202
+ if attempt < _PG_CONNECT_MAX_ATTEMPTS - 1:
203
+ time.sleep(_PG_CONNECT_BACKOFF_BASE * (2 ** attempt))
204
+ if last_exc is not None:
205
+ raise last_exc
206
  yield self._local.pg_conn
207
 
208
  @staticmethod
 
355
  conn.commit()
356
  return True
357
 
358
+ def update_tier_by_tenant_id(self, tenant_id: str, new_tier: Tier) -> bool:
359
+ """Update the tier of every active API key belonging to tenant_id.
360
+
361
+ Used by the Stripe webhook, which must never handle a raw API key
362
+ (Stripe's own systems -- dashboard, logs, webhook payloads -- are a
363
+ third party; the plaintext bearer secret has no business being
364
+ stored there, which is exactly what passing it as Checkout
365
+ metadata used to do). tenant_id is not a secret -- it's an opaque
366
+ row identifier -- so it's safe to round-trip through Stripe."""
367
+ with self._get_pg_conn() as conn:
368
+ cur = self._pg_execute(
369
+ conn,
370
+ "UPDATE api_keys SET tier = %s WHERE tenant_id = %s AND is_active = true",
371
+ (new_tier.value, tenant_id),
372
+ )
373
+ updated = cur.rowcount > 0
374
+ conn.commit()
375
+ return updated
376
+
377
  # --------------------------------------------------------------------------
378
  # Atomic quota consumption (unchanged, but uses api_key which links to tenant)
379
  # --------------------------------------------------------------------------
 
571
  return tracker.update_api_key_tier(api_key, new_tier)
572
 
573
 
574
+ def update_key_tier_by_tenant_id(tenant_id: str, new_tier: Tier) -> bool:
575
+ if tracker is None:
576
+ return False
577
+ return tracker.update_tier_by_tenant_id(tenant_id, new_tier)
578
+
579
+
580
  async def enforce_quota(request: Request, api_key: str = None):
581
  """
582
  FastAPI dependency that enforces quota and attaches tenant_id to request state.
tests/test_payments.py CHANGED
@@ -1,17 +1,19 @@
1
- import os
2
  import pytest
3
  from unittest.mock import patch, MagicMock
4
  from fastapi.testclient import TestClient
5
  from app.main import app
6
 
 
 
 
 
 
7
  client = TestClient(app)
8
 
9
- # Skip all tests in this module if Stripe secret key is not set
10
- STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY")
11
- if not STRIPE_SECRET_KEY:
12
- pytest.skip(
13
- "Stripe not configured – skipping payment tests",
14
- allow_module_level=True)
15
 
16
 
17
  @pytest.fixture
@@ -21,7 +23,9 @@ def mock_stripe():
21
 
22
 
23
  def test_create_checkout_session_missing_stripe_key(monkeypatch):
24
- monkeypatch.setenv("STRIPE_SECRET_KEY", "")
 
 
25
  response = client.post(
26
  "/api/v1/payments/create-checkout-session",
27
  json={
@@ -34,8 +38,9 @@ def test_create_checkout_session_missing_stripe_key(monkeypatch):
34
 
35
  def test_create_checkout_session_free_key(mock_stripe):
36
  # Mock tracker.get_tier to return Tier.FREE
37
- with patch("app.core.usage_tracker.tracker") as mock_tracker:
38
  mock_tracker.get_tier.return_value = "free"
 
39
  mock_stripe.return_value = MagicMock(
40
  id="cs_test_123", url="https://checkout.stripe.com/pay")
41
  response = client.post(
@@ -51,7 +56,7 @@ def test_create_checkout_session_free_key(mock_stripe):
51
 
52
 
53
  def test_create_checkout_session_pro_key():
54
- with patch("app.core.usage_tracker.tracker") as mock_tracker:
55
  mock_tracker.get_tier.return_value = "pro"
56
  response = client.post(
57
  "/api/v1/payments/create-checkout-session",
 
 
1
  import pytest
2
  from unittest.mock import patch, MagicMock
3
  from fastapi.testclient import TestClient
4
  from app.main import app
5
 
6
+ # Every Stripe call in this module is mocked -- nothing here reaches the
7
+ # network, and no real credentials are involved. This module used to skip
8
+ # entirely whenever STRIPE_SECRET_KEY was unset (always, in CI), so none of
9
+ # it ran; stripe.api_key is patched below instead of relying on env vars,
10
+ # since routes_payments.py reads it at import time.
11
  client = TestClient(app)
12
 
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def stripe_configured(monkeypatch):
16
+ monkeypatch.setattr("stripe.api_key", "sk_test_fake")
 
 
17
 
18
 
19
  @pytest.fixture
 
23
 
24
 
25
  def test_create_checkout_session_missing_stripe_key(monkeypatch):
26
+ # routes_payments.py sets stripe.api_key from the environment at import
27
+ # time and checks `stripe.api_key`, so setenv here would be a no-op.
28
+ monkeypatch.setattr("stripe.api_key", None)
29
  response = client.post(
30
  "/api/v1/payments/create-checkout-session",
31
  json={
 
38
 
39
  def test_create_checkout_session_free_key(mock_stripe):
40
  # Mock tracker.get_tier to return Tier.FREE
41
+ with patch("app.api.routes_payments.tracker") as mock_tracker:
42
  mock_tracker.get_tier.return_value = "free"
43
+ mock_tracker.get_tenant_id.return_value = "tenant_test_123"
44
  mock_stripe.return_value = MagicMock(
45
  id="cs_test_123", url="https://checkout.stripe.com/pay")
46
  response = client.post(
 
56
 
57
 
58
  def test_create_checkout_session_pro_key():
59
+ with patch("app.api.routes_payments.tracker") as mock_tracker:
60
  mock_tracker.get_tier.return_value = "pro"
61
  response = client.post(
62
  "/api/v1/payments/create-checkout-session",
tests/test_webhooks.py CHANGED
@@ -1,17 +1,25 @@
1
- import os
2
  import pytest
3
  from unittest.mock import patch
4
  from fastapi.testclient import TestClient
5
  from app.main import app
6
 
 
 
 
 
 
 
 
 
 
 
7
  client = TestClient(app)
8
 
9
- # Skip all tests in this module if Stripe webhook secret is not set
10
- STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
11
- if not STRIPE_WEBHOOK_SECRET:
12
- pytest.skip(
13
- "Stripe webhook not configured – skipping webhook tests",
14
- allow_module_level=True)
15
 
16
 
17
  @pytest.fixture
@@ -21,7 +29,9 @@ def mock_stripe_webhook():
21
 
22
 
23
  def test_webhook_missing_secret(monkeypatch):
24
- monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "")
 
 
25
  response = client.post(
26
  "/webhooks/stripe",
27
  json={},
@@ -31,8 +41,7 @@ def test_webhook_missing_secret(monkeypatch):
31
  assert "Stripe not configured" in response.json()["detail"]
32
 
33
 
34
- def test_webhook_invalid_payload(mock_stripe_webhook, monkeypatch):
35
- monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test")
36
  mock_stripe_webhook.side_effect = ValueError("Invalid payload")
37
  response = client.post(
38
  "/webhooks/stripe",
@@ -43,9 +52,12 @@ def test_webhook_invalid_payload(mock_stripe_webhook, monkeypatch):
43
  assert "Invalid payload" in response.json()["detail"]
44
 
45
 
46
- def test_webhook_invalid_signature(mock_stripe_webhook, monkeypatch):
47
- monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test")
48
- mock_stripe_webhook.side_effect = Exception("Invalid signature")
 
 
 
49
  response = client.post(
50
  "/webhooks/stripe",
51
  json={},
@@ -55,35 +67,92 @@ def test_webhook_invalid_signature(mock_stripe_webhook, monkeypatch):
55
  assert "Invalid signature" in response.json()["detail"]
56
 
57
 
58
- def test_webhook_checkout_completed(monkeypatch):
59
- monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test")
60
- monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test")
61
  with patch("stripe.Webhook.construct_event") as mock_construct, \
62
- patch("app.core.usage_tracker.update_key_tier") as mock_update:
63
  mock_construct.return_value = {
64
  "type": "checkout.session.completed",
65
  "data": {
66
  "object": {
67
- "client_reference_id": "test_key",
 
68
  "metadata": {
69
- "api_key": "test_key"}}}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  response = client.post(
71
  "/webhooks/stripe",
72
  json={},
73
  headers={"stripe-signature": "test"}
74
  )
75
  assert response.status_code == 200
76
- mock_update.assert_called_once_with("test_key", "pro")
77
 
78
 
79
- def test_webhook_subscription_deleted(monkeypatch):
80
- monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test")
81
- monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test")
82
  with patch("stripe.Webhook.construct_event") as mock_construct, \
83
- patch("app.core.usage_tracker.update_key_tier") as mock_update:
84
  mock_construct.return_value = {
85
  "type": "customer.subscription.deleted",
86
- "data": {"object": {"metadata": {"api_key": "test_key"}}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
88
  response = client.post(
89
  "/webhooks/stripe",
@@ -91,4 +160,4 @@ def test_webhook_subscription_deleted(monkeypatch):
91
  headers={"stripe-signature": "test"}
92
  )
93
  assert response.status_code == 200
94
- mock_update.assert_called_once_with("test_key", "free")
 
 
1
  import pytest
2
  from unittest.mock import patch
3
  from fastapi.testclient import TestClient
4
  from app.main import app
5
 
6
+ # Every Stripe call in this module is mocked -- nothing here reaches the
7
+ # network, and no real credentials are involved.
8
+ #
9
+ # This module used to skip entirely (pytest.skip(allow_module_level=True))
10
+ # whenever STRIPE_* env vars were unset, which is always in CI -- so none of
11
+ # it was ever exercised anywhere. webhooks.py reads its config into
12
+ # module-level globals at import time, so env vars set in conftest.py can't
13
+ # help (conftest's imports run before its os.environ assignments); the
14
+ # fixture below patches those globals directly instead, which works
15
+ # regardless of import order.
16
  client = TestClient(app)
17
 
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def stripe_configured(monkeypatch):
21
+ monkeypatch.setattr("app.api.webhooks.STRIPE_WEBHOOK_SECRET", "whsec_test_fake")
22
+ monkeypatch.setattr("stripe.api_key", "sk_test_fake")
 
23
 
24
 
25
  @pytest.fixture
 
29
 
30
 
31
  def test_webhook_missing_secret(monkeypatch):
32
+ # webhooks.py reads STRIPE_WEBHOOK_SECRET into a module-level global at
33
+ # import time, so setenv here would be a no-op -- patch the global.
34
+ monkeypatch.setattr("app.api.webhooks.STRIPE_WEBHOOK_SECRET", "")
35
  response = client.post(
36
  "/webhooks/stripe",
37
  json={},
 
41
  assert "Stripe not configured" in response.json()["detail"]
42
 
43
 
44
+ def test_webhook_invalid_payload(mock_stripe_webhook):
 
45
  mock_stripe_webhook.side_effect = ValueError("Invalid payload")
46
  response = client.post(
47
  "/webhooks/stripe",
 
52
  assert "Invalid payload" in response.json()["detail"]
53
 
54
 
55
+ def test_webhook_invalid_signature(mock_stripe_webhook):
56
+ import stripe
57
+ # Must be the real exception type the route catches -- a bare Exception
58
+ # would propagate as a 500 instead of the 400 this asserts.
59
+ mock_stripe_webhook.side_effect = stripe.error.SignatureVerificationError(
60
+ "Invalid signature", "sig_header")
61
  response = client.post(
62
  "/webhooks/stripe",
63
  json={},
 
67
  assert "Invalid signature" in response.json()["detail"]
68
 
69
 
70
+ def test_webhook_checkout_completed():
 
 
71
  with patch("stripe.Webhook.construct_event") as mock_construct, \
72
+ patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
73
  mock_construct.return_value = {
74
  "type": "checkout.session.completed",
75
  "data": {
76
  "object": {
77
+ "client_reference_id": "tenant_123",
78
+ "payment_status": "paid",
79
  "metadata": {
80
+ "tenant_id": "tenant_123"}}}}
81
+ response = client.post(
82
+ "/webhooks/stripe",
83
+ json={},
84
+ headers={"stripe-signature": "test"}
85
+ )
86
+ assert response.status_code == 200
87
+ mock_update.assert_called_once_with("tenant_123", "pro")
88
+
89
+
90
+ def test_webhook_checkout_completed_not_yet_paid_does_not_upgrade():
91
+ """checkout.session.completed can fire before payment actually clears
92
+ for delayed-notification payment methods -- must not grant Pro yet."""
93
+ with patch("stripe.Webhook.construct_event") as mock_construct, \
94
+ patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
95
+ mock_construct.return_value = {
96
+ "type": "checkout.session.completed",
97
+ "data": {
98
+ "object": {
99
+ "client_reference_id": "tenant_123",
100
+ "payment_status": "unpaid",
101
+ "metadata": {"tenant_id": "tenant_123"}}}}
102
  response = client.post(
103
  "/webhooks/stripe",
104
  json={},
105
  headers={"stripe-signature": "test"}
106
  )
107
  assert response.status_code == 200
108
+ mock_update.assert_not_called()
109
 
110
 
111
+ def test_webhook_subscription_deleted():
 
 
112
  with patch("stripe.Webhook.construct_event") as mock_construct, \
113
+ patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
114
  mock_construct.return_value = {
115
  "type": "customer.subscription.deleted",
116
+ "data": {"object": {"metadata": {"tenant_id": "tenant_123"}}}
117
+ }
118
+ response = client.post(
119
+ "/webhooks/stripe",
120
+ json={},
121
+ headers={"stripe-signature": "test"}
122
+ )
123
+ assert response.status_code == 200
124
+ mock_update.assert_called_once_with("tenant_123", "free")
125
+
126
+
127
+ def test_webhook_subscription_updated_canceled_downgrades():
128
+ """customer.subscription.updated (not just .deleted) must also
129
+ downgrade -- a status transition to canceled/unpaid can arrive this
130
+ way, and .deleted is not the only terminal event Stripe sends."""
131
+ with patch("stripe.Webhook.construct_event") as mock_construct, \
132
+ patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
133
+ mock_construct.return_value = {
134
+ "type": "customer.subscription.updated",
135
+ "data": {"object": {
136
+ "status": "canceled",
137
+ "metadata": {"tenant_id": "tenant_123"}}}
138
+ }
139
+ response = client.post(
140
+ "/webhooks/stripe",
141
+ json={},
142
+ headers={"stripe-signature": "test"}
143
+ )
144
+ assert response.status_code == 200
145
+ mock_update.assert_called_once_with("tenant_123", "free")
146
+
147
+
148
+ def test_webhook_subscription_updated_active_does_not_downgrade():
149
+ with patch("stripe.Webhook.construct_event") as mock_construct, \
150
+ patch("app.api.webhooks.update_key_tier_by_tenant_id") as mock_update:
151
+ mock_construct.return_value = {
152
+ "type": "customer.subscription.updated",
153
+ "data": {"object": {
154
+ "status": "active",
155
+ "metadata": {"tenant_id": "tenant_123"}}}
156
  }
157
  response = client.post(
158
  "/webhooks/stripe",
 
160
  headers={"stripe-signature": "test"}
161
  )
162
  assert response.status_code == 200
163
+ mock_update.assert_not_called()