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