@@ -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+
503639def _handle_payment_failed (invoice : dict ) -> dict :
504640 """Handle failed payment — warn customer + notify owner, don't downgrade.
505641
0 commit comments