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