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>
1558 lines
58 KiB
PHP
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}'");
|
|
}
|
|
});
|
|
});
|