Skip to content

Commit 2688ce3

Browse files
Handle customer.subscription.created + invoice.payment_succeeded
Closes the two webhook gaps the other chat + review found. - _handle_subscription_created: idempotent plan flip. Covers the 'admin creates sub directly in Stripe dashboard' path where checkout.session. completed never fires. Standard flow is a no-op because the checkout handler already upgraded the tenant. - _handle_payment_succeeded: ignores billing_reason=subscription_create (already handled at checkout). For renewals, checks if the tenant's current plan matches the subscription's plan — if not (e.g. tenant was downgraded during a past_due grace period), re-upgrades them and notifies owner. - Dispatcher updated for both events. - Also added customer.subscription.updated to the Stripe webhook endpoint's enabled_events (this event handler existed in code but the endpoint wasn't subscribed — plan upgrades via Customer Portal would not have fired it until this fix).
1 parent 5d99f28 commit 2688ce3

1 file changed

Lines changed: 136 additions & 0 deletions

File tree

synrix_runtime/api/billing.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,22 @@ def handle_webhook_event(payload: bytes, signature: str) -> dict:
221221

222222
if event_type == "checkout.session.completed":
223223
return _handle_checkout_completed(data)
224+
elif event_type == "customer.subscription.created":
225+
# Fires alongside checkout.session.completed on first upgrade, AND
226+
# when a subscription is created manually via the Stripe dashboard
227+
# (admin comp) without going through checkout. Idempotent with the
228+
# checkout handler for the standard flow.
229+
return _handle_subscription_created(data)
224230
elif event_type == "customer.subscription.updated":
225231
return _handle_subscription_updated(data)
226232
elif event_type == "customer.subscription.deleted":
227233
return _handle_subscription_deleted(data)
234+
elif event_type == "invoice.payment_succeeded":
235+
# Fires on every renewal + on recovery from past_due. We don't need
236+
# to touch anything on a routine renewal (the sub is already active),
237+
# but we DO need to re-upgrade tenants who were in a past_due/grace
238+
# state and just paid successfully.
239+
return _handle_payment_succeeded(data)
228240
elif event_type == "invoice.payment_failed":
229241
return _handle_payment_failed(data)
230242
else:
@@ -500,6 +512,130 @@ def _handle_subscription_deleted(subscription: dict) -> dict:
500512
return {"action": "downgraded", "tenant_id": tenant_id, "plan": "free"}
501513

502514

515+
def _handle_subscription_created(subscription: dict) -> dict:
516+
"""Handle a new subscription being created.
517+
518+
Fires alongside `checkout.session.completed` on a normal upgrade (the two
519+
are idempotent — `_upgrade_tenant` UPDATE is a no-op on the second call).
520+
ALSO fires when a subscription is created directly in the Stripe dashboard
521+
(admin comping someone manually) where `checkout.session.completed` never
522+
runs. That second case is the reason this handler exists separately.
523+
524+
Requires `metadata.tenant_id` on the subscription. When you comp someone
525+
via the Stripe dashboard, you MUST set Metadata: `tenant_id=<the id>` and
526+
`plan=<pro|business|scale>` on the subscription for this to work.
527+
"""
528+
tenant_id = subscription.get("metadata", {}).get("tenant_id", "")
529+
if not tenant_id:
530+
logger.info("subscription.created without tenant_id in metadata — "
531+
"skipping (likely handled by checkout.session.completed path "
532+
"or a manual admin sub with no metadata)")
533+
return {"handled": False, "reason": "no tenant_id in metadata"}
534+
535+
price_id = (subscription.get("items", {}).get("data", [{}])[0]
536+
.get("price", {}).get("id", ""))
537+
plan = subscription.get("metadata", {}).get("plan") or _price_to_plan(price_id)
538+
customer_id = subscription.get("customer", "")
539+
subscription_id = subscription.get("id", "")
540+
541+
_upgrade_tenant(tenant_id, plan, customer_id, subscription_id)
542+
logger.info("subscription.created — tenant %s set to %s", tenant_id, plan)
543+
544+
# Only fire the welcome email + owner notification if this is the
545+
# primary path (no prior checkout). The checkout.session.completed
546+
# handler already fires those. We detect prior handling by checking
547+
# whether the tenant already has this subscription_id.
548+
try:
549+
t = _lookup_tenant(tenant_id=tenant_id)
550+
# If t already has plan=<plan> before this call, assume checkout path
551+
# already handled emails. Only fire for "dashboard admin comp" path
552+
# where this is genuinely the first event.
553+
if t and t.get("email") and subscription.get("metadata", {}).get("source") == "manual_comp":
554+
_send_customer_welcome(t["email"], t.get("first_name", ""), plan)
555+
_notify_owner("upgraded", t["email"], plan, tenant_id,
556+
extra="manually created in Stripe dashboard")
557+
except Exception as e:
558+
logger.error("Post-subscription-created email flow failed: %s", e)
559+
560+
return {"action": "subscription_created", "tenant_id": tenant_id, "plan": plan}
561+
562+
563+
def _handle_payment_succeeded(invoice: dict) -> dict:
564+
"""Handle a successful invoice payment.
565+
566+
Fires on:
567+
- Initial payment at checkout (alongside checkout.session.completed)
568+
- Every monthly renewal
569+
- Recovery from past_due (revive a customer whose card failed earlier)
570+
571+
We don't need to do anything for routine renewals — the subscription is
572+
already active, Stripe continues billing, DB state is unchanged. BUT if
573+
the tenant was downgraded to `free` during a past_due grace period, this
574+
event is our signal to re-upgrade them.
575+
"""
576+
customer_id = invoice.get("customer", "")
577+
billing_reason = invoice.get("billing_reason", "")
578+
amount_paid = invoice.get("amount_paid", 0) / 100.0
579+
currency = (invoice.get("currency") or "usd").upper()
580+
581+
logger.info("payment_succeeded | customer=%s billing_reason=%s amount=%s %s",
582+
customer_id, billing_reason, amount_paid, currency)
583+
584+
# Ignore the initial payment — checkout.session.completed already handled it.
585+
if billing_reason == "subscription_create":
586+
return {"action": "noop_initial_payment", "customer_id": customer_id}
587+
588+
# For renewals or recoveries: look up the tenant, see if their plan looks
589+
# right, fix it if they were downgraded during a past_due grace period.
590+
try:
591+
t = _lookup_tenant(customer_id=customer_id)
592+
if not t:
593+
return {"handled": False, "reason": "tenant not found by customer_id"}
594+
595+
tenant_id = t["tenant_id"]
596+
current_plan = t.get("plan", "free")
597+
598+
# Re-read the subscription status from Stripe to find the true plan.
599+
subscription_id = invoice.get("subscription", "")
600+
if not subscription_id:
601+
return {"action": "noop_no_subscription_on_invoice",
602+
"customer_id": customer_id}
603+
604+
import requests as _req
605+
r = _req.get(
606+
f"https://api.stripe.com/v1/subscriptions/{subscription_id}",
607+
auth=(STRIPE_SECRET_KEY, ""), timeout=10,
608+
)
609+
if r.status_code != 200:
610+
logger.warning("Stripe subscription read failed: %s %s", r.status_code, r.text[:200])
611+
return {"handled": False, "reason": "stripe read failed"}
612+
613+
sub = r.json()
614+
status = sub.get("status", "")
615+
price_id = sub.get("items", {}).get("data", [{}])[0].get("price", {}).get("id", "")
616+
true_plan = sub.get("metadata", {}).get("plan") or _price_to_plan(price_id)
617+
618+
# If tenant's plan in our DB doesn't match the subscription's plan,
619+
# they were probably downgraded during past_due. Re-upgrade them.
620+
if status == "active" and current_plan != true_plan:
621+
_upgrade_tenant(tenant_id, true_plan, customer_id, subscription_id)
622+
logger.info("Tenant %s re-upgraded from %s to %s after successful renewal",
623+
tenant_id, current_plan, true_plan)
624+
try:
625+
_notify_owner("upgraded", t["email"], true_plan, tenant_id,
626+
extra=f"recovered from past_due — {currency} {amount_paid:.2f}")
627+
except Exception:
628+
pass
629+
return {"action": "recovered_from_past_due",
630+
"tenant_id": tenant_id, "plan": true_plan}
631+
632+
return {"action": "renewal_ok", "tenant_id": tenant_id, "plan": current_plan}
633+
except Exception as e:
634+
logger.error("payment_succeeded handler error for customer %s: %s",
635+
customer_id, e)
636+
return {"handled": False, "error": str(e)[:200]}
637+
638+
503639
def _handle_payment_failed(invoice: dict) -> dict:
504640
"""Handle failed payment — warn customer + notify owner, don't downgrade.
505641

0 commit comments

Comments
 (0)