diff --git a/docs/03-purchasing.md b/docs/03-purchasing.md index 404fed8..ae8aeb6 100644 --- a/docs/03-purchasing.md +++ b/docs/03-purchasing.md @@ -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 diff --git a/src/Http/Controllers/StripeWebhookController.php b/src/Http/Controllers/StripeWebhookController.php index f43b0e7..f7c057f 100644 --- a/src/Http/Controllers/StripeWebhookController.php +++ b/src/Http/Controllers/StripeWebhookController.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 + */ + 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; } diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 31d308e..501c0bb 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -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; }); diff --git a/src/Models/Order.php b/src/Models/Order.php index 1c2fb5b..f35b788 100644 --- a/src/Models/Order.php +++ b/src/Models/Order.php @@ -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); + } + }); } /** diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index 73b1acb..b5bef6c 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -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 + */ + 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') diff --git a/tests/Unit/Order/OrderPaidEventTest.php b/tests/Unit/Order/OrderPaidEventTest.php new file mode 100644 index 0000000..4883844 --- /dev/null +++ b/tests/Unit/Order/OrderPaidEventTest.php @@ -0,0 +1,110 @@ +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) + ); + } +} diff --git a/tests/Unit/Stripe/StripeWebhookOrderModelTest.php b/tests/Unit/Stripe/StripeWebhookOrderModelTest.php new file mode 100644 index 0000000..07b99d1 --- /dev/null +++ b/tests/Unit/Stripe/StripeWebhookOrderModelTest.php @@ -0,0 +1,164 @@ +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); + } +}