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:
parent
3e3c0b4c9d
commit
9bc9e1f93c
5 changed files with 318 additions and 15 deletions
|
|
@ -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')]),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue