fix(orders): dispatch OrderPaid once; honour shop.models.order on webhook + service paths
OrderPaid was defined but never dispatched. Order::booted() now fires it from
the `updated` hook exactly when paid_at goes null -> value (recordPayment()
reaching amount_total, the Stripe checkout webhook, or a direct update). Later
saves of a paid order, partial payments and overpayments do not re-fire it, and
a host subclass configured via shop.models.order gets it with its own instance.
StripeWebhookController, ShopService and Cart::checkout used the base
Blax\Shop\Models\Order statically (createFromCart, find, where, scopes,
aggregates), so a host app's configured Order subclass — with its own boot
hooks — was bypassed on the real webhook path. They now resolve
config('shop.models.order') through a small orderModel() helper. Return types
are unchanged (subclass instances satisfy Order); Order::formatMoney stays a
pure static.
Tests: OrderPaidEventTest (once-only on recordPayment, partial payments,
direct paid_at update, host subclass) and StripeWebhookOrderModelTest
(handleCheckoutSessionCompleted builds the configured subclass and its boot
hook runs; webhook/ShopService/Cart finders return it). Suite: 1485 tests green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b4125c1ee1
commit
0d9823df04
|
|
@ -417,6 +417,13 @@ if ($order->is_fully_paid) {
|
|||
$outstanding = $order->amount_outstanding; // In cents
|
||||
```
|
||||
|
||||
`Blax\Shop\Events\OrderPaid` is dispatched exactly once per order: on the save
|
||||
where `paid_at` goes from `null` to a value (`recordPayment()` reaching
|
||||
`amount_total`, the Stripe `checkout.session.completed` webhook, or a direct
|
||||
update that sets `paid_at`). Partial payments and later saves of an already-paid
|
||||
order do not re-fire it. Listen to it for fulfilment, invoicing or confirmation
|
||||
mail; the event carries the order as your configured `shop.models.order` class.
|
||||
|
||||
### Update Order Status
|
||||
|
||||
```php
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ class StripeWebhookController
|
|||
$order = $cart->order;
|
||||
if (! $order) {
|
||||
// Create order from the converted cart
|
||||
$order = Order::createFromCart($cart);
|
||||
$order = $this->orderModel()::createFromCart($cart);
|
||||
|
||||
Log::info('Order created from Stripe checkout session', [
|
||||
'order_id' => $order->id,
|
||||
|
|
@ -651,7 +651,7 @@ class StripeWebhookController
|
|||
// Invoice events are typically for subscriptions
|
||||
// Add order note if we can find the related order
|
||||
if ($invoice->metadata->order_id ?? null) {
|
||||
$order = Order::find($invoice->metadata->order_id);
|
||||
$order = $this->orderModel()::find($invoice->metadata->order_id);
|
||||
if ($order) {
|
||||
$amountPaid = ($invoice->amount_paid ?? 0) / 100;
|
||||
$order->addNote(
|
||||
|
|
@ -675,7 +675,7 @@ class StripeWebhookController
|
|||
]);
|
||||
|
||||
if ($invoice->metadata->order_id ?? null) {
|
||||
$order = Order::find($invoice->metadata->order_id);
|
||||
$order = $this->orderModel()::find($invoice->metadata->order_id);
|
||||
if ($order) {
|
||||
$order->addNote(
|
||||
"Subscription invoice payment failed (Invoice: {$invoice->id})",
|
||||
|
|
@ -687,6 +687,20 @@ class StripeWebhookController
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order model class configured by the host app (`shop.models.order`).
|
||||
*
|
||||
* Every lookup/creation in this controller goes through it — not through
|
||||
* `Order::` statically — so a host subclass with its own boot hooks, casts
|
||||
* or scopes is what the real webhook path builds and returns.
|
||||
*
|
||||
* @return class-string<Order>
|
||||
*/
|
||||
protected function orderModel(): string
|
||||
{
|
||||
return config('shop.models.order', Order::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an order by payment intent ID
|
||||
*/
|
||||
|
|
@ -697,7 +711,7 @@ class StripeWebhookController
|
|||
}
|
||||
|
||||
// First try to find via order's payment_reference
|
||||
$order = Order::where('payment_reference', $paymentIntentId)->first();
|
||||
$order = $this->orderModel()::where('payment_reference', $paymentIntentId)->first();
|
||||
if ($order) {
|
||||
return $order;
|
||||
}
|
||||
|
|
@ -727,7 +741,7 @@ class StripeWebhookController
|
|||
}
|
||||
|
||||
// Try to find order where payment_reference contains the charge
|
||||
$order = Order::where('payment_reference', $chargeId)->first();
|
||||
$order = $this->orderModel()::where('payment_reference', $chargeId)->first();
|
||||
if ($order) {
|
||||
return $order;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2298,8 +2298,10 @@ class Cart extends Model
|
|||
'status' => CartStatus::CONVERTED,
|
||||
]);
|
||||
|
||||
// Create an Order from this converted cart
|
||||
$order = Order::createFromCart($this);
|
||||
// Create an Order from this converted cart — via the configured
|
||||
// class so a host subclass (shop.models.order) is what gets built.
|
||||
$orderModel = config('shop.models.order', Order::class);
|
||||
$order = $orderModel::createFromCart($this);
|
||||
|
||||
return $this;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -167,6 +167,17 @@ class Order extends Model
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Announce the transition into "paid" exactly once: when paid_at goes
|
||||
// from null to a value in this save (recordPayment() reaching
|
||||
// amount_total, or a direct update that sets paid_at). Later saves of
|
||||
// an already-paid order (status moves, meta touches) must not re-fire,
|
||||
// and a host subclass configured via shop.models.order gets it too.
|
||||
static::updated(function (Order $order) {
|
||||
if ($order->wasChanged('paid_at') && $order->paid_at && !$order->getOriginal('paid_at')) {
|
||||
\Blax\Shop\Events\OrderPaid::dispatch($order);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -147,12 +147,25 @@ class ShopService
|
|||
// ORDER QUERIES
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* The configured {@see Order} model class (`shop.models.order`).
|
||||
*
|
||||
* All order queries below resolve it instead of using `Order::` statically,
|
||||
* so a host subclass is what every finder returns.
|
||||
*
|
||||
* @return class-string<Order>
|
||||
*/
|
||||
protected function orderModel(): string
|
||||
{
|
||||
return config('shop.models.order', Order::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all orders query builder.
|
||||
*/
|
||||
public function orders(): Builder
|
||||
{
|
||||
return Order::query();
|
||||
return $this->orderModel()::query();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -160,7 +173,7 @@ class ShopService
|
|||
*/
|
||||
public function order(string $id): ?Order
|
||||
{
|
||||
return Order::find($id);
|
||||
return $this->orderModel()::find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -168,7 +181,7 @@ class ShopService
|
|||
*/
|
||||
public function orderByNumber(string $orderNumber): ?Order
|
||||
{
|
||||
return Order::where('order_number', $orderNumber)->first();
|
||||
return $this->orderModel()::where('order_number', $orderNumber)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,7 +189,7 @@ class ShopService
|
|||
*/
|
||||
public function ordersToday(): Builder
|
||||
{
|
||||
return Order::whereDate('created_at', Carbon::today());
|
||||
return $this->orderModel()::whereDate('created_at', Carbon::today());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -184,7 +197,7 @@ class ShopService
|
|||
*/
|
||||
public function ordersThisWeek(): Builder
|
||||
{
|
||||
return Order::whereBetween('created_at', [
|
||||
return $this->orderModel()::whereBetween('created_at', [
|
||||
Carbon::now()->startOfWeek(),
|
||||
Carbon::now()->endOfWeek(),
|
||||
]);
|
||||
|
|
@ -195,7 +208,7 @@ class ShopService
|
|||
*/
|
||||
public function ordersThisMonth(): Builder
|
||||
{
|
||||
return Order::whereBetween('created_at', [
|
||||
return $this->orderModel()::whereBetween('created_at', [
|
||||
Carbon::now()->startOfMonth(),
|
||||
Carbon::now()->endOfMonth(),
|
||||
]);
|
||||
|
|
@ -206,7 +219,7 @@ class ShopService
|
|||
*/
|
||||
public function ordersThisYear(): Builder
|
||||
{
|
||||
return Order::whereBetween('created_at', [
|
||||
return $this->orderModel()::whereBetween('created_at', [
|
||||
Carbon::now()->startOfYear(),
|
||||
Carbon::now()->endOfYear(),
|
||||
]);
|
||||
|
|
@ -217,7 +230,7 @@ class ShopService
|
|||
*/
|
||||
public function ordersBetween(\DateTimeInterface $from, \DateTimeInterface $until): Builder
|
||||
{
|
||||
return Order::whereBetween('created_at', [$from, $until]);
|
||||
return $this->orderModel()::whereBetween('created_at', [$from, $until]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -225,7 +238,7 @@ class ShopService
|
|||
*/
|
||||
public function ordersWithStatus(OrderStatus $status): Builder
|
||||
{
|
||||
return Order::where('status', $status->value);
|
||||
return $this->orderModel()::where('status', $status->value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -265,7 +278,7 @@ class ShopService
|
|||
*/
|
||||
public function activeOrders(): Builder
|
||||
{
|
||||
return Order::active();
|
||||
return $this->orderModel()::active();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -273,7 +286,7 @@ class ShopService
|
|||
*/
|
||||
public function paidOrders(): Builder
|
||||
{
|
||||
return Order::paid();
|
||||
return $this->orderModel()::paid();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -281,7 +294,7 @@ class ShopService
|
|||
*/
|
||||
public function unpaidOrders(): Builder
|
||||
{
|
||||
return Order::unpaid();
|
||||
return $this->orderModel()::unpaid();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -294,7 +307,7 @@ class ShopService
|
|||
*/
|
||||
public function totalRevenue(): int
|
||||
{
|
||||
return (int) Order::sum('amount_paid');
|
||||
return (int) $this->orderModel()::sum('amount_paid');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -348,7 +361,7 @@ class ShopService
|
|||
*/
|
||||
public function totalRefunded(): int
|
||||
{
|
||||
return (int) Order::sum('amount_refunded');
|
||||
return (int) $this->orderModel()::sum('amount_refunded');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -366,7 +379,7 @@ class ShopService
|
|||
*/
|
||||
public function averageOrderValue(): float
|
||||
{
|
||||
return (float) Order::avg('amount_total') ?? 0;
|
||||
return (float) $this->orderModel()::avg('amount_total') ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -392,7 +405,7 @@ class ShopService
|
|||
$startOfYear = Carbon::now()->startOfYear();
|
||||
$endOfYear = Carbon::now()->endOfYear();
|
||||
|
||||
$orderStats = Order::selectRaw("
|
||||
$orderStats = $this->orderModel()::selectRaw("
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as processing,
|
||||
|
|
@ -482,7 +495,7 @@ class ShopService
|
|||
*/
|
||||
public function revenueByDay(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection
|
||||
{
|
||||
return Order::whereBetween('created_at', [$from, $until])
|
||||
return $this->orderModel()::whereBetween('created_at', [$from, $until])
|
||||
->selectRaw('DATE(created_at) as date, SUM(amount_paid) as revenue, COUNT(*) as orders')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
|
|
@ -494,7 +507,7 @@ class ShopService
|
|||
*/
|
||||
public function revenueByMonth(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection
|
||||
{
|
||||
return Order::whereBetween('created_at', [$from, $until])
|
||||
return $this->orderModel()::whereBetween('created_at', [$from, $until])
|
||||
->selectRaw('YEAR(created_at) as year, MONTH(created_at) as month, SUM(amount_paid) as revenue, COUNT(*) as orders')
|
||||
->groupBy('year', 'month')
|
||||
->orderBy('year')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Tests\Unit;
|
||||
|
||||
use Blax\Shop\Events\OrderPaid;
|
||||
use Blax\Shop\Models\Order;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* A host app's Order subclass, as configured via `shop.models.order`.
|
||||
* OrderPaid must be dispatched for it exactly like for the base model.
|
||||
*/
|
||||
class HostPaidOrder extends Order {}
|
||||
|
||||
/**
|
||||
* OrderPaid is announced exactly once: on the save where paid_at goes from
|
||||
* null to a value. Never on partial payments, never again on later saves.
|
||||
*/
|
||||
class OrderPaidEventTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function pendingOrder(int $total = 10000): Order
|
||||
{
|
||||
return Order::factory()->pending()->create([
|
||||
'amount_total' => $total,
|
||||
'amount_paid' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function record_payment_reaching_total_dispatches_order_paid_once(): void
|
||||
{
|
||||
$order = $this->pendingOrder(10000);
|
||||
|
||||
Event::fake([OrderPaid::class]);
|
||||
|
||||
$order->recordPayment(10000, 'pi_paid', 'card', 'stripe');
|
||||
|
||||
Event::assertDispatchedTimes(OrderPaid::class, 1);
|
||||
Event::assertDispatched(OrderPaid::class, fn (OrderPaid $e) => $e->order->is($order));
|
||||
|
||||
// Overpayment / a second recordPayment on an already-paid order stays quiet.
|
||||
$order->recordPayment(500);
|
||||
// ... and so do unrelated later saves (status move, meta touch).
|
||||
$order->updateMetaKey('tracking_number', 'TRK-1');
|
||||
$order->markAsShipped('TRK-1', 'DHL');
|
||||
|
||||
Event::assertDispatchedTimes(OrderPaid::class, 1);
|
||||
$this->assertNotNull($order->fresh()->paid_at);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function partial_payment_does_not_dispatch_until_total_is_reached(): void
|
||||
{
|
||||
$order = $this->pendingOrder(10000);
|
||||
|
||||
Event::fake([OrderPaid::class]);
|
||||
|
||||
$order->recordPayment(4000);
|
||||
Event::assertNotDispatched(OrderPaid::class);
|
||||
$this->assertNull($order->fresh()->paid_at);
|
||||
|
||||
$order->recordPayment(6000);
|
||||
Event::assertDispatchedTimes(OrderPaid::class, 1);
|
||||
$this->assertNotNull($order->fresh()->paid_at);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function direct_update_setting_paid_at_dispatches_order_paid_once(): void
|
||||
{
|
||||
$order = $this->pendingOrder(10000);
|
||||
|
||||
Event::fake([OrderPaid::class]);
|
||||
|
||||
$order->update(['paid_at' => now()]);
|
||||
Event::assertDispatchedTimes(OrderPaid::class, 1);
|
||||
|
||||
// Moving an existing paid_at (value -> value) is not a "became paid" transition.
|
||||
$order->update(['paid_at' => now()->addMinute()]);
|
||||
$order->fresh()->update(['internal_note' => 'touched']);
|
||||
|
||||
Event::assertDispatchedTimes(OrderPaid::class, 1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function order_paid_fires_for_a_configured_host_subclass_with_that_instance(): void
|
||||
{
|
||||
config(['shop.models.order' => HostPaidOrder::class]);
|
||||
|
||||
$order = HostPaidOrder::create(Order::factory()->pending()->raw([
|
||||
'amount_total' => 10000,
|
||||
'amount_paid' => 0,
|
||||
]));
|
||||
|
||||
Event::fake([OrderPaid::class]);
|
||||
|
||||
$order->recordPayment(10000, 'pi_host', 'card', 'stripe');
|
||||
$order->recordPayment(1);
|
||||
|
||||
Event::assertDispatchedTimes(OrderPaid::class, 1);
|
||||
Event::assertDispatched(
|
||||
OrderPaid::class,
|
||||
fn (OrderPaid $e) => $e->order instanceof HostPaidOrder && $e->order->is($order)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Tests\Unit;
|
||||
|
||||
use Blax\Shop\Enums\CartStatus;
|
||||
use Blax\Shop\Events\OrderPaid;
|
||||
use Blax\Shop\Http\Controllers\StripeWebhookController;
|
||||
use Blax\Shop\Models\Order;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Services\ShopService;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use ReflectionClass;
|
||||
use Workbench\App\Models\User;
|
||||
|
||||
/**
|
||||
* A host app's Order subclass with its own boot hook — the shape nirioci and
|
||||
* other consumers use. The webhook path must build THIS class, not the base.
|
||||
*/
|
||||
class HostWebhookOrder extends Order
|
||||
{
|
||||
public static int $bootHookRuns = 0;
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function (HostWebhookOrder $order) {
|
||||
$order->internal_note = 'host boot hook ran';
|
||||
});
|
||||
|
||||
static::created(function () {
|
||||
static::$bootHookRuns++;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `shop.models.order` is honoured on the real Stripe webhook path and by the
|
||||
* ShopService / Cart finders and creators that previously used `Order::`.
|
||||
*/
|
||||
class StripeWebhookOrderModelTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected StripeWebhookController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'shop.stripe.enabled' => true,
|
||||
'shop.models.order' => HostWebhookOrder::class,
|
||||
]);
|
||||
HostWebhookOrder::$bootHookRuns = 0;
|
||||
|
||||
$this->controller = new StripeWebhookController;
|
||||
}
|
||||
|
||||
protected function invoke(string $method, array $args = [])
|
||||
{
|
||||
$ref = (new ReflectionClass($this->controller))->getMethod($method);
|
||||
$ref->setAccessible(true);
|
||||
|
||||
return $ref->invokeArgs($this->controller, $args);
|
||||
}
|
||||
|
||||
protected function mockSession(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,
|
||||
'currency' => 'eur',
|
||||
'payment_status' => 'paid',
|
||||
'customer' => 'cus_test_123',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
protected function product(int $price = 10000): Product
|
||||
{
|
||||
return Product::factory()->withPrices(unit_amount: $price)->create(['manage_stock' => false]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function checkout_session_completed_builds_the_configured_order_subclass(): void
|
||||
{
|
||||
$customer = User::factory()->create();
|
||||
$customer->addToCart($this->product(10000));
|
||||
$cart = $customer->currentCart();
|
||||
|
||||
$this->assertEquals(CartStatus::ACTIVE, $cart->status);
|
||||
$this->assertNull($cart->order);
|
||||
|
||||
Event::fake([OrderPaid::class]);
|
||||
|
||||
$result = $this->invoke('handleCheckoutSessionCompleted', [
|
||||
$this->mockSession(['metadata' => (object) ['cart_id' => $cart->id], 'amount_total' => 10000]),
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
|
||||
$order = $cart->fresh()->order;
|
||||
$this->assertInstanceOf(HostWebhookOrder::class, $order);
|
||||
$this->assertSame('host boot hook ran', $order->internal_note, 'the subclass creating hook must have run');
|
||||
$this->assertSame(1, HostWebhookOrder::$bootHookRuns);
|
||||
$this->assertSame(1, HostWebhookOrder::count());
|
||||
|
||||
$this->assertEquals(10000, $order->amount_paid);
|
||||
$this->assertNotNull($order->paid_at);
|
||||
|
||||
Event::assertDispatchedTimes(OrderPaid::class, 1);
|
||||
Event::assertDispatched(
|
||||
OrderPaid::class,
|
||||
fn (OrderPaid $e) => $e->order instanceof HostWebhookOrder && $e->order->is($order)
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function webhook_finders_return_the_configured_order_subclass(): void
|
||||
{
|
||||
$order = HostWebhookOrder::create(Order::factory()->pending()->raw([
|
||||
'payment_reference' => 'pi_host_ref',
|
||||
]));
|
||||
|
||||
$byIntent = $this->invoke('findOrderByPaymentIntent', ['pi_host_ref']);
|
||||
$byCharge = $this->invoke('findOrderByChargeId', ['pi_host_ref']);
|
||||
|
||||
$this->assertInstanceOf(HostWebhookOrder::class, $byIntent);
|
||||
$this->assertInstanceOf(HostWebhookOrder::class, $byCharge);
|
||||
$this->assertTrue($byIntent->is($order));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function shop_service_finders_return_the_configured_order_subclass(): void
|
||||
{
|
||||
$order = HostWebhookOrder::create(Order::factory()->pending()->raw());
|
||||
$service = new ShopService;
|
||||
|
||||
$this->assertInstanceOf(HostWebhookOrder::class, $service->order($order->id));
|
||||
$this->assertInstanceOf(HostWebhookOrder::class, $service->orderByNumber($order->order_number));
|
||||
$this->assertInstanceOf(HostWebhookOrder::class, $service->orders()->first());
|
||||
$this->assertInstanceOf(HostWebhookOrder::class, $service->pendingOrders()->first());
|
||||
$this->assertSame(1, $service->ordersToday()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cart_checkout_builds_the_configured_order_subclass(): void
|
||||
{
|
||||
$customer = User::factory()->create();
|
||||
$customer->addToCart($this->product(5000));
|
||||
|
||||
$cart = $customer->checkoutCart();
|
||||
|
||||
$order = $cart->fresh()->order;
|
||||
$this->assertInstanceOf(HostWebhookOrder::class, $order);
|
||||
$this->assertSame('host boot hook ran', $order->internal_note);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue