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); } }