Align commerce module with the monorepo module structure by updating all namespaces to use the Core\Mod\Commerce convention. This change supports the recent monorepo separation and ensures consistency with other modules. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1001 lines
38 KiB
PHP
1001 lines
38 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Core\Mod\Commerce\Controllers\Webhooks\BTCPayWebhookController;
|
|
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\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\InvoiceService;
|
|
use Core\Mod\Commerce\Services\PaymentGateway\BTCPayGateway;
|
|
use Core\Mod\Commerce\Services\PaymentGateway\StripeGateway;
|
|
use Core\Mod\Commerce\Services\WebhookLogger;
|
|
use Core\Mod\Tenant\Models\Package;
|
|
use Core\Mod\Tenant\Models\User;
|
|
use Core\Mod\Tenant\Models\Workspace;
|
|
use Core\Mod\Tenant\Models\WorkspacePackage;
|
|
use Core\Mod\Tenant\Services\EntitlementService;
|
|
|
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Notification::fake();
|
|
|
|
$this->user = User::factory()->create();
|
|
$this->workspace = Workspace::factory()->create([
|
|
'stripe_customer_id' => 'cus_test_123',
|
|
'btcpay_customer_id' => 'btc_cus_test_123',
|
|
]);
|
|
$this->workspace->users()->attach($this->user->id, [
|
|
'role' => 'owner',
|
|
'is_default' => true,
|
|
]);
|
|
});
|
|
|
|
// ============================================================================
|
|
// Stripe Webhook Tests
|
|
// ============================================================================
|
|
|
|
describe('StripeWebhookController', function () {
|
|
beforeEach(function () {
|
|
$this->order = Order::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'order_number' => 'ORD-TEST-001',
|
|
'gateway' => 'stripe',
|
|
'gateway_session_id' => 'cs_test_123',
|
|
'subtotal' => 49.00,
|
|
'tax_amount' => 9.80,
|
|
'total' => 58.80,
|
|
'currency' => 'GBP',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
OrderItem::create([
|
|
'order_id' => $this->order->id,
|
|
'name' => 'Creator Plan',
|
|
'description' => 'Monthly subscription',
|
|
'quantity' => 1,
|
|
'unit_price' => 49.00,
|
|
'total' => 49.00,
|
|
'type' => 'package',
|
|
]);
|
|
});
|
|
|
|
describe('signature verification', function () {
|
|
it('rejects requests with invalid signature', function () {
|
|
$response = $this->postJson(route('api.webhook.stripe'), [
|
|
'type' => 'checkout.session.completed',
|
|
], [
|
|
'Stripe-Signature' => 'invalid_signature',
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
});
|
|
|
|
it('rejects requests without signature', function () {
|
|
$response = $this->postJson(route('api.webhook.stripe'), [
|
|
'type' => 'checkout.session.completed',
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
});
|
|
});
|
|
|
|
describe('checkout.session.completed event', function () {
|
|
it('fulfils order on successful checkout', function () {
|
|
$mockGateway = Mockery::mock(StripeGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'checkout.session.completed',
|
|
'id' => 'cs_test_123',
|
|
'metadata' => ['order_id' => $this->order->id],
|
|
'raw' => [
|
|
'data' => [
|
|
'object' => [
|
|
'id' => 'cs_test_123',
|
|
'payment_intent' => 'pi_test_123',
|
|
'amount_total' => 5880,
|
|
'currency' => 'gbp',
|
|
'metadata' => ['order_id' => $this->order->id],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$mockCommerce->shouldReceive('fulfillOrder')->once();
|
|
|
|
$mockInvoice = Mockery::mock(InvoiceService::class);
|
|
$mockEntitlements = Mockery::mock(EntitlementService::class);
|
|
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new StripeWebhookController(
|
|
$mockGateway,
|
|
$mockCommerce,
|
|
$mockInvoice,
|
|
$mockEntitlements,
|
|
$webhookLogger
|
|
);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$request->headers->set('Stripe-Signature', 't=123,v1=abc');
|
|
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
|
|
// Verify payment was created
|
|
$payment = Payment::where('order_id', $this->order->id)->first();
|
|
expect($payment)->not->toBeNull()
|
|
->and($payment->gateway)->toBe('stripe')
|
|
->and($payment->status)->toBe('succeeded');
|
|
|
|
// Verify webhook event was logged
|
|
$webhookEvent = WebhookEvent::forGateway('stripe')->latest()->first();
|
|
expect($webhookEvent)->not->toBeNull()
|
|
->and($webhookEvent->event_type)->toBe('checkout.session.completed')
|
|
->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_PROCESSED);
|
|
});
|
|
|
|
it('skips already paid orders', function () {
|
|
$this->order->update(['status' => 'paid', 'paid_at' => now()]);
|
|
|
|
$mockGateway = Mockery::mock(StripeGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'checkout.session.completed',
|
|
'id' => 'cs_test_123',
|
|
'raw' => [
|
|
'data' => [
|
|
'object' => [
|
|
'id' => 'cs_test_123',
|
|
'metadata' => ['order_id' => $this->order->id],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$mockCommerce->shouldNotReceive('fulfillOrder');
|
|
|
|
$mockInvoice = Mockery::mock(InvoiceService::class);
|
|
$mockEntitlements = Mockery::mock(EntitlementService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new StripeWebhookController(
|
|
$mockGateway,
|
|
$mockCommerce,
|
|
$mockInvoice,
|
|
$mockEntitlements,
|
|
$webhookLogger
|
|
);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200)
|
|
->and($response->getContent())->toBe('Already processed');
|
|
});
|
|
|
|
it('handles missing order gracefully', function () {
|
|
$mockGateway = Mockery::mock(StripeGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'checkout.session.completed',
|
|
'id' => 'cs_test_123',
|
|
'raw' => [
|
|
'data' => [
|
|
'object' => [
|
|
'id' => 'cs_test_123',
|
|
'metadata' => ['order_id' => 99999],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$mockInvoice = Mockery::mock(InvoiceService::class);
|
|
$mockEntitlements = Mockery::mock(EntitlementService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new StripeWebhookController(
|
|
$mockGateway,
|
|
$mockCommerce,
|
|
$mockInvoice,
|
|
$mockEntitlements,
|
|
$webhookLogger
|
|
);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200)
|
|
->and($response->getContent())->toBe('Order not found');
|
|
});
|
|
});
|
|
|
|
describe('invoice.payment_failed event', function () {
|
|
it('marks subscription as past due and notifies owner', function () {
|
|
$package = Package::where('code', 'creator')->first();
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $package->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = Subscription::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'workspace_package_id' => $workspacePackage->id,
|
|
'gateway' => 'stripe',
|
|
'gateway_subscription_id' => 'sub_test_123',
|
|
'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' => 'in_test_123',
|
|
'raw' => [
|
|
'data' => [
|
|
'object' => [
|
|
'id' => 'in_test_123',
|
|
'subscription' => 'sub_test_123',
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$mockInvoice = Mockery::mock(InvoiceService::class);
|
|
$mockEntitlements = Mockery::mock(EntitlementService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new StripeWebhookController(
|
|
$mockGateway,
|
|
$mockCommerce,
|
|
$mockInvoice,
|
|
$mockEntitlements,
|
|
$webhookLogger
|
|
);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
|
|
$subscription->refresh();
|
|
expect($subscription->status)->toBe('past_due');
|
|
|
|
Notification::assertSentTo($this->user, PaymentFailed::class);
|
|
});
|
|
});
|
|
|
|
describe('customer.subscription.deleted event', function () {
|
|
it('cancels subscription and revokes entitlements', function () {
|
|
$package = Package::where('code', 'creator')->first();
|
|
$workspacePackage = WorkspacePackage::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'package_id' => $package->id,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$subscription = Subscription::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'workspace_package_id' => $workspacePackage->id,
|
|
'gateway' => 'stripe',
|
|
'gateway_subscription_id' => 'sub_test_456',
|
|
'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' => 'sub_test_456',
|
|
'raw' => [
|
|
'data' => [
|
|
'object' => [
|
|
'id' => 'sub_test_456',
|
|
'status' => 'canceled',
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$mockInvoice = Mockery::mock(InvoiceService::class);
|
|
$mockEntitlements = Mockery::mock(EntitlementService::class);
|
|
$mockEntitlements->shouldReceive('revokePackage')->once();
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new StripeWebhookController(
|
|
$mockGateway,
|
|
$mockCommerce,
|
|
$mockInvoice,
|
|
$mockEntitlements,
|
|
$webhookLogger
|
|
);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
|
|
$subscription->refresh();
|
|
expect($subscription->status)->toBe('cancelled')
|
|
->and($subscription->ended_at)->not->toBeNull();
|
|
|
|
Notification::assertSentTo($this->user, SubscriptionCancelled::class);
|
|
});
|
|
});
|
|
|
|
describe('unhandled events', function () {
|
|
it('returns 200 for unknown event types', function () {
|
|
$mockGateway = Mockery::mock(StripeGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'some.unknown.event',
|
|
'id' => 'evt_test_123',
|
|
'raw' => [],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$mockInvoice = Mockery::mock(InvoiceService::class);
|
|
$mockEntitlements = Mockery::mock(EntitlementService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new StripeWebhookController(
|
|
$mockGateway,
|
|
$mockCommerce,
|
|
$mockInvoice,
|
|
$mockEntitlements,
|
|
$webhookLogger
|
|
);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200)
|
|
->and($response->getContent())->toBe('Unhandled event type');
|
|
|
|
// Verify webhook event was logged as skipped
|
|
$webhookEvent = WebhookEvent::forGateway('stripe')->latest()->first();
|
|
expect($webhookEvent)->not->toBeNull()
|
|
->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_SKIPPED);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// BTCPay Webhook Tests
|
|
// ============================================================================
|
|
|
|
describe('BTCPayWebhookController', function () {
|
|
beforeEach(function () {
|
|
$this->order = Order::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'order_number' => 'ORD-BTC-001',
|
|
'gateway' => 'btcpay',
|
|
'gateway_session_id' => 'btc_invoice_123',
|
|
'subtotal' => 49.00,
|
|
'tax_amount' => 9.80,
|
|
'total' => 58.80,
|
|
'currency' => 'GBP',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
OrderItem::create([
|
|
'order_id' => $this->order->id,
|
|
'name' => 'Creator Plan',
|
|
'description' => 'Monthly subscription',
|
|
'quantity' => 1,
|
|
'unit_price' => 49.00,
|
|
'total' => 49.00,
|
|
'type' => 'package',
|
|
]);
|
|
});
|
|
|
|
describe('signature verification', function () {
|
|
it('rejects requests with invalid signature', function () {
|
|
$response = $this->postJson(route('api.webhook.btcpay'), [
|
|
'type' => 'InvoiceSettled',
|
|
], [
|
|
'BTCPay-Sig' => 'invalid_signature',
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
});
|
|
|
|
it('rejects requests without signature', function () {
|
|
$response = $this->postJson(route('api.webhook.btcpay'), [
|
|
'type' => 'InvoiceSettled',
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
});
|
|
});
|
|
|
|
describe('invoice.paid event (InvoiceSettled)', function () {
|
|
it('fulfils order on successful payment', function () {
|
|
$mockGateway = Mockery::mock(BTCPayGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'invoice.paid',
|
|
'id' => 'btc_invoice_123',
|
|
'status' => 'succeeded',
|
|
'metadata' => [],
|
|
'raw' => [
|
|
'invoiceId' => 'btc_invoice_123',
|
|
'type' => 'InvoiceSettled',
|
|
],
|
|
]);
|
|
$mockGateway->shouldReceive('getCheckoutSession')->andReturn([
|
|
'id' => 'btc_invoice_123',
|
|
'status' => 'succeeded',
|
|
'amount' => 58.80,
|
|
'currency' => 'GBP',
|
|
'raw' => ['invoiceId' => 'btc_invoice_123'],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$mockCommerce->shouldReceive('fulfillOrder')->once();
|
|
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$request->headers->set('BTCPay-Sig', 'valid_signature');
|
|
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
|
|
// Verify payment was created
|
|
$payment = Payment::where('order_id', $this->order->id)->first();
|
|
expect($payment)->not->toBeNull()
|
|
->and($payment->gateway)->toBe('btcpay')
|
|
->and($payment->status)->toBe('succeeded');
|
|
|
|
// Verify webhook event was logged
|
|
$webhookEvent = WebhookEvent::forGateway('btcpay')->latest()->first();
|
|
expect($webhookEvent)->not->toBeNull()
|
|
->and($webhookEvent->event_type)->toBe('invoice.paid')
|
|
->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_PROCESSED);
|
|
});
|
|
|
|
it('skips already paid orders', function () {
|
|
$this->order->update(['status' => 'paid', 'paid_at' => now()]);
|
|
|
|
$mockGateway = Mockery::mock(BTCPayGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'invoice.paid',
|
|
'id' => 'btc_invoice_123',
|
|
'status' => 'succeeded',
|
|
'metadata' => [],
|
|
'raw' => [],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$mockCommerce->shouldNotReceive('fulfillOrder');
|
|
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200)
|
|
->and($response->getContent())->toBe('Already processed');
|
|
});
|
|
|
|
it('handles missing order gracefully', function () {
|
|
$mockGateway = Mockery::mock(BTCPayGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'invoice.paid',
|
|
'id' => 'btc_invoice_nonexistent',
|
|
'status' => 'succeeded',
|
|
'metadata' => [],
|
|
'raw' => [],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200)
|
|
->and($response->getContent())->toBe('Order not found');
|
|
});
|
|
});
|
|
|
|
describe('invoice.expired event', function () {
|
|
it('marks order as failed when invoice expires', function () {
|
|
$mockGateway = Mockery::mock(BTCPayGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'invoice.expired',
|
|
'id' => 'btc_invoice_123',
|
|
'status' => 'expired',
|
|
'metadata' => [],
|
|
'raw' => [],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
|
|
$this->order->refresh();
|
|
expect($this->order->status)->toBe('failed');
|
|
});
|
|
|
|
it('does not mark paid orders as failed', function () {
|
|
$this->order->update(['status' => 'paid', 'paid_at' => now()]);
|
|
|
|
$mockGateway = Mockery::mock(BTCPayGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'invoice.expired',
|
|
'id' => 'btc_invoice_123',
|
|
'status' => 'expired',
|
|
'metadata' => [],
|
|
'raw' => [],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
|
|
$this->order->refresh();
|
|
expect($this->order->status)->toBe('paid');
|
|
});
|
|
});
|
|
|
|
describe('invoice.failed event', function () {
|
|
it('marks order as failed when payment is rejected', function () {
|
|
$mockGateway = Mockery::mock(BTCPayGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'invoice.failed',
|
|
'id' => 'btc_invoice_123',
|
|
'status' => 'failed',
|
|
'metadata' => [],
|
|
'raw' => [],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
|
|
$this->order->refresh();
|
|
expect($this->order->status)->toBe('failed');
|
|
});
|
|
});
|
|
|
|
describe('invoice.processing event', function () {
|
|
it('marks order as processing when payment is being confirmed', function () {
|
|
$mockGateway = Mockery::mock(BTCPayGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'invoice.processing',
|
|
'id' => 'btc_invoice_123',
|
|
'status' => 'processing',
|
|
'metadata' => [],
|
|
'raw' => [],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
|
|
$this->order->refresh();
|
|
expect($this->order->status)->toBe('processing');
|
|
});
|
|
});
|
|
|
|
describe('invoice.payment_received event', function () {
|
|
it('marks order as processing when payment is detected', function () {
|
|
$mockGateway = Mockery::mock(BTCPayGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'invoice.payment_received',
|
|
'id' => 'btc_invoice_123',
|
|
'status' => 'processing',
|
|
'metadata' => [],
|
|
'raw' => [],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
|
|
$this->order->refresh();
|
|
expect($this->order->status)->toBe('processing');
|
|
});
|
|
});
|
|
|
|
describe('unhandled events', function () {
|
|
it('returns 200 for unknown event types', function () {
|
|
$mockGateway = Mockery::mock(BTCPayGateway::class);
|
|
$mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true);
|
|
$mockGateway->shouldReceive('parseWebhookEvent')->andReturn([
|
|
'type' => 'some.unknown.event',
|
|
'id' => 'btc_invoice_123',
|
|
'status' => 'unknown',
|
|
'metadata' => [],
|
|
'raw' => [],
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$webhookLogger = new WebhookLogger;
|
|
|
|
$controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger);
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$response = $controller->handle($request);
|
|
|
|
expect($response->getStatusCode())->toBe(200)
|
|
->and($response->getContent())->toBe('Unhandled event type');
|
|
|
|
// Verify webhook event was logged as skipped
|
|
$webhookEvent = WebhookEvent::forGateway('btcpay')->latest()->first();
|
|
expect($webhookEvent)->not->toBeNull()
|
|
->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_SKIPPED);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Webhook Event Logging Tests
|
|
// ============================================================================
|
|
|
|
describe('WebhookEvent model', function () {
|
|
it('creates webhook event with all fields', function () {
|
|
$event = WebhookEvent::record(
|
|
gateway: 'stripe',
|
|
eventType: 'checkout.session.completed',
|
|
payload: '{"test": true}',
|
|
eventId: 'evt_test_123',
|
|
headers: ['Content-Type' => 'application/json']
|
|
);
|
|
|
|
expect($event)->toBeInstanceOf(WebhookEvent::class)
|
|
->and($event->gateway)->toBe('stripe')
|
|
->and($event->event_type)->toBe('checkout.session.completed')
|
|
->and($event->event_id)->toBe('evt_test_123')
|
|
->and($event->payload)->toBe('{"test": true}')
|
|
->and($event->headers)->toBe(['Content-Type' => 'application/json'])
|
|
->and($event->status)->toBe(WebhookEvent::STATUS_PENDING)
|
|
->and($event->received_at)->not->toBeNull();
|
|
});
|
|
|
|
it('marks event as processed', function () {
|
|
$event = WebhookEvent::record('stripe', 'test.event', '{}');
|
|
$event->markProcessed(200);
|
|
|
|
expect($event->status)->toBe(WebhookEvent::STATUS_PROCESSED)
|
|
->and($event->http_status_code)->toBe(200)
|
|
->and($event->processed_at)->not->toBeNull();
|
|
});
|
|
|
|
it('marks event as failed with error', function () {
|
|
$event = WebhookEvent::record('stripe', 'test.event', '{}');
|
|
$event->markFailed('Something went wrong', 500);
|
|
|
|
expect($event->status)->toBe(WebhookEvent::STATUS_FAILED)
|
|
->and($event->error_message)->toBe('Something went wrong')
|
|
->and($event->http_status_code)->toBe(500)
|
|
->and($event->processed_at)->not->toBeNull();
|
|
});
|
|
|
|
it('marks event as skipped with reason', function () {
|
|
$event = WebhookEvent::record('stripe', 'test.event', '{}');
|
|
$event->markSkipped('Unhandled event type');
|
|
|
|
expect($event->status)->toBe(WebhookEvent::STATUS_SKIPPED)
|
|
->and($event->error_message)->toBe('Unhandled event type')
|
|
->and($event->http_status_code)->toBe(200);
|
|
});
|
|
|
|
it('checks for duplicate events', function () {
|
|
WebhookEvent::record('stripe', 'test.event', '{}', 'evt_unique_123')
|
|
->markProcessed();
|
|
|
|
expect(WebhookEvent::hasBeenProcessed('stripe', 'evt_unique_123'))->toBeTrue()
|
|
->and(WebhookEvent::hasBeenProcessed('stripe', 'evt_other'))->toBeFalse()
|
|
->and(WebhookEvent::hasBeenProcessed('btcpay', 'evt_unique_123'))->toBeFalse();
|
|
});
|
|
|
|
it('links to order and subscription', function () {
|
|
$order = Order::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'order_number' => 'ORD-LINK-001',
|
|
'subtotal' => 10.00,
|
|
'total' => 10.00,
|
|
'currency' => 'GBP',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$event = WebhookEvent::record('stripe', 'test.event', '{}');
|
|
$event->linkOrder($order);
|
|
|
|
expect($event->order_id)->toBe($order->id)
|
|
->and($event->order)->toBeInstanceOf(Order::class);
|
|
});
|
|
|
|
it('decodes payload correctly', function () {
|
|
$event = WebhookEvent::record('stripe', 'test.event', '{"key": "value", "nested": {"a": 1}}');
|
|
|
|
expect($event->getDecodedPayload())->toBe([
|
|
'key' => 'value',
|
|
'nested' => ['a' => 1],
|
|
]);
|
|
});
|
|
|
|
it('scopes by gateway and status', function () {
|
|
WebhookEvent::record('stripe', 'evt.1', '{}')->markProcessed();
|
|
WebhookEvent::record('stripe', 'evt.2', '{}')->markFailed('err');
|
|
WebhookEvent::record('btcpay', 'evt.3', '{}')->markProcessed();
|
|
|
|
expect(WebhookEvent::forGateway('stripe')->count())->toBe(2)
|
|
->and(WebhookEvent::forGateway('btcpay')->count())->toBe(1)
|
|
->and(WebhookEvent::failed()->count())->toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('WebhookLogger service', function () {
|
|
it('starts and completes webhook logging', function () {
|
|
$logger = new WebhookLogger;
|
|
|
|
$event = $logger->start(
|
|
gateway: 'stripe',
|
|
eventType: 'checkout.session.completed',
|
|
payload: '{"data": "test"}',
|
|
eventId: 'evt_logger_test'
|
|
);
|
|
|
|
expect($event->status)->toBe(WebhookEvent::STATUS_PENDING);
|
|
|
|
$logger->success();
|
|
|
|
$event->refresh();
|
|
expect($event->status)->toBe(WebhookEvent::STATUS_PROCESSED);
|
|
});
|
|
|
|
it('handles failures correctly', function () {
|
|
$logger = new WebhookLogger;
|
|
|
|
$logger->start('btcpay', 'invoice.paid', '{}');
|
|
$logger->fail('Database error', 500);
|
|
|
|
$event = $logger->getCurrentEvent();
|
|
expect($event->status)->toBe(WebhookEvent::STATUS_FAILED)
|
|
->and($event->error_message)->toBe('Database error')
|
|
->and($event->http_status_code)->toBe(500);
|
|
});
|
|
|
|
it('detects duplicate events', function () {
|
|
$logger = new WebhookLogger;
|
|
|
|
// First event
|
|
$logger->start('stripe', 'test.event', '{}', 'evt_dup_test');
|
|
$logger->success();
|
|
|
|
// Check for duplicate
|
|
expect($logger->isDuplicate('stripe', 'evt_dup_test'))->toBeTrue();
|
|
});
|
|
|
|
it('extracts relevant headers', function () {
|
|
$logger = new WebhookLogger;
|
|
|
|
$request = new \Illuminate\Http\Request;
|
|
$request->headers->set('Stripe-Signature', 't=123,v1=secret_signature_here');
|
|
$request->headers->set('Content-Type', 'application/json');
|
|
$request->headers->set('User-Agent', 'Stripe/1.0');
|
|
|
|
$event = $logger->start('stripe', 'test.event', '{}', null, $request);
|
|
|
|
expect($event->headers)->toHaveKey('Content-Type')
|
|
->and($event->headers)->toHaveKey('User-Agent')
|
|
->and($event->headers)->toHaveKey('Stripe-Signature');
|
|
|
|
// Signature should be masked
|
|
expect($event->headers['Stripe-Signature'])->toContain('...');
|
|
});
|
|
|
|
it('gets statistics for webhook events', function () {
|
|
$logger = new WebhookLogger;
|
|
|
|
// Create some events
|
|
WebhookEvent::record('stripe', 'evt.1', '{}')->markProcessed();
|
|
WebhookEvent::record('stripe', 'evt.2', '{}')->markProcessed();
|
|
WebhookEvent::record('stripe', 'evt.3', '{}')->markFailed('err');
|
|
WebhookEvent::record('stripe', 'evt.4', '{}')->markSkipped('skip');
|
|
|
|
$stats = $logger->getStats('stripe');
|
|
|
|
expect($stats['total'])->toBe(4)
|
|
->and($stats['processed'])->toBe(2)
|
|
->and($stats['failed'])->toBe(1)
|
|
->and($stats['skipped'])->toBe(1);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Gateway Webhook Signature Tests
|
|
// ============================================================================
|
|
|
|
describe('BTCPayGateway webhook signature verification', function () {
|
|
it('verifies correct HMAC signature', function () {
|
|
config(['commerce.gateways.btcpay.webhook_secret' => 'test_secret_123']);
|
|
|
|
$gateway = new BTCPayGateway;
|
|
$payload = '{"type":"InvoiceSettled","invoiceId":"123"}';
|
|
$signature = hash_hmac('sha256', $payload, 'test_secret_123');
|
|
|
|
expect($gateway->verifyWebhookSignature($payload, $signature))->toBeTrue();
|
|
});
|
|
|
|
it('verifies signature with sha256= prefix', function () {
|
|
config(['commerce.gateways.btcpay.webhook_secret' => 'test_secret_123']);
|
|
|
|
$gateway = new BTCPayGateway;
|
|
$payload = '{"type":"InvoiceSettled","invoiceId":"123"}';
|
|
$signature = 'sha256='.hash_hmac('sha256', $payload, 'test_secret_123');
|
|
|
|
expect($gateway->verifyWebhookSignature($payload, $signature))->toBeTrue();
|
|
});
|
|
|
|
it('rejects invalid signature', function () {
|
|
config(['commerce.gateways.btcpay.webhook_secret' => 'test_secret_123']);
|
|
|
|
$gateway = new BTCPayGateway;
|
|
$payload = '{"type":"InvoiceSettled","invoiceId":"123"}';
|
|
|
|
expect($gateway->verifyWebhookSignature($payload, 'invalid'))->toBeFalse();
|
|
});
|
|
|
|
it('rejects empty signature', function () {
|
|
config(['commerce.gateways.btcpay.webhook_secret' => 'test_secret_123']);
|
|
|
|
$gateway = new BTCPayGateway;
|
|
$payload = '{"type":"InvoiceSettled","invoiceId":"123"}';
|
|
|
|
expect($gateway->verifyWebhookSignature($payload, ''))->toBeFalse();
|
|
});
|
|
|
|
it('rejects when no webhook secret configured', function () {
|
|
config(['commerce.gateways.btcpay.webhook_secret' => null]);
|
|
|
|
$gateway = new BTCPayGateway;
|
|
$payload = '{"type":"InvoiceSettled","invoiceId":"123"}';
|
|
$signature = hash_hmac('sha256', $payload, 'any_secret');
|
|
|
|
expect($gateway->verifyWebhookSignature($payload, $signature))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('BTCPayGateway webhook event parsing', function () {
|
|
it('parses valid webhook payload', function () {
|
|
$gateway = new BTCPayGateway;
|
|
$payload = json_encode([
|
|
'type' => 'InvoiceSettled',
|
|
'invoiceId' => 'inv_123',
|
|
'status' => 'Settled',
|
|
'metadata' => ['order_id' => 1],
|
|
]);
|
|
|
|
$event = $gateway->parseWebhookEvent($payload);
|
|
|
|
expect($event['type'])->toBe('invoice.paid')
|
|
->and($event['id'])->toBe('inv_123')
|
|
->and($event['status'])->toBe('succeeded')
|
|
->and($event['metadata'])->toBe(['order_id' => 1]);
|
|
});
|
|
|
|
it('handles invalid JSON gracefully', function () {
|
|
$gateway = new BTCPayGateway;
|
|
$payload = 'invalid json {{{';
|
|
|
|
$event = $gateway->parseWebhookEvent($payload);
|
|
|
|
expect($event['type'])->toBe('unknown')
|
|
->and($event['id'])->toBeNull()
|
|
->and($event['raw'])->toBe([]);
|
|
});
|
|
|
|
it('maps event types correctly', function () {
|
|
$gateway = new BTCPayGateway;
|
|
|
|
$testCases = [
|
|
['type' => 'InvoiceCreated', 'expected' => 'invoice.created'],
|
|
['type' => 'InvoiceReceivedPayment', 'expected' => 'invoice.payment_received'],
|
|
['type' => 'InvoiceProcessing', 'expected' => 'invoice.processing'],
|
|
['type' => 'InvoiceExpired', 'expected' => 'invoice.expired'],
|
|
['type' => 'InvoiceSettled', 'expected' => 'invoice.paid'],
|
|
['type' => 'InvoiceInvalid', 'expected' => 'invoice.failed'],
|
|
['type' => 'InvoicePaymentSettled', 'expected' => 'payment.settled'],
|
|
];
|
|
|
|
foreach ($testCases as $case) {
|
|
$event = $gateway->parseWebhookEvent(json_encode(['type' => $case['type']]));
|
|
expect($event['type'])->toBe($case['expected'], "Failed for type: {$case['type']}");
|
|
}
|
|
});
|
|
|
|
it('maps invoice statuses correctly', function () {
|
|
$gateway = new BTCPayGateway;
|
|
|
|
$testCases = [
|
|
['status' => 'New', 'expected' => 'pending'],
|
|
['status' => 'Processing', 'expected' => 'processing'],
|
|
['status' => 'Expired', 'expected' => 'expired'],
|
|
['status' => 'Invalid', 'expected' => 'failed'],
|
|
['status' => 'Settled', 'expected' => 'succeeded'],
|
|
['status' => 'Complete', 'expected' => 'succeeded'],
|
|
];
|
|
|
|
foreach ($testCases as $case) {
|
|
$event = $gateway->parseWebhookEvent(json_encode([
|
|
'type' => 'InvoiceSettled',
|
|
'status' => $case['status'],
|
|
]));
|
|
expect($event['status'])->toBe($case['expected'], "Failed for status: {$case['status']}");
|
|
}
|
|
});
|
|
});
|
|
|
|
afterEach(function () {
|
|
Mockery::close();
|
|
});
|