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:
Fabian @ Blax Software 2026-09-24 08:22:55 +02:00
parent b4125c1ee1
commit 0d9823df04
7 changed files with 346 additions and 25 deletions

View File

@ -417,6 +417,13 @@ if ($order->is_fully_paid) {
$outstanding = $order->amount_outstanding; // In cents $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 ### Update Order Status
```php ```php

View File

@ -153,7 +153,7 @@ class StripeWebhookController
$order = $cart->order; $order = $cart->order;
if (! $order) { if (! $order) {
// Create order from the converted cart // Create order from the converted cart
$order = Order::createFromCart($cart); $order = $this->orderModel()::createFromCart($cart);
Log::info('Order created from Stripe checkout session', [ Log::info('Order created from Stripe checkout session', [
'order_id' => $order->id, 'order_id' => $order->id,
@ -651,7 +651,7 @@ class StripeWebhookController
// Invoice events are typically for subscriptions // Invoice events are typically for subscriptions
// Add order note if we can find the related order // Add order note if we can find the related order
if ($invoice->metadata->order_id ?? null) { if ($invoice->metadata->order_id ?? null) {
$order = Order::find($invoice->metadata->order_id); $order = $this->orderModel()::find($invoice->metadata->order_id);
if ($order) { if ($order) {
$amountPaid = ($invoice->amount_paid ?? 0) / 100; $amountPaid = ($invoice->amount_paid ?? 0) / 100;
$order->addNote( $order->addNote(
@ -675,7 +675,7 @@ class StripeWebhookController
]); ]);
if ($invoice->metadata->order_id ?? null) { if ($invoice->metadata->order_id ?? null) {
$order = Order::find($invoice->metadata->order_id); $order = $this->orderModel()::find($invoice->metadata->order_id);
if ($order) { if ($order) {
$order->addNote( $order->addNote(
"Subscription invoice payment failed (Invoice: {$invoice->id})", "Subscription invoice payment failed (Invoice: {$invoice->id})",
@ -687,6 +687,20 @@ class StripeWebhookController
return true; 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 * Find an order by payment intent ID
*/ */
@ -697,7 +711,7 @@ class StripeWebhookController
} }
// First try to find via order's payment_reference // 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) { if ($order) {
return $order; return $order;
} }
@ -727,7 +741,7 @@ class StripeWebhookController
} }
// Try to find order where payment_reference contains the charge // 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) { if ($order) {
return $order; return $order;
} }

View File

@ -2298,8 +2298,10 @@ class Cart extends Model
'status' => CartStatus::CONVERTED, 'status' => CartStatus::CONVERTED,
]); ]);
// Create an Order from this converted cart // Create an Order from this converted cart — via the configured
$order = Order::createFromCart($this); // 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; return $this;
}); });

View File

@ -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);
}
});
} }
/** /**

View File

@ -147,12 +147,25 @@ class ShopService
// ORDER QUERIES // 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. * Get all orders query builder.
*/ */
public function orders(): Builder public function orders(): Builder
{ {
return Order::query(); return $this->orderModel()::query();
} }
/** /**
@ -160,7 +173,7 @@ class ShopService
*/ */
public function order(string $id): ?Order 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 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 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 public function ordersThisWeek(): Builder
{ {
return Order::whereBetween('created_at', [ return $this->orderModel()::whereBetween('created_at', [
Carbon::now()->startOfWeek(), Carbon::now()->startOfWeek(),
Carbon::now()->endOfWeek(), Carbon::now()->endOfWeek(),
]); ]);
@ -195,7 +208,7 @@ class ShopService
*/ */
public function ordersThisMonth(): Builder public function ordersThisMonth(): Builder
{ {
return Order::whereBetween('created_at', [ return $this->orderModel()::whereBetween('created_at', [
Carbon::now()->startOfMonth(), Carbon::now()->startOfMonth(),
Carbon::now()->endOfMonth(), Carbon::now()->endOfMonth(),
]); ]);
@ -206,7 +219,7 @@ class ShopService
*/ */
public function ordersThisYear(): Builder public function ordersThisYear(): Builder
{ {
return Order::whereBetween('created_at', [ return $this->orderModel()::whereBetween('created_at', [
Carbon::now()->startOfYear(), Carbon::now()->startOfYear(),
Carbon::now()->endOfYear(), Carbon::now()->endOfYear(),
]); ]);
@ -217,7 +230,7 @@ class ShopService
*/ */
public function ordersBetween(\DateTimeInterface $from, \DateTimeInterface $until): Builder 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 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 public function activeOrders(): Builder
{ {
return Order::active(); return $this->orderModel()::active();
} }
/** /**
@ -273,7 +286,7 @@ class ShopService
*/ */
public function paidOrders(): Builder public function paidOrders(): Builder
{ {
return Order::paid(); return $this->orderModel()::paid();
} }
/** /**
@ -281,7 +294,7 @@ class ShopService
*/ */
public function unpaidOrders(): Builder public function unpaidOrders(): Builder
{ {
return Order::unpaid(); return $this->orderModel()::unpaid();
} }
// ========================================================================= // =========================================================================
@ -294,7 +307,7 @@ class ShopService
*/ */
public function totalRevenue(): int 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 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 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(); $startOfYear = Carbon::now()->startOfYear();
$endOfYear = Carbon::now()->endOfYear(); $endOfYear = Carbon::now()->endOfYear();
$orderStats = Order::selectRaw(" $orderStats = $this->orderModel()::selectRaw("
COUNT(*) as total, COUNT(*) as total,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as processing, 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 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') ->selectRaw('DATE(created_at) as date, SUM(amount_paid) as revenue, COUNT(*) as orders')
->groupBy('date') ->groupBy('date')
->orderBy('date') ->orderBy('date')
@ -494,7 +507,7 @@ class ShopService
*/ */
public function revenueByMonth(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection 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') ->selectRaw('YEAR(created_at) as year, MONTH(created_at) as month, SUM(amount_paid) as revenue, COUNT(*) as orders')
->groupBy('year', 'month') ->groupBy('year', 'month')
->orderBy('year') ->orderBy('year')

View File

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

View File

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