diff --git a/README.md b/README.md index 2eda254..952046c 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ # Laravel Shop [![Tests](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml/badge.svg)](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml) -[![Tests Count](https://img.shields.io/badge/tests-1400%20passing-success?style=flat-square)](#testing) -[![Assertions](https://img.shields.io/badge/assertions-3755-blue?style=flat-square)](#testing) +[![Tests Count](https://img.shields.io/badge/tests-1404%20passing-success?style=flat-square)](#testing) +[![Assertions](https://img.shields.io/badge/assertions-3759-blue?style=flat-square)](#testing) [![Latest Version](https://img.shields.io/packagist/v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![License](https://img.shields.io/packagist/l/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![PHP Version](https://img.shields.io/packagist/php-v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) @@ -192,7 +192,7 @@ booking, Stripe sync and the event surface — so host applications can lean on the behaviour with confidence. ``` -Tests: 1400, Assertions: 3755 +Tests: 1404, Assertions: 3759 ``` CI runs the full suite on every push (see the badge above). To run it diff --git a/docs/03-purchasing.md b/docs/03-purchasing.md index d35e5a5..aee8371 100644 --- a/docs/03-purchasing.md +++ b/docs/03-purchasing.md @@ -734,3 +734,39 @@ Route::get('/orders/{order}', function (Order $order) { return view('orders.show', compact('order')); }); ``` + +## Fulfillment via events + +A purchase becoming **COMPLETED** is the moment to grant access, send a +receipt, provision a licence, etc. The package exposes that as a first-class, +model-agnostic event so host apps don't have to couple to the `ProductAction` +table or to a specific purchasable model: + +```php +use Blax\Shop\Events\PurchaseCompleted; + +class GrantAccessOnPurchase +{ + public function handle(PurchaseCompleted $event): void + { + $purchase = $event->purchase; // Blax\Shop\Models\ProductPurchase + $item = $purchase->purchasable; // the Product / ProductPrice / host model sold + + // grant roles, unlock content, email a licence key, … + } +} +``` + +`PurchaseCompleted` fires: + +- when a purchase row is **created already COMPLETED** (e.g. a paid checkout), +- when an existing purchase **transitions into COMPLETED** (`PENDING → COMPLETED`), + +and **not** on later, unrelated saves of an already-completed purchase. For the +broader stream of new rows regardless of status, listen to `PurchaseCreated` +instead. + +In addition, any `ProductAction` configured on the product with the +`purchased` event still runs automatically on completion — the product is +resolved via `config('shop.models.product')` / `...product_price`, so apps +overriding those models are covered too. diff --git a/src/Events/PurchaseCompleted.php b/src/Events/PurchaseCompleted.php new file mode 100644 index 0000000..49c34f4 --- /dev/null +++ b/src/Events/PurchaseCompleted.php @@ -0,0 +1,33 @@ +purchasable instanceof Product) - ? $productPurchase->purchasable - : null; - - $product ??= ($productPurchase->purchasable instanceof ProductPrice) - ? $productPurchase->purchasable?->product - : $product; - - if ($productPurchase->status === PurchaseStatus::COMPLETED && $product) { - $product->callActions('purchased', $productPurchase); + if ($productPurchase->status !== PurchaseStatus::COMPLETED) { + return; } + + static::runCompletedFulfillment($productPurchase); + + // Package-agnostic fulfillment seam: a row created already-completed + // is a completion. Host apps listen here to grant access etc. + \Blax\Shop\Events\PurchaseCompleted::dispatch($productPurchase); }); // updated purchase from unpaid to paid static::updated(function ($productPurchase) { - $product = ($productPurchase->purchasable instanceof Product) - ? $productPurchase->purchasable - : null; + if ($productPurchase->status !== PurchaseStatus::COMPLETED) { + return; + } - $product ??= ($productPurchase->purchasable instanceof ProductPrice) - ? $productPurchase->purchasable?->product - : $product; + static::runCompletedFulfillment($productPurchase); - - if ($productPurchase->status === PurchaseStatus::COMPLETED && $product) { - $product->callActions('purchased', $productPurchase); + // Only announce the transition into COMPLETED once — not on every + // later save of an already-completed purchase (e.g. a meta touch). + if ($productPurchase->wasChanged('status')) { + \Blax\Shop\Events\PurchaseCompleted::dispatch($productPurchase); } }); } + /** + * Run the built-in {@see ProductAction} fulfillment for a completed + * purchase. Resolves the actionable product via the configured product / + * price model (so host apps overriding `shop.models.*` are covered) and + * only invokes `callActions()` when the resolved product actually exposes + * it — host purchasables that opt out of the action table still complete + * cleanly and rely on the {@see \Blax\Shop\Events\PurchaseCompleted} event. + */ + protected static function runCompletedFulfillment(self $productPurchase): void + { + $product = static::resolveActionableProduct($productPurchase); + + if ($product && method_exists($product, 'callActions')) { + $product->callActions('purchased', $productPurchase); + } + } + + /** + * Resolve the product whose ProductActions should run for this purchase. + * Accepts the configured product model (default {@see Product}) directly, + * or a {@see ProductPrice} (configured or base) whose parent product is + * used. Returns null for any other / missing purchasable. + */ + protected static function resolveActionableProduct(self $productPurchase) + { + $purchasable = $productPurchase->purchasable; + if (! $purchasable) { + return null; + } + + $productModel = config('shop.models.product', Product::class); + if ($purchasable instanceof Product || $purchasable instanceof $productModel) { + return $purchasable; + } + + $priceModel = config('shop.models.product_price', ProductPrice::class); + if ($purchasable instanceof ProductPrice || $purchasable instanceof $priceModel) { + return $purchasable->product ?? null; + } + + return null; + } + /** * Run-log of every {@see ProductAction} fired against the underlying * product for this purchase (welcome email, fulfilment webhook, etc.). diff --git a/tests/Feature/EventsWiredUpTest.php b/tests/Feature/EventsWiredUpTest.php index e84ec71..3a57ac0 100644 --- a/tests/Feature/EventsWiredUpTest.php +++ b/tests/Feature/EventsWiredUpTest.php @@ -9,7 +9,9 @@ use Blax\Shop\Events\OrderCreated; use Blax\Shop\Events\ProductDeleted; use Blax\Shop\Events\ProductPublished; use Blax\Shop\Events\ProductUnpublished; +use Blax\Shop\Events\PurchaseCompleted; use Blax\Shop\Events\PurchaseCreated; +use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Events\StockBecameLow; use Blax\Shop\Events\StockClaimed; use Blax\Shop\Events\StockClaimExpired; @@ -268,4 +270,100 @@ class EventsWiredUpTest extends TestCase Event::assertDispatched(PurchaseCreated::class, fn (PurchaseCreated $e) => $e->purchase->is($purchase)); } + + // ─── PurchaseCompleted (fulfillment seam) ───────────────────────────── + + #[Test] + public function creating_a_completed_purchase_dispatches_purchase_completed(): void + { + $product = $this->newProduct(); + $product->increaseStock(1); + + Event::fake([PurchaseCompleted::class]); + + $purchase = ProductPurchase::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => 'user-x', + 'purchaser_type' => 'App\\Models\\User', + 'quantity' => 1, + 'amount' => 0, + 'amount_paid' => 0, + 'status' => PurchaseStatus::COMPLETED, + ]); + + Event::assertDispatched(PurchaseCompleted::class, fn (PurchaseCompleted $e) => $e->purchase->is($purchase)); + } + + #[Test] + public function pending_purchase_does_not_dispatch_purchase_completed(): void + { + $product = $this->newProduct(); + $product->increaseStock(1); + + Event::fake([PurchaseCompleted::class]); + + ProductPurchase::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => 'user-x', + 'purchaser_type' => 'App\\Models\\User', + 'quantity' => 1, + 'amount' => 0, + 'amount_paid' => 0, + 'status' => PurchaseStatus::PENDING, + ]); + + Event::assertNotDispatched(PurchaseCompleted::class); + } + + #[Test] + public function transitioning_a_purchase_to_completed_dispatches_purchase_completed(): void + { + $product = $this->newProduct(); + $product->increaseStock(1); + + $purchase = ProductPurchase::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => 'user-x', + 'purchaser_type' => 'App\\Models\\User', + 'quantity' => 1, + 'amount' => 0, + 'amount_paid' => 0, + 'status' => PurchaseStatus::PENDING, + ]); + + Event::fake([PurchaseCompleted::class]); + + $purchase->update(['status' => PurchaseStatus::COMPLETED]); + + Event::assertDispatched(PurchaseCompleted::class, fn (PurchaseCompleted $e) => $e->purchase->is($purchase)); + } + + #[Test] + public function re_saving_an_already_completed_purchase_does_not_redispatch(): void + { + $product = $this->newProduct(); + $product->increaseStock(1); + + $purchase = ProductPurchase::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => 'user-x', + 'purchaser_type' => 'App\\Models\\User', + 'quantity' => 1, + 'amount' => 0, + 'amount_paid' => 0, + 'status' => PurchaseStatus::COMPLETED, + ]); + + // Now fake, and touch an unrelated column — status didn't change, so + // the completion event must not fire again. + Event::fake([PurchaseCompleted::class]); + + $purchase->update(['meta' => ['note' => 'touched']]); + + Event::assertNotDispatched(PurchaseCompleted::class); + } }