From 96f83eca1bd2ecbb7ced1b468eac745fe9c76efe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 16:35:48 +0000 Subject: [PATCH] test: add integration tests for Stripe webhook handlers Add comprehensive test coverage for all Stripe webhook event handlers: - invoice.paid (subscription renewal, non-subscription, missing sub) - invoice.payment_failed (past due, notifications, edge cases) - customer.subscription.created/updated/deleted (full lifecycle) - payment_method.attached/detached/updated (card management) - setup_intent.succeeded (hosted setup page) - charge.succeeded & payment_intent.succeeded (Stripe Radar fraud scoring) - Idempotency / duplicate event rejection - Webhook audit trail logging - Stripe status mapping for all subscription states Fixes #11 Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/Feature/StripeWebhookHandlerTest.php | 1558 ++++++++++++++++++++ 1 file changed, 1558 insertions(+) create mode 100644 tests/Feature/StripeWebhookHandlerTest.php diff --git a/tests/Feature/StripeWebhookHandlerTest.php b/tests/Feature/StripeWebhookHandlerTest.php new file mode 100644 index 0000000..70e1ce6 --- /dev/null +++ b/tests/Feature/StripeWebhookHandlerTest.php @@ -0,0 +1,1558 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create([ + 'stripe_customer_id' => 'cus_test_123', + ]); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + $this->package = Package::where('code', 'creator')->first(); + if (! $this->package) { + $this->package = Package::create([ + 'name' => 'Creator', + 'code' => 'creator', + 'description' => 'For creators', + 'monthly_price' => 19.00, + 'yearly_price' => 190.00, + 'is_active' => true, + ]); + } +}); + +/** + * Build a StripeWebhookController with mocked dependencies. + * + * @param array $overrides Keyed mock overrides + */ +function makeStripeController(array $overrides = []): StripeWebhookController +{ + $mockGateway = $overrides['gateway'] ?? Mockery::mock(StripeGateway::class); + $mockCommerce = $overrides['commerce'] ?? Mockery::mock(CommerceService::class); + $mockInvoice = $overrides['invoice'] ?? Mockery::mock(InvoiceService::class); + $mockEntitlements = $overrides['entitlements'] ?? Mockery::mock(EntitlementService::class); + $webhookLogger = $overrides['logger'] ?? new WebhookLogger; + $mockFraud = $overrides['fraud'] ?? Mockery::mock(FraudService::class); + $mockRateLimiter = $overrides['rateLimiter'] ?? Mockery::mock(WebhookRateLimiter::class); + + // Default rate limiter behaviour: never throttle + if (! isset($overrides['rateLimiter'])) { + $mockRateLimiter->shouldReceive('tooManyAttempts')->andReturn(false); + $mockRateLimiter->shouldReceive('increment')->andReturnNull(); + } + + return new StripeWebhookController( + $mockGateway, + $mockCommerce, + $mockInvoice, + $mockEntitlements, + $webhookLogger, + $mockFraud, + $mockRateLimiter, + ); +} + +/** + * Build a Request with a Stripe-Signature header. + */ +function stripeRequest(): Request +{ + $request = new Request; + $request->headers->set('Stripe-Signature', 't=123,v1=abc'); + + return $request; +} + +// ============================================================================ +// Payment Succeeded (invoice.paid) — Subscription Renewal +// ============================================================================ + +describe('invoice.paid event', function () { + it('renews subscription and creates payment record', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_renew_001', + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'current_period_start' => now()->subMonth(), + 'current_period_end' => now(), + ]); + + $periodStart = now()->timestamp; + $periodEnd = now()->addMonth()->timestamp; + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.paid', + 'id' => 'evt_inv_paid_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'in_test_001', + 'subscription' => 'sub_renew_001', + 'payment_intent' => 'pi_renew_001', + 'amount_paid' => 1900, + 'currency' => 'gbp', + 'period_start' => $periodStart, + 'period_end' => $periodEnd, + ], + ], + ], + ]); + + $mockInvoice = Mockery::mock(InvoiceService::class); + $mockInvoice->shouldReceive('createForRenewal')->once(); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'invoice' => $mockInvoice, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + // Verify subscription was renewed with new period + $subscription->refresh(); + expect($subscription->status)->toBe('active') + ->and($subscription->current_period_start->timestamp)->toBe($periodStart) + ->and($subscription->current_period_end->timestamp)->toBe($periodEnd); + + // Verify payment record created + $payment = Payment::where('gateway', 'stripe') + ->where('gateway_payment_id', 'pi_renew_001') + ->first(); + expect($payment)->not->toBeNull() + ->and($payment->amount)->toBe('19.00') + ->and($payment->currency)->toBe('GBP') + ->and($payment->status)->toBe('succeeded'); + + // Verify webhook event logged + $webhookEvent = WebhookEvent::forGateway('stripe')->latest()->first(); + expect($webhookEvent)->not->toBeNull() + ->and($webhookEvent->event_type)->toBe('invoice.paid') + ->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_PROCESSED); + }); + + it('returns OK for non-subscription invoices', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.paid', + 'id' => 'evt_inv_paid_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'in_one_time_001', + // No subscription field + 'amount_paid' => 5000, + 'currency' => 'gbp', + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('OK'); + }); + + it('handles missing subscription gracefully', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.paid', + 'id' => 'evt_inv_paid_003', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'in_test_003', + 'subscription' => 'sub_nonexistent', + 'amount_paid' => 1900, + 'currency' => 'gbp', + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Subscription not found'); + }); +}); + +// ============================================================================ +// Payment Failed (invoice.payment_failed) +// ============================================================================ + +describe('invoice.payment_failed event', function () { + it('marks subscription as past due and sends notification', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_fail_001', + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'current_period_start' => now(), + 'current_period_end' => now()->addMonth(), + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.payment_failed', + 'id' => 'evt_inv_fail_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'in_fail_001', + 'subscription' => 'sub_fail_001', + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $subscription->refresh(); + expect($subscription->status)->toBe('past_due'); + + Notification::assertSentTo($this->user, PaymentFailed::class); + }); + + it('handles missing subscription without error', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.payment_failed', + 'id' => 'evt_inv_fail_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'in_fail_002', + 'subscription' => 'sub_nonexistent', + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + Notification::assertNothingSent(); + }); + + it('returns OK for non-subscription invoice failures', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.payment_failed', + 'id' => 'evt_inv_fail_003', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'in_fail_003', + // No subscription field + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('OK'); + }); +}); + +// ============================================================================ +// Subscription Lifecycle Events +// ============================================================================ + +describe('customer.subscription.created event', function () { + it('returns OK as fallback handler', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'customer.subscription.created', + 'id' => 'evt_sub_created_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'sub_new_001', + 'status' => 'active', + 'current_period_start' => now()->timestamp, + 'current_period_end' => now()->addMonth()->timestamp, + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('OK'); + }); +}); + +describe('customer.subscription.updated event', function () { + it('updates subscription status and period', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_update_001', + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'current_period_start' => now()->subMonth(), + 'current_period_end' => now(), + ]); + + $newPeriodStart = now()->timestamp; + $newPeriodEnd = now()->addMonth()->timestamp; + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'customer.subscription.updated', + 'id' => 'evt_sub_update_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'sub_update_001', + 'status' => 'active', + 'cancel_at_period_end' => false, + 'current_period_start' => $newPeriodStart, + 'current_period_end' => $newPeriodEnd, + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $subscription->refresh(); + expect($subscription->status)->toBe('active') + ->and($subscription->cancel_at_period_end)->toBeFalse() + ->and($subscription->current_period_start->timestamp)->toBe($newPeriodStart) + ->and($subscription->current_period_end->timestamp)->toBe($newPeriodEnd); + }); + + it('handles cancel_at_period_end flag', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_cancel_end_001', + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'cancel_at_period_end' => false, + 'current_period_start' => now(), + 'current_period_end' => now()->addMonth(), + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'customer.subscription.updated', + 'id' => 'evt_sub_update_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'sub_cancel_end_001', + 'status' => 'active', + 'cancel_at_period_end' => true, + 'current_period_start' => now()->timestamp, + 'current_period_end' => now()->addMonth()->timestamp, + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $subscription->refresh(); + expect($subscription->cancel_at_period_end)->toBeTrue(); + }); + + it('maps paused status correctly', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_pause_001', + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'current_period_start' => now(), + 'current_period_end' => now()->addMonth(), + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'customer.subscription.updated', + 'id' => 'evt_sub_update_003', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'sub_pause_001', + 'status' => 'paused', + 'cancel_at_period_end' => false, + 'current_period_start' => now()->timestamp, + 'current_period_end' => now()->addMonth()->timestamp, + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $subscription->refresh(); + expect($subscription->status)->toBe('paused'); + }); + + it('handles missing subscription gracefully', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'customer.subscription.updated', + 'id' => 'evt_sub_update_004', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'sub_nonexistent', + 'status' => 'active', + 'cancel_at_period_end' => false, + 'current_period_start' => now()->timestamp, + 'current_period_end' => now()->addMonth()->timestamp, + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Subscription not found'); + }); +}); + +describe('customer.subscription.deleted event', function () { + it('cancels subscription, revokes entitlements and notifies owner', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_delete_001', + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'current_period_start' => now(), + 'current_period_end' => now()->addMonth(), + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'customer.subscription.deleted', + 'id' => 'evt_sub_delete_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'sub_delete_001', + 'status' => 'canceled', + ], + ], + ], + ]); + + $mockEntitlements = Mockery::mock(EntitlementService::class); + $mockEntitlements->shouldReceive('revokePackage') + ->once() + ->with( + Mockery::on(fn ($ws) => $ws->id === $this->workspace->id), + $this->package->code + ); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'entitlements' => $mockEntitlements, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $subscription->refresh(); + expect($subscription->status)->toBe('cancelled') + ->and($subscription->ended_at)->not->toBeNull(); + + Notification::assertSentTo($this->user, SubscriptionCancelled::class); + }); + + it('handles missing subscription without error', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'customer.subscription.deleted', + 'id' => 'evt_sub_delete_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'sub_nonexistent', + 'status' => 'canceled', + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + Notification::assertNothingSent(); + }); +}); + +// ============================================================================ +// Payment Method Events +// ============================================================================ + +describe('payment_method.attached event', function () { + it('creates payment method record for known workspace', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_method.attached', + 'id' => 'evt_pm_attach_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pm_test_visa_001', + 'customer' => 'cus_test_123', + 'type' => 'card', + 'card' => [ + 'brand' => 'visa', + 'last4' => '4242', + 'exp_month' => 12, + 'exp_year' => 2028, + ], + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $paymentMethod = PaymentMethod::where('gateway', 'stripe') + ->where('gateway_payment_method_id', 'pm_test_visa_001') + ->first(); + expect($paymentMethod)->not->toBeNull() + ->and($paymentMethod->workspace_id)->toBe($this->workspace->id) + ->and($paymentMethod->type)->toBe('card') + ->and($paymentMethod->brand)->toBe('visa') + ->and($paymentMethod->last_four)->toBe('4242') + ->and($paymentMethod->exp_month)->toBe(12) + ->and($paymentMethod->exp_year)->toBe(2028) + ->and($paymentMethod->is_default)->toBeFalse(); + }); + + it('does not duplicate existing payment methods', function () { + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_existing_001', + 'type' => 'card', + 'brand' => 'visa', + 'last_four' => '4242', + 'is_default' => true, + 'is_active' => true, + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_method.attached', + 'id' => 'evt_pm_attach_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pm_existing_001', + 'customer' => 'cus_test_123', + 'type' => 'card', + 'card' => [ + 'brand' => 'visa', + 'last4' => '4242', + 'exp_month' => 12, + 'exp_year' => 2028, + ], + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $count = PaymentMethod::where('gateway_payment_method_id', 'pm_existing_001')->count(); + expect($count)->toBe(1); + }); + + it('returns OK when workspace not found', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_method.attached', + 'id' => 'evt_pm_attach_003', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pm_orphan_001', + 'customer' => 'cus_unknown', + 'type' => 'card', + 'card' => [ + 'brand' => 'mastercard', + 'last4' => '5555', + ], + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Workspace not found'); + }); + + it('returns OK when no customer ID present', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_method.attached', + 'id' => 'evt_pm_attach_004', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pm_no_cus_001', + // No customer field + 'type' => 'card', + 'card' => ['brand' => 'visa', 'last4' => '1234'], + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('OK'); + }); +}); + +describe('payment_method.detached event', function () { + it('deactivates payment method', function () { + $paymentMethod = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_detach_001', + 'type' => 'card', + 'brand' => 'visa', + 'last_four' => '4242', + 'is_default' => false, + 'is_active' => true, + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_method.detached', + 'id' => 'evt_pm_detach_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pm_detach_001', + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $paymentMethod->refresh(); + expect($paymentMethod->is_active)->toBeFalse(); + }); + + it('handles unknown payment method gracefully', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_method.detached', + 'id' => 'evt_pm_detach_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pm_nonexistent', + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + }); +}); + +describe('payment_method.updated event', function () { + it('updates card details when expiry changes', function () { + $paymentMethod = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_update_001', + 'type' => 'card', + 'brand' => 'visa', + 'last_four' => '4242', + 'exp_month' => 6, + 'exp_year' => 2026, + 'is_default' => true, + 'is_active' => true, + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_method.updated', + 'id' => 'evt_pm_update_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pm_update_001', + 'card' => [ + 'brand' => 'visa', + 'last4' => '4242', + 'exp_month' => 12, + 'exp_year' => 2029, + ], + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $paymentMethod->refresh(); + expect($paymentMethod->exp_month)->toBe(12) + ->and($paymentMethod->exp_year)->toBe(2029); + }); + + it('handles missing payment method gracefully', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_method.updated', + 'id' => 'evt_pm_update_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pm_nonexistent', + 'card' => [ + 'brand' => 'mastercard', + 'last4' => '5555', + 'exp_month' => 1, + 'exp_year' => 2030, + ], + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + }); +}); + +// ============================================================================ +// Charge & Payment Intent Events (Stripe Radar / Fraud Scoring) +// ============================================================================ + +describe('charge.succeeded event', function () { + it('processes Stripe Radar outcome on matching payment', function () { + $payment = Payment::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_id' => 'pi_radar_001', + 'amount' => 49.00, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'paid_at' => now(), + ]); + + $radarOutcome = [ + 'network_status' => 'approved_by_network', + 'risk_level' => 'normal', + 'risk_score' => 12, + 'seller_message' => 'Payment complete.', + 'type' => 'authorized', + ]; + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'charge.succeeded', + 'id' => 'evt_charge_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'ch_test_001', + 'payment_intent' => 'pi_radar_001', + 'outcome' => $radarOutcome, + ], + ], + ], + ]); + + $mockFraud = Mockery::mock(FraudService::class); + $mockFraud->shouldReceive('processStripeRadarOutcome') + ->once() + ->with( + Mockery::on(fn ($p) => $p->id === $payment->id), + $radarOutcome + ); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'fraud' => $mockFraud, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('OK'); + }); + + it('finds payment by charge ID when payment_intent is missing', function () { + $payment = Payment::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_id' => 'ch_direct_001', + 'amount' => 25.00, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'paid_at' => now(), + ]); + + $radarOutcome = [ + 'risk_level' => 'elevated', + 'risk_score' => 55, + 'type' => 'authorized', + ]; + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'charge.succeeded', + 'id' => 'evt_charge_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'ch_direct_001', + // No payment_intent + 'outcome' => $radarOutcome, + ], + ], + ], + ]); + + $mockFraud = Mockery::mock(FraudService::class); + $mockFraud->shouldReceive('processStripeRadarOutcome')->once(); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'fraud' => $mockFraud, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + }); + + it('skips when no Radar outcome present', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'charge.succeeded', + 'id' => 'evt_charge_003', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'ch_no_radar_001', + 'payment_intent' => 'pi_no_radar_001', + // No outcome + ], + ], + ], + ]); + + $mockFraud = Mockery::mock(FraudService::class); + $mockFraud->shouldNotReceive('processStripeRadarOutcome'); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'fraud' => $mockFraud, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('OK (no Radar data)'); + }); +}); + +describe('payment_intent.succeeded event', function () { + it('processes Radar outcome from charge data', function () { + $payment = Payment::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_id' => 'pi_intent_001', + 'amount' => 99.00, + 'currency' => 'GBP', + 'status' => 'succeeded', + 'paid_at' => now(), + ]); + + $radarOutcome = [ + 'risk_level' => 'normal', + 'risk_score' => 8, + 'type' => 'authorized', + ]; + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_intent.succeeded', + 'id' => 'evt_pi_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pi_intent_001', + 'charges' => [ + 'data' => [ + [ + 'id' => 'ch_from_pi_001', + 'outcome' => $radarOutcome, + ], + ], + ], + ], + ], + ], + ]); + + $mockFraud = Mockery::mock(FraudService::class); + $mockFraud->shouldReceive('processStripeRadarOutcome') + ->once() + ->with( + Mockery::on(fn ($p) => $p->id === $payment->id), + $radarOutcome + ); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'fraud' => $mockFraud, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + }); + + it('skips when no charges present', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_intent.succeeded', + 'id' => 'evt_pi_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pi_no_charges_001', + // No charges array + ], + ], + ], + ]); + + $mockFraud = Mockery::mock(FraudService::class); + $mockFraud->shouldNotReceive('processStripeRadarOutcome'); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'fraud' => $mockFraud, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('OK (no charges)'); + }); + + it('skips when charges have no Radar outcome', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'payment_intent.succeeded', + 'id' => 'evt_pi_003', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'pi_no_outcome_001', + 'charges' => [ + 'data' => [ + [ + 'id' => 'ch_no_outcome_001', + // No outcome field + ], + ], + ], + ], + ], + ], + ]); + + $mockFraud = Mockery::mock(FraudService::class); + $mockFraud->shouldNotReceive('processStripeRadarOutcome'); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'fraud' => $mockFraud, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('OK (no Radar data)'); + }); +}); + +// ============================================================================ +// Setup Intent +// ============================================================================ + +describe('setup_intent.succeeded event', function () { + it('creates payment method via gateway when not already stored', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'setup_intent.succeeded', + 'id' => 'evt_setup_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'seti_test_001', + 'customer' => 'cus_test_123', + 'payment_method' => 'pm_setup_001', + ], + ], + ], + ]); + $mockGateway->shouldReceive('attachPaymentMethod') + ->once() + ->with( + Mockery::on(fn ($ws) => $ws->id === $this->workspace->id), + 'pm_setup_001' + ); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + }); + + it('skips if payment method already exists', function () { + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_setup_existing', + 'type' => 'card', + 'is_default' => false, + 'is_active' => true, + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'setup_intent.succeeded', + 'id' => 'evt_setup_002', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'seti_test_002', + 'customer' => 'cus_test_123', + 'payment_method' => 'pm_setup_existing', + ], + ], + ], + ]); + $mockGateway->shouldNotReceive('attachPaymentMethod'); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + }); + + it('returns OK when customer or payment_method missing', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'setup_intent.succeeded', + 'id' => 'evt_setup_003', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'seti_test_003', + // No customer or payment_method + ], + ], + ], + ]); + $mockGateway->shouldNotReceive('attachPaymentMethod'); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('OK'); + }); + + it('handles workspace not found', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'setup_intent.succeeded', + 'id' => 'evt_setup_004', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'seti_test_004', + 'customer' => 'cus_unknown', + 'payment_method' => 'pm_setup_004', + ], + ], + ], + ]); + $mockGateway->shouldNotReceive('attachPaymentMethod'); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Workspace not found'); + }); + + it('handles gateway exception when attaching payment method', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'setup_intent.succeeded', + 'id' => 'evt_setup_005', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'seti_test_005', + 'customer' => 'cus_test_123', + 'payment_method' => 'pm_setup_fail', + ], + ], + ], + ]); + $mockGateway->shouldReceive('attachPaymentMethod') + ->once() + ->andThrow(new RuntimeException('Stripe API error')); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + // Should still succeed (error is caught internally) + expect($response->getStatusCode())->toBe(200); + }); +}); + +// ============================================================================ +// Checkout Session with Subscription +// ============================================================================ + +describe('checkout.session.completed with subscription', function () { + it('creates subscription from checkout session', function () { + $order = Order::create([ + 'workspace_id' => $this->workspace->id, + 'order_number' => 'ORD-SUB-001', + 'gateway' => 'stripe', + 'gateway_session_id' => 'cs_sub_001', + 'subtotal' => 19.00, + 'tax_amount' => 3.80, + 'total' => 22.80, + 'currency' => 'GBP', + 'status' => 'pending', + ]); + + OrderItem::create([ + 'order_id' => $order->id, + 'name' => 'Creator Plan', + 'description' => 'Monthly subscription', + 'quantity' => 1, + 'unit_price' => 19.00, + 'total' => 19.00, + 'type' => 'package', + 'package_id' => $this->package->id, + ]); + + $periodStart = now()->timestamp; + $periodEnd = now()->addMonth()->timestamp; + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'checkout.session.completed', + 'id' => 'evt_cs_sub_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'cs_sub_001', + 'payment_intent' => 'pi_sub_001', + 'amount_total' => 2280, + 'currency' => 'gbp', + 'metadata' => ['order_id' => $order->id], + 'subscription' => 'sub_from_checkout_001', + 'customer' => 'cus_test_123', + ], + ], + ], + ]); + // getInvoice is called to fetch subscription details + $mockGateway->shouldReceive('getInvoice')->andReturn([ + 'id' => 'sub_from_checkout_001', + 'status' => 'active', + 'current_period_start' => $periodStart, + 'current_period_end' => $periodEnd, + 'items' => [ + 'data' => [ + ['price' => ['id' => 'price_creator_monthly']], + ], + ], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockCommerce->shouldReceive('fulfillOrder')->once(); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'commerce' => $mockCommerce, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + // Verify subscription was created + $subscription = Subscription::where('gateway_subscription_id', 'sub_from_checkout_001')->first(); + expect($subscription)->not->toBeNull() + ->and($subscription->gateway)->toBe('stripe') + ->and($subscription->gateway_customer_id)->toBe('cus_test_123') + ->and($subscription->status)->toBe('active') + ->and($subscription->gateway_price_id)->toBe('price_creator_monthly'); + }); +}); + +// ============================================================================ +// Idempotency & Duplicate Event Handling +// ============================================================================ + +describe('idempotency', function () { + it('skips duplicate events that were already processed', function () { + // Pre-create a processed webhook event + WebhookEvent::create([ + 'gateway' => 'stripe', + 'event_id' => 'evt_duplicate_001', + 'event_type' => 'invoice.paid', + 'payload' => '{}', + 'status' => WebhookEvent::STATUS_PROCESSED, + 'received_at' => now(), + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.paid', + 'id' => 'evt_duplicate_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'in_dup_001', + 'subscription' => 'sub_dup_001', + ], + ], + ], + ]); + + $mockInvoice = Mockery::mock(InvoiceService::class); + $mockInvoice->shouldNotReceive('createForRenewal'); + + $controller = makeStripeController([ + 'gateway' => $mockGateway, + 'invoice' => $mockInvoice, + ]); + + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getContent())->toBe('Already processed (duplicate)'); + }); +}); + +// ============================================================================ +// Webhook Audit Trail +// ============================================================================ + +describe('webhook audit trail', function () { + it('logs webhook event with correct status on success', function () { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => 'sub_audit_001', + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'current_period_start' => now(), + 'current_period_end' => now()->addMonth(), + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.payment_failed', + 'id' => 'evt_audit_001', + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => 'in_audit_001', + 'subscription' => 'sub_audit_001', + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + $webhookEvent = WebhookEvent::where('event_id', 'evt_audit_001')->first(); + expect($webhookEvent)->not->toBeNull() + ->and($webhookEvent->gateway)->toBe('stripe') + ->and($webhookEvent->event_type)->toBe('invoice.payment_failed') + ->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_PROCESSED); + }); + + it('logs webhook event as skipped for unhandled types', function () { + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'radar.early_fraud_warning.created', + 'id' => 'evt_audit_002', + 'raw' => [], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + expect($response->getStatusCode())->toBe(200); + + $webhookEvent = WebhookEvent::where('event_id', 'evt_audit_002')->first(); + expect($webhookEvent)->not->toBeNull() + ->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_SKIPPED); + }); +}); + +// ============================================================================ +// Stripe Status Mapping +// ============================================================================ + +describe('Stripe status mapping', function () { + it('maps all Stripe subscription statuses correctly', function () { + $statusMappings = [ + 'active' => 'active', + 'trialing' => 'trialing', + 'past_due' => 'past_due', + 'paused' => 'paused', + 'canceled' => 'cancelled', + 'cancelled' => 'cancelled', + 'incomplete' => 'incomplete', + 'incomplete_expired' => 'incomplete', + ]; + + foreach ($statusMappings as $stripeStatus => $expectedStatus) { + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $this->package->id, + 'status' => 'active', + ]); + + $subscription = Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'gateway' => 'stripe', + 'gateway_subscription_id' => "sub_status_{$stripeStatus}", + 'gateway_customer_id' => 'cus_test_123', + 'status' => 'active', + 'billing_cycle' => 'monthly', + 'current_period_start' => now(), + 'current_period_end' => now()->addMonth(), + ]); + + $mockGateway = Mockery::mock(StripeGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'customer.subscription.updated', + 'id' => "evt_status_{$stripeStatus}", + 'raw' => [ + 'data' => [ + 'object' => [ + 'id' => "sub_status_{$stripeStatus}", + 'status' => $stripeStatus, + 'cancel_at_period_end' => false, + 'current_period_start' => now()->timestamp, + 'current_period_end' => now()->addMonth()->timestamp, + ], + ], + ], + ]); + + $controller = makeStripeController(['gateway' => $mockGateway]); + $response = $controller->handle(stripeRequest()); + + $subscription->refresh(); + expect($subscription->status)->toBe($expectedStatus, "Stripe status '{$stripeStatus}' should map to '{$expectedStatus}'"); + } + }); +});