php-commerce/tests/Feature/CheckoutFlowTest.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
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>
2026-01-27 16:23:12 +00:00

340 lines
11 KiB
PHP

<?php
use Illuminate\Support\Facades\Cache;
use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Services\CommerceService;
use Core\Mod\Commerce\Services\PaymentGateway\PaymentGatewayContract;
use Core\Mod\Tenant\Models\Package;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Models\WorkspacePackage;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->user = User::factory()->create([
'email' => 'test@example.com',
]);
$this->workspace = Workspace::factory()->create([
'billing_email' => 'billing@example.com',
'billing_name' => 'Test Company',
'billing_country' => 'GB',
]);
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
// Use existing seeded packages or create test package
$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,
]);
}
$this->service = app(CommerceService::class);
});
describe('Order Creation', function () {
it('creates an order for a package purchase', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
expect($order)->toBeInstanceOf(Order::class)
->and($order->orderable_type)->toBe(Workspace::class)
->and($order->orderable_id)->toBe($this->workspace->id)
->and($order->status)->toBe('pending')
->and($order->billing_cycle)->toBe('monthly')
->and($order->billing_email)->toBe('billing@example.com')
->and($order->billing_name)->toBe('Test Company')
->and($order->order_number)->toStartWith('ORD-');
});
it('creates order items for package', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
expect($order->items)->toHaveCount(1);
$item = $order->items->first();
expect($item->item_type)->toBe('package')
->and($item->item_id)->toBe($this->package->id)
->and($item->item_code)->toBe($this->package->code)
->and($item->billing_cycle)->toBe('monthly');
});
it('calculates tax correctly for UK customer', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
// UK VAT is 20%
expect($order->tax_country)->toBe('GB')
->and($order->tax_rate)->toBe(20.00)
->and((float) $order->tax_amount)->toBe(round(19.00 * 0.20, 2));
});
it('calculates total correctly', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
$expectedTotal = 19.00 + (19.00 * 0.20); // subtotal + tax
expect((float) $order->total)->toBe(round($expectedTotal, 2));
});
it('creates order with yearly billing cycle', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'yearly'
);
expect($order->billing_cycle)->toBe('yearly')
->and((float) $order->subtotal)->toBe(190.00);
});
});
describe('Checkout Session Creation', function () {
it('creates checkout session with mocked gateway', function () {
// Create a mock gateway
$mockGateway = Mockery::mock(PaymentGatewayContract::class);
$mockGateway->shouldReceive('createCustomer')
->andReturn('cust_mock_123');
$mockGateway->shouldReceive('createCheckoutSession')
->andReturn([
'session_id' => 'cs_mock_session_123',
'checkout_url' => 'https://checkout.example.com/session/123',
]);
app()->instance('commerce.gateway.btcpay', $mockGateway);
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
$result = $this->service->createCheckout(
$order,
'btcpay',
'https://example.com/success',
'https://example.com/cancel'
);
expect($result)->toHaveKeys(['order', 'session_id', 'checkout_url'])
->and($result['session_id'])->toBe('cs_mock_session_123')
->and($result['checkout_url'])->toContain('checkout.example.com')
->and($result['order']->status)->toBe('processing');
});
});
describe('Order Fulfilment', function () {
it('fulfils order and provisions entitlements', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
// Create a payment
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'order_id' => $order->id,
'gateway' => 'btcpay',
'gateway_payment_id' => 'pay_mock_123',
'amount' => $order->total,
'currency' => 'GBP',
'status' => 'succeeded',
'paid_at' => now(),
]);
// Fulfil the order
$this->service->fulfillOrder($order, $payment);
$order->refresh();
expect($order->status)->toBe('paid')
->and($order->paid_at)->not->toBeNull();
// Check that workspace package was provisioned
$workspacePackage = WorkspacePackage::where('workspace_id', $this->workspace->id)
->where('package_id', $this->package->id)
->first();
expect($workspacePackage)->not->toBeNull()
->and($workspacePackage->status)->toBe('active');
});
it('creates invoice on fulfilment', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'order_id' => $order->id,
'gateway' => 'btcpay',
'gateway_payment_id' => 'pay_mock_456',
'amount' => $order->total,
'currency' => 'GBP',
'status' => 'succeeded',
'paid_at' => now(),
]);
$this->service->fulfillOrder($order, $payment);
$invoice = Invoice::where('order_id', $order->id)->first();
expect($invoice)->not->toBeNull()
->and($invoice->invoice_number)->toStartWith('INV-')
->and((float) $invoice->total)->toBe((float) $order->total)
->and($invoice->status)->toBe('paid');
});
it('fails order with reason', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
$this->service->failOrder($order, 'Payment declined');
$order->refresh();
expect($order->status)->toBe('failed')
->and($order->metadata['failure_reason'])->toBe('Payment declined');
});
});
describe('End-to-End Checkout Flow', function () {
it('completes full checkout flow: cart to paid order', function () {
// Step 1: Create order (simulates adding to cart and proceeding)
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
expect($order->status)->toBe('pending');
// Step 2: Simulate checkout session creation (mocked gateway)
$mockGateway = Mockery::mock(PaymentGatewayContract::class);
$mockGateway->shouldReceive('createCustomer')
->andReturn('cust_e2e_123');
$mockGateway->shouldReceive('createCheckoutSession')
->andReturn([
'session_id' => 'cs_e2e_session',
'checkout_url' => 'https://pay.example.com/checkout',
]);
app()->instance('commerce.gateway.btcpay', $mockGateway);
$checkout = $this->service->createCheckout($order, 'btcpay');
$order->refresh();
expect($order->status)->toBe('processing')
->and($order->gateway_session_id)->toBe('cs_e2e_session');
// Step 3: Simulate payment completion (webhook would call this)
$payment = Payment::create([
'workspace_id' => $this->workspace->id,
'order_id' => $order->id,
'gateway' => 'btcpay',
'gateway_payment_id' => 'pay_e2e_completed',
'amount' => $order->total,
'currency' => 'GBP',
'status' => 'succeeded',
'paid_at' => now(),
]);
$this->service->fulfillOrder($order, $payment);
// Step 4: Verify final state
$order->refresh();
$this->workspace->refresh();
expect($order->status)->toBe('paid')
->and($order->paid_at)->not->toBeNull();
// Verify invoice created
$invoice = Invoice::where('order_id', $order->id)->first();
expect($invoice)->not->toBeNull()
->and($invoice->status)->toBe('paid');
// Verify entitlements provisioned
$workspacePackage = $this->workspace->workspacePackages()
->where('package_id', $this->package->id)
->where('status', 'active')
->first();
expect($workspacePackage)->not->toBeNull();
});
it('handles checkout cancellation', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
// Simulate user cancelling checkout
$order->cancel();
expect($order->status)->toBe('cancelled');
// No entitlements should be provisioned
$workspacePackage = $this->workspace->workspacePackages()
->where('package_id', $this->package->id)
->first();
expect($workspacePackage)->toBeNull();
});
it('handles checkout expiry', function () {
$order = $this->service->createOrder(
$this->workspace,
$this->package,
'monthly'
);
// Simulate payment expiry (BTCPay invoice expired)
$order->markAsFailed('Payment expired');
expect($order->status)->toBe('failed')
->and($order->metadata['failure_reason'])->toBe('Payment expired');
// No entitlements should be provisioned
$workspacePackage = $this->workspace->workspacePackages()
->where('package_id', $this->package->id)
->first();
expect($workspacePackage)->toBeNull();
});
});
afterEach(function () {
Mockery::close();
});