From 9bc9e1f93c76cda7f0a9b3f6754e7b2e89c4cc52 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 7 May 2026 10:10:23 -0300 Subject: [PATCH] feat: wire StripeEventListener domain logic for plan swap & cancel The previous version of `handleSubscriptionUpdated` and `handleSubscriptionDeleted` were no-ops, so a plan swap or cancellation on Stripe never reflected in `accounts.plan_id`. Mirrors sendkit's listener structure and adds full coverage. Listener: - `handleSubscriptionCreated` / `handleSubscriptionUpdated` capture the previous plan name, resolve the new plan from the subscription items' price ids, and update `account.plan_id` if it changed. Pennant feature caches are forgotten automatically by `Account::booted()`. - `handleSubscriptionDeleted` clears `account.plan_id` so the UI and authorization checks reflect "no plan" instead of keeping the previous one attached. Idempotent. - Helpers extracted: `resolvePlanFromSubscriptionItems(payload, account)` (pure resolver, logs a warning when no plan matches) and `trackPlanChange(account, event, previousPlan, payload)` (delegates to the queued `TrackBilling` job). - Restored the `match($type) => handler...` shape with explicit protected handler methods for each subscription type. TrackBilling: - Adds optional `?string $previousPlan` constructor param. Forwarded as `previous_plan` event property so PostHog funnels can tell upgrade from downgrade from cancellation. Tests: - New: subscription updated/created sync `plan_id` from price ids (monthly + yearly), idempotent when price already matches, ignored when price ids are unknown, deletion clears plan_id, deletion idempotent when already null. Tests override the seeded plans' Stripe price ids inline so they don't depend on `.env.testing` having `STRIPE_*_MONTHLY/YEARLY` set. - New: previousPlan is forwarded to `TrackBilling` for updated and deleted, and is null for first-activation. - TrackBilling test: covers the previous_plan property in capture payload (both supplied and default-null cases). Suite: 1438 passed (+11). Plus a tiny cleanup: replaced the `'PostHog\\SendEvent: ...'` namespace-look-alike in the SendEvent log warning with the cleaner `'PostHog SendEvent: ...'`. --- app/Jobs/PostHog/SendEvent.php | 2 +- app/Jobs/PostHog/TrackBilling.php | 2 + app/Listeners/StripeEventListener.php | 123 +++++++++-- .../Feature/Jobs/PostHog/TrackBillingTest.php | 14 +- .../Listeners/StripeEventListenerTest.php | 192 ++++++++++++++++++ 5 files changed, 318 insertions(+), 15 deletions(-) diff --git a/app/Jobs/PostHog/SendEvent.php b/app/Jobs/PostHog/SendEvent.php index 0a2ef992..75f679e6 100644 --- a/app/Jobs/PostHog/SendEvent.php +++ b/app/Jobs/PostHog/SendEvent.php @@ -50,7 +50,7 @@ public function handle(): void 'capture' => PostHog::capture(data_get($call, 'payload')), 'identify' => PostHog::identify(data_get($call, 'payload')), 'groupIdentify' => PostHog::groupIdentify(data_get($call, 'payload')), - default => Log::warning('PostHog\\SendEvent: unknown method', ['method' => data_get($call, 'method')]), + default => Log::warning('PostHog SendEvent: unknown method', ['method' => data_get($call, 'method')]), }; } diff --git a/app/Jobs/PostHog/TrackBilling.php b/app/Jobs/PostHog/TrackBilling.php index ca8475a7..aa4f9ce2 100644 --- a/app/Jobs/PostHog/TrackBilling.php +++ b/app/Jobs/PostHog/TrackBilling.php @@ -35,6 +35,7 @@ public function __construct( public string $accountId, public string $event, public array $payload, + public ?string $previousPlan = null, ) { $this->onQueue('posthog'); } @@ -54,6 +55,7 @@ public function handle(PostHogService $postHog): void 'stripe_status' => data_get($this->payload, 'data.object.status'), 'plan' => $account->plan?->name, 'plan_slug' => $account->plan?->slug, + 'previous_plan' => $this->previousPlan, ], $account, ); diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index 20132b96..6fc0f039 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -7,17 +7,12 @@ use App\Events\SubscriptionCreated; use App\Jobs\PostHog\TrackBilling; use App\Models\Account; +use App\Models\Plan; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Events\WebhookReceived; class StripeEventListener { - private const POSTHOG_EVENT_BY_TYPE = [ - 'customer.subscription.created' => 'subscription.created', - 'customer.subscription.updated' => 'subscription.updated', - 'customer.subscription.deleted' => 'subscription.cancelled', - ]; - public function handle(WebhookReceived $event): void { try { @@ -34,13 +29,12 @@ public function handle(WebhookReceived $event): void return; } - if ($type === 'customer.subscription.created') { - SubscriptionCreated::dispatch($account); - } - - if ($postHogEvent = self::POSTHOG_EVENT_BY_TYPE[$type] ?? null) { - TrackBilling::dispatch((string) $account->id, $postHogEvent, $event->payload); - } + match ($type) { + 'customer.subscription.created' => $this->handleSubscriptionCreated($account, $event->payload), + 'customer.subscription.updated' => $this->handleSubscriptionUpdated($account, $event->payload), + 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($account, $event->payload), + default => null, + }; } catch (\Exception $e) { Log::error('Stripe webhook error: '.$e->getMessage(), [ 'exception' => $e, @@ -48,4 +42,107 @@ public function handle(WebhookReceived $event): void ]); } } + + /** + * @param array $payload + */ + protected function handleSubscriptionCreated(Account $account, array $payload): void + { + $previousPlan = $account->plan?->name; + $plan = $this->resolvePlanFromSubscriptionItems($payload, $account); + + if ($plan && $account->plan_id !== $plan->id) { + $account->update(['plan_id' => $plan->id]); + } + + SubscriptionCreated::dispatch($account); + + $this->trackPlanChange($account, 'subscription.created', $previousPlan, $payload); + } + + /** + * Plan changes coming from Stripe (billing portal, dashboard, or our own + * `BillingController@swap`) re-arrive here. Re-resolving the plan from + * the price ids guarantees the local state matches Stripe even when a + * change happens out-of-band. Pennant feature caches are forgotten + * automatically via Account::booted() when `plan_id` changes. + * + * @param array $payload + */ + protected function handleSubscriptionUpdated(Account $account, array $payload): void + { + $previousPlan = $account->plan?->name; + $plan = $this->resolvePlanFromSubscriptionItems($payload, $account); + + if ($plan && $account->plan_id !== $plan->id) { + $account->update(['plan_id' => $plan->id]); + } + + $this->trackPlanChange($account, 'subscription.updated', $previousPlan, $payload); + } + + /** + * Subscription fully terminated (period ended after cancellation, or + * cancelled immediately). Clear the local plan so the UI and + * authorisation checks (`Account::hasActiveSubscription`) reflect + * "no plan" instead of keeping the previous one attached. + * + * @param array $payload + */ + protected function handleSubscriptionDeleted(Account $account, array $payload): void + { + $previousPlan = $account->plan?->name; + + if ($account->plan_id !== null) { + $account->update(['plan_id' => null]); + } + + $this->trackPlanChange($account, 'subscription.cancelled', $previousPlan, $payload); + } + + /** + * Resolve the matching `Plan` for a Stripe subscription payload by + * looking up the items' price ids against the local plans table. + * Logs a warning when no match is found so price/plan drift between + * Stripe and the local DB is visible. + * + * @param array $payload + */ + private function resolvePlanFromSubscriptionItems(array $payload, Account $account): ?Plan + { + $priceIds = collect(data_get($payload, 'data.object.items.data', [])) + ->pluck('price.id') + ->filter() + ->all(); + + if (empty($priceIds)) { + return null; + } + + $plan = Plan::query() + ->where(function ($query) use ($priceIds): void { + $query->whereIn('stripe_monthly_price_id', $priceIds) + ->orWhereIn('stripe_yearly_price_id', $priceIds); + }) + ->first(); + + if (! $plan) { + Log::warning('Stripe webhook: no matching plan found in subscription items', [ + 'account_id' => $account->id, + 'price_ids' => $priceIds, + ]); + } + + return $plan; + } + + /** + * Hand the lifecycle event off to the queue so the listener stays fast. + * + * @param array $payload + */ + private function trackPlanChange(Account $account, string $event, ?string $previousPlan, array $payload): void + { + TrackBilling::dispatch((string) $account->id, $event, $payload, $previousPlan); + } } diff --git a/tests/Feature/Jobs/PostHog/TrackBillingTest.php b/tests/Feature/Jobs/PostHog/TrackBillingTest.php index eb47e9f8..974ad67b 100644 --- a/tests/Feature/Jobs/PostHog/TrackBillingTest.php +++ b/tests/Feature/Jobs/PostHog/TrackBillingTest.php @@ -46,7 +46,19 @@ && $call['payload']['event'] === 'subscription.updated' && $call['payload']['distinctId'] === (string) $this->user->id && $call['payload']['properties']['$groups']['account'] === (string) $this->account->id - && $call['payload']['properties']['stripe_status'] === 'active'; + && $call['payload']['properties']['stripe_status'] === 'active' + && array_key_exists('previous_plan', $call['payload']['properties']); + }); +}); + +test('handle forwards previousPlan as a property when supplied', function () { + Queue::fake(); + + (new TrackBilling((string) $this->account->id, 'subscription.updated', $this->payload, 'Starter')) + ->handle(app(PostHogService::class)); + + Queue::assertPushed(SendEvent::class, function ($job) { + return $job->calls[0]['payload']['properties']['previous_plan'] === 'Starter'; }); }); diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php index 6a785bb3..67c61705 100644 --- a/tests/Feature/Listeners/StripeEventListenerTest.php +++ b/tests/Feature/Listeners/StripeEventListenerTest.php @@ -6,6 +6,7 @@ use App\Jobs\PostHog\TrackBilling; use App\Listeners\StripeEventListener; use App\Models\Account; +use App\Models\Plan; use App\Models\User; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; @@ -201,3 +202,194 @@ Bus::assertNotDispatched(TrackBilling::class); }); + +// ======================================== +// Plan sync — domain logic +// ======================================== +// +// Tests below override the seeded plans' Stripe price ids with deterministic +// values so the assertions don't depend on `.env.testing` having +// `STRIPE_*_MONTHLY/YEARLY` set. + +beforeEach(function () { + Plan::query()->where('slug', 'starter')->update([ + 'stripe_monthly_price_id' => 'price_starter_monthly', + 'stripe_yearly_price_id' => 'price_starter_yearly', + ]); + Plan::query()->where('slug', 'pro')->update([ + 'stripe_monthly_price_id' => 'price_pro_monthly', + 'stripe_yearly_price_id' => 'price_pro_yearly', + ]); +}); + +test('subscription updated swaps account plan_id when price matches a different plan', function () { + $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); + $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); + + $this->account->update(['plan_id' => $starter->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [ + ['price' => ['id' => 'price_pro_monthly']], + ]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($pro->id); +}); + +test('subscription updated leaves plan_id alone when price already matches current plan', function () { + $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); + $this->account->update(['plan_id' => $starter->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [ + ['price' => ['id' => 'price_starter_monthly']], + ]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($starter->id); +}); + +test('subscription updated ignores unknown price ids without erroring', function () { + $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); + $this->account->update(['plan_id' => $starter->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [ + ['price' => ['id' => 'price_unknown_xyz']], + ]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($starter->id); +}); + +test('subscription updated matches yearly price ids too', function () { + $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); + $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); + + $this->account->update(['plan_id' => $starter->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [ + ['price' => ['id' => 'price_pro_yearly']], + ]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($pro->id); +}); + +test('subscription created syncs plan from price ids on first activation', function () { + $pro = Plan::query()->where('slug', 'pro')->firstOrFail(); + $this->account->update(['plan_id' => null]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [ + ['price' => ['id' => 'price_pro_monthly']], + ]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($pro->id); +}); + +test('subscription deleted clears the account plan_id', function () { + $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); + $this->account->update(['plan_id' => $starter->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['customer' => 'cus_test123', 'status' => 'canceled']], + ])); + + expect($this->account->fresh()->plan_id)->toBeNull(); +}); + +test('subscription deleted is idempotent when plan_id is already null', function () { + $this->account->update(['plan_id' => null]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['customer' => 'cus_test123']], + ])); + + expect($this->account->fresh()->plan_id)->toBeNull(); +}); + +// ======================================== +// previousPlan propagation +// ======================================== + +test('subscription updated forwards the previous plan name to TrackBilling', function () { + $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); + $this->account->update(['plan_id' => $starter->id]); + + Bus::fake([TrackBilling::class]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]], + ]], + ])); + + Bus::assertDispatched( + TrackBilling::class, + fn ($job) => $job->event === 'subscription.updated' && $job->previousPlan === $starter->name, + ); +}); + +test('subscription deleted forwards the previous plan name to TrackBilling', function () { + $starter = Plan::query()->where('slug', 'starter')->firstOrFail(); + $this->account->update(['plan_id' => $starter->id]); + + Bus::fake([TrackBilling::class]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['customer' => 'cus_test123']], + ])); + + Bus::assertDispatched( + TrackBilling::class, + fn ($job) => $job->event === 'subscription.cancelled' && $job->previousPlan === $starter->name, + ); +}); + +test('subscription created forwards a null previous plan when account had none', function () { + $this->account->update(['plan_id' => null]); + + Bus::fake([TrackBilling::class]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'items' => ['data' => [['price' => ['id' => 'price_starter_monthly']]]], + ]], + ])); + + Bus::assertDispatched( + TrackBilling::class, + fn ($job) => $job->event === 'subscription.created' && $job->previousPlan === null, + ); +});