554 lines
16 KiB
PHP
554 lines
16 KiB
PHP
<?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);
|
|
}
|
|
}
|