Three fixes from a fresh code review:
1. SyncUser identify used 'email' / 'name' instead of the PostHog
special person properties '\$email' / '\$name'. The frontend already
used the correct keys; the backend identify (sole source for users
who sign up but never log in) would have populated only custom
properties, leaving the built-in person profile email/name blank
in the PostHog UI.
2. handleSubscriptionDeleted now short-circuits when plan_id is already
null. Stripe re-delivers webhooks on transient failures, and the
prior version would dispatch a duplicate 'subscription.cancelled'
event and re-flush the (already empty) Pennant cache on each retry.
3. useTracking composable called posthog.capture directly, bypassing
the new enabled gate. While posthog-js queues calls before init
(so no events leaked over the network in self-hosted mode), the
buffer grew unbounded and would fire all queued events in bulk if
init was ever called. Replaced with a gated captureEvent helper
exported from posthog.ts.
Plus: drop the now-trivial 'updating non-plan fields does not flush
the pennant cache' test (no observer to test against), refresh stale
doc comments referencing the removed SyncUserToPostHog filename, and
add a Bus::assertNotDispatched check to the deletion-idempotency test.
Replaces the implicit Account::booted() observer with an explicit
Account::forgetPlanFeatureCache() method called from each plan_id
mutation site (StripeEventListener x3, BillingController x2). Self-hosted
installs naturally never reach any of these callsites — Stripe webhooks
do not fire and the billing controllers redirect to /calendar before any
plan mutation happens — so the Pennant flush is now guaranteed to be a
cloud-only operation.
Adds integration coverage proving the full chain webhook -> plan_id
update -> Pennant flush -> next Feature::value resolves against the new
plan limit.