341 lines
11 KiB
PHP
341 lines
11 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
use Illuminate\Support\Facades\Cache;
|
||
|
|
use Core\Commerce\Models\Invoice;
|
||
|
|
use Core\Commerce\Models\Order;
|
||
|
|
use Core\Commerce\Models\Payment;
|
||
|
|
use Core\Commerce\Services\CommerceService;
|
||
|
|
use Core\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();
|
||
|
|
});
|