php-commerce/tests/Feature/StripeWebhookHandlerTest.php
Claude 96f83eca1b
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) <noreply@anthropic.com>
2026-03-24 16:35:48 +00:00

1558 lines
58 KiB
PHP

<?php
declare(strict_types=1);
use Core\Mod\Commerce\Controllers\Webhooks\StripeWebhookController;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\OrderItem;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\PaymentMethod;
use Core\Mod\Commerce\Models\Subscription;
use Core\Mod\Commerce\Models\WebhookEvent;
use Core\Mod\Commerce\Notifications\PaymentFailed;
use Core\Mod\Commerce\Notifications\SubscriptionCancelled;
use Core\Mod\Commerce\Services\CommerceService;
use Core\Mod\Commerce\Services\FraudService;
use Core\Mod\Commerce\Services\InvoiceService;
use Core\Mod\Commerce\Services\PaymentGateway\StripeGateway;
use Core\Mod\Commerce\Services\WebhookLogger;
use Core\Mod\Commerce\Services\WebhookRateLimiter;
use Core\Tenant\Models\Package;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Models\WorkspacePackage;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
beforeEach(function () {
Notification::fake();
$this->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<string, mixed> $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}'");
}
});
});