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: ...'`.
This commit is contained in:
Paulo Castellano 2026-05-07 10:10:23 -03:00
parent 3e3c0b4c9d
commit 9bc9e1f93c
5 changed files with 318 additions and 15 deletions

View file

@ -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')]),
};
}

View file

@ -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,
);

View file

@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $payload
*/
private function trackPlanChange(Account $account, string $event, ?string $previousPlan, array $payload): void
{
TrackBilling::dispatch((string) $account->id, $event, $payload, $previousPlan);
}
}

View file

@ -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';
});
});

View file

@ -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,
);
});