laravel-shop/tests/Unit/Stripe/StripeWebhookOrderTest.php

757 lines
23 KiB
PHP
Raw Normal View History

2025-12-29 08:59:02 +00:00
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Enums\OrderStatus;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Http\Controllers\StripeWebhookController;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\OrderNote;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use ReflectionClass;
use Workbench\App\Models\User;
class StripeWebhookOrderTest extends TestCase
{
use RefreshDatabase;
protected StripeWebhookController $controller;
protected function setUp(): void
{
parent::setUp();
config(['shop.stripe.enabled' => true]);
$this->controller = new StripeWebhookController();
}
/**
* Call a protected method on the controller
*/
protected function invokeMethod(string $method, array $args = [])
{
$reflection = new ReflectionClass($this->controller);
$method = $reflection->getMethod($method);
$method->setAccessible(true);
return $method->invokeArgs($this->controller, $args);
}
/**
* Create a mock session object
*/
protected function createMockSession(array $overrides = []): object
{
return (object) array_merge([
'id' => 'cs_test_' . uniqid(),
'payment_intent' => 'pi_test_' . uniqid(),
'metadata' => (object) ['cart_id' => null],
'client_reference_id' => null,
'amount_total' => 10000, // 100.00
'currency' => 'usd',
'payment_status' => 'paid',
'customer' => 'cus_test_123',
], $overrides);
}
/**
* Create a mock charge object
*/
protected function createMockCharge(array $overrides = []): object
{
return (object) array_merge([
'id' => 'ch_test_' . uniqid(),
'payment_intent' => 'pi_test_' . uniqid(),
'amount' => 10000, // 100.00
'amount_refunded' => 0,
'currency' => 'usd',
'failure_message' => null,
'failure_code' => null,
'receipt_url' => 'https://receipt.stripe.com/test',
], $overrides);
}
/**
* Create a product for testing
*/
protected function createProduct(float $price = 100.00): Product
{
return Product::factory()->withPrices(unit_amount: $price)->create([
'manage_stock' => false,
]);
}
#[Test]
public function checkout_session_completed_creates_order_payment()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$this->assertNotNull($order);
$this->assertEquals(0, $order->amount_paid);
// Simulate checkout session completed
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 10000, // 100.00
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$order->refresh();
$this->assertEquals(100.00, $order->amount_paid);
$this->assertEquals(OrderStatus::PROCESSING, $order->status);
}
#[Test]
public function checkout_session_completed_logs_payment_note()
{
$customer = User::factory()->create();
$product = $this->createProduct(50.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$initialNoteCount = $order->notes()->count();
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 5000,
'payment_intent' => 'pi_test_12345',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$order->refresh();
$this->assertGreaterThan($initialNoteCount, $order->notes()->count());
$paymentNote = $order->notes()->where('type', OrderNote::TYPE_PAYMENT)->first();
$this->assertNotNull($paymentNote);
}
#[Test]
public function checkout_session_failed_updates_order_status()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
]);
$this->invokeMethod('handleCheckoutSessionFailed', [$session]);
$order->refresh();
$this->assertEquals(OrderStatus::FAILED, $order->status);
}
#[Test]
public function checkout_session_failed_adds_payment_note()
{
$customer = User::factory()->create();
$product = $this->createProduct(50.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
]);
$this->invokeMethod('handleCheckoutSessionFailed', [$session]);
$failedNote = $order->notes()
->where('type', OrderNote::TYPE_PAYMENT)
->where('content', 'like', '%failed%')
->first();
$this->assertNotNull($failedNote);
}
#[Test]
public function checkout_session_expired_adds_system_note()
{
$customer = User::factory()->create();
$product = $this->createProduct(50.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
]);
$this->invokeMethod('handleCheckoutSessionExpired', [$session]);
$expiredNote = $order->notes()
->where('type', OrderNote::TYPE_SYSTEM)
->where('content', 'like', '%expired%')
->first();
$this->assertNotNull($expiredNote);
}
#[Test]
public function charge_refunded_records_refund_on_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
// First record a payment (amount in cents: 100.00 * 100 = 10000)
$order->recordPayment(10000, 'pi_test_123', 'stripe', 'stripe');
$this->assertTrue($order->is_fully_paid);
// Update the order's payment_reference so we can find it
$order->update(['payment_reference' => 'pi_test_123']);
// Now simulate a refund
$charge = $this->createMockCharge([
'payment_intent' => 'pi_test_123',
'amount_refunded' => 5000, // 50.00 in cents
]);
$this->invokeMethod('handleChargeRefunded', [$charge]);
$order->refresh();
$this->assertEquals(50, $order->amount_refunded);
}
#[Test]
public function charge_dispute_created_puts_order_on_hold()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->recordPayment(100, 'ch_test_dispute', 'stripe', 'stripe');
$order->update([
'status' => OrderStatus::PROCESSING,
'payment_reference' => 'ch_test_dispute',
]);
$dispute = (object) [
'id' => 'dp_test_123',
'charge' => 'ch_test_dispute',
'amount' => 10000,
'reason' => 'fraudulent',
];
$this->invokeMethod('handleChargeDisputeCreated', [$dispute]);
$order->refresh();
$this->assertEquals(OrderStatus::ON_HOLD, $order->status);
}
#[Test]
public function charge_dispute_created_adds_payment_note()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->update(['payment_reference' => 'ch_test_dispute2']);
$dispute = (object) [
'id' => 'dp_test_456',
'charge' => 'ch_test_dispute2',
'amount' => 10000,
'reason' => 'product_not_received',
];
$this->invokeMethod('handleChargeDisputeCreated', [$dispute]);
$disputeNote = $order->notes()
->where('type', OrderNote::TYPE_PAYMENT)
->where('content', 'like', '%dispute%')
->first();
$this->assertNotNull($disputeNote);
}
#[Test]
public function charge_dispute_closed_restores_order_if_won()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->update([
'status' => OrderStatus::ON_HOLD,
'payment_reference' => 'ch_test_won',
]);
$dispute = (object) [
'id' => 'dp_test_789',
'charge' => 'ch_test_won',
'status' => 'won',
];
$this->invokeMethod('handleChargeDisputeClosed', [$dispute]);
$order->refresh();
$this->assertEquals(OrderStatus::PROCESSING, $order->status);
}
#[Test]
public function charge_dispute_closed_refunds_order_if_lost()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->update([
'status' => OrderStatus::ON_HOLD,
'payment_reference' => 'ch_test_lost',
]);
$dispute = (object) [
'id' => 'dp_test_000',
'charge' => 'ch_test_lost',
'status' => 'lost',
];
$this->invokeMethod('handleChargeDisputeClosed', [$dispute]);
$order->refresh();
$this->assertEquals(OrderStatus::REFUNDED, $order->status);
}
#[Test]
public function refund_created_records_refund_on_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->recordPayment(100, 'ch_refund_test', 'stripe', 'stripe');
$order->update(['payment_reference' => 'ch_refund_test']);
$refund = (object) [
'id' => 're_test_123',
'charge' => 'ch_refund_test',
'amount' => 2500, // 25.00
'reason' => 'requested_by_customer',
'status' => 'succeeded',
];
$this->invokeMethod('handleRefundCreated', [$refund]);
$order->refresh();
$this->assertEquals(25, $order->amount_refunded);
}
#[Test]
public function refund_updated_adds_note_to_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->update(['payment_reference' => 'ch_refund_update']);
$refund = (object) [
'id' => 're_test_456',
'charge' => 'ch_refund_update',
'status' => 'succeeded',
];
$this->invokeMethod('handleRefundUpdated', [$refund]);
$refundNote = $order->notes()
->where('type', OrderNote::TYPE_REFUND)
->first();
$this->assertNotNull($refundNote);
}
#[Test]
public function find_order_by_payment_intent_works()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->update(['payment_reference' => 'pi_find_test']);
$foundOrder = $this->invokeMethod('findOrderByPaymentIntent', ['pi_find_test']);
$this->assertNotNull($foundOrder);
$this->assertEquals($order->id, $foundOrder->id);
}
#[Test]
public function find_order_by_charge_id_works()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->update(['payment_reference' => 'ch_find_test']);
$foundOrder = $this->invokeMethod('findOrderByChargeId', ['ch_find_test']);
$this->assertNotNull($foundOrder);
$this->assertEquals($order->id, $foundOrder->id);
}
#[Test]
public function find_order_returns_null_for_unknown_payment_intent()
{
$foundOrder = $this->invokeMethod('findOrderByPaymentIntent', ['pi_unknown_123']);
$this->assertNull($foundOrder);
}
#[Test]
public function handlers_return_false_for_missing_cart()
{
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => 'nonexistent-cart-id'],
]);
$result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$this->assertFalse($result);
}
#[Test]
public function handlers_return_false_for_missing_cart_id()
{
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => null],
'client_reference_id' => null,
]);
$result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$this->assertFalse($result);
}
#[Test]
public function handler_uses_client_reference_id_as_fallback()
{
$customer = User::factory()->create();
$product = $this->createProduct(50.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$session = $this->createMockSession([
'metadata' => (object) [], // No cart_id in metadata
'client_reference_id' => $cart->id, // But it's in client_reference_id
'amount_total' => 5000,
]);
$result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$this->assertTrue($result);
$order = $cart->fresh()->order;
$this->assertEquals(50.00, $order->amount_paid);
}
#[Test]
public function payment_intent_canceled_adds_note()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->update(['payment_reference' => 'pi_canceled_test']);
$paymentIntent = (object) [
'id' => 'pi_canceled_test',
];
$this->invokeMethod('handlePaymentIntentCanceled', [$paymentIntent]);
$cancelNote = $order->notes()
->where('type', OrderNote::TYPE_PAYMENT)
->where('content', 'like', '%canceled%')
->first();
$this->assertNotNull($cancelNote);
}
#[Test]
public function charge_failed_adds_failure_note_to_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$order = $cart->fresh()->order;
$order->update(['payment_reference' => 'pi_charge_fail']);
$charge = $this->createMockCharge([
'id' => 'ch_failed_test',
'payment_intent' => 'pi_charge_fail',
'failure_message' => 'Your card was declined.',
'failure_code' => 'card_declined',
]);
$this->invokeMethod('handleChargeFailed', [$charge]);
$failNote = $order->notes()
->where('type', OrderNote::TYPE_PAYMENT)
->where('content', 'like', '%failed%')
->first();
$this->assertNotNull($failNote);
$this->assertStringContainsString('declined', $failNote->content);
}
// =========================================================================
// STRIPE CHECKOUT SESSION FLOW TESTS (No pre-existing order)
// =========================================================================
#[Test]
public function checkout_session_completed_creates_order_when_none_exists()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
// Add to cart but DON'T call checkoutCart() - simulate checkoutSession() flow
$customer->addToCart($product);
$cart = $customer->currentCart();
// Verify no order exists yet
$this->assertNull($cart->order);
// Simulate what checkoutSession() does: mark cart as converted
$cart->update([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
// Now simulate checkout session completed webhook
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 10000, // 100.00
'payment_status' => 'paid',
]);
$result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$this->assertTrue($result);
// Verify order was created
$cart->refresh();
$order = $cart->order;
$this->assertNotNull($order, 'Order should be created by webhook');
$this->assertEquals($cart->id, $order->cart_id);
$this->assertEquals($customer->id, $order->customer_id);
}
#[Test]
public function checkout_session_completed_creates_order_and_records_payment()
{
$customer = User::factory()->create();
$product = $this->createProduct(150.00);
// Add to cart but DON'T call checkoutCart()
$customer->addToCart($product);
$cart = $customer->currentCart();
// Simulate checkoutSession() conversion
$cart->update([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 15000, // 150.00
'payment_status' => 'paid',
'payment_intent' => 'pi_stripe_checkout_test',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$cart->refresh();
$order = $cart->order;
$this->assertNotNull($order);
$this->assertEquals(150.00, $order->amount_paid);
$this->assertEquals(OrderStatus::PROCESSING, $order->status);
}
#[Test]
public function checkout_session_completed_creates_order_with_correct_totals()
{
$customer = User::factory()->create();
$product = $this->createProduct(75.50);
$customer->addToCart($product, 2); // 2 items = 151.00
$cart = $customer->currentCart();
$cart->update([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 15100, // 151.00
'payment_status' => 'paid',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$order = $cart->fresh()->order;
$this->assertNotNull($order);
// Order total should match cart total (in cents)
$this->assertEquals((int) $cart->getTotal() * 100, $order->amount_total);
}
#[Test]
public function checkout_session_completed_adds_payment_note_when_creating_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(50.00);
$customer->addToCart($product);
$cart = $customer->currentCart();
$cart->update([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 5000,
'payment_status' => 'paid',
'payment_intent' => 'pi_test_payment_note',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$order = $cart->fresh()->order;
$this->assertNotNull($order);
$paymentNote = $order->notes()->where('type', OrderNote::TYPE_PAYMENT)->first();
$this->assertNotNull($paymentNote, 'Payment note should be created');
$this->assertStringContainsString('50', $paymentNote->content);
$this->assertStringContainsString('Stripe checkout', $paymentNote->content);
}
#[Test]
public function checkout_session_completed_does_not_duplicate_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
// Use checkoutCart() which creates an order
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$existingOrder = $cart->fresh()->order;
$this->assertNotNull($existingOrder);
$originalOrderId = $existingOrder->id;
// Now call webhook - should NOT create a duplicate order
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 10000,
'payment_status' => 'paid',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$cart->refresh();
// Should still be the same order
$this->assertEquals($originalOrderId, $cart->order->id);
// Should only have one order for this cart
$orderCount = Order::where('cart_id', $cart->id)->count();
$this->assertEquals(1, $orderCount);
}
#[Test]
public function checkout_session_completed_without_prior_conversion_creates_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(200.00);
// Add to cart - cart is NOT converted yet (simulates edge case)
$customer->addToCart($product);
$cart = $customer->currentCart();
$this->assertEquals(CartStatus::ACTIVE, $cart->status);
$this->assertNull($cart->order);
// Webhook fires - should convert cart AND create order
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 20000,
'payment_status' => 'paid',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$cart->refresh();
// Cart should be converted
$this->assertEquals(CartStatus::CONVERTED, $cart->status);
$this->assertNotNull($cart->converted_at);
// Order should exist
$this->assertNotNull($cart->order);
$this->assertEquals(200.00, $cart->order->amount_paid);
}
2025-12-29 08:59:02 +00:00
}