feat(fulfillment): add model-agnostic PurchaseCompleted event

Introduce a first-class PurchaseCompleted lifecycle event so host apps can run
fulfillment (grant access, send receipts, provision licences) without coupling
to the ProductAction table or to a concrete purchasable model. It fires when a
purchase is created already-COMPLETED and when one transitions into COMPLETED,
and is transition-guarded so it does not re-fire on unrelated saves of an
already-completed purchase.

Also generalise the built-in ProductAction fulfillment in ProductPurchase:
the actionable product is now resolved via config('shop.models.product') /
'...product_price' (instead of a hard instanceof the bundled Product), and
callActions() is only invoked when the resolved product exposes it — so apps
overriding the models, or using IsSimplePurchasable host models, complete
cleanly. Existing behaviour for the bundled Product is unchanged.

Adds 4 EventsWiredUpTest cases; full suite 1404 green. Docs + README updated.
This commit is contained in:
Fabian @ Blax Software 2026-06-02 11:19:21 +02:00
parent 30dc3755d2
commit 96b9a19287
5 changed files with 229 additions and 22 deletions

View File

@ -3,8 +3,8 @@
# Laravel Shop # 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](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) [![Tests Count](https://img.shields.io/badge/tests-1404%20passing-success?style=flat-square)](#testing)
[![Assertions](https://img.shields.io/badge/assertions-3755-blue?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) [![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) [![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) [![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. 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 CI runs the full suite on every push (see the badge above). To run it

View File

@ -734,3 +734,39 @@ Route::get('/orders/{order}', function (Order $order) {
return view('orders.show', compact('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.

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched the moment a {@see ProductPurchase} becomes COMPLETED both when
* a row is created already-completed and when an existing row transitions into
* COMPLETED (it does NOT re-fire on later, unrelated saves of an
* already-completed purchase).
*
* This is the package-agnostic fulfillment seam: host applications listen here
* to grant access, send receipts, provision licences, etc., without coupling
* to the package's own {@see \Blax\Shop\Models\ProductAction} table or to a
* specific purchasable model. The purchase carries everything needed to fan
* out `purchasable` (the product/price/host model that was sold), `price_id`,
* `quantity`, `cart_id`, and `meta`.
*
* Contrast with {@see PurchaseCreated}, which fires for every new purchase row
* regardless of status (including CART/PENDING); listen to PurchaseCompleted
* when you only care about paid/fulfillable purchases.
*/
class PurchaseCompleted
{
use Dispatchable, SerializesModels;
public function __construct(public ProductPurchase $purchase) {}
}

View File

@ -185,36 +185,76 @@ class ProductPurchase extends Model
protected static function booted() protected static function booted()
{ {
static::created(function ($productPurchase) { static::created(function ($productPurchase) {
$product = ($productPurchase->purchasable instanceof Product) if ($productPurchase->status !== PurchaseStatus::COMPLETED) {
? $productPurchase->purchasable return;
: null;
$product ??= ($productPurchase->purchasable instanceof ProductPrice)
? $productPurchase->purchasable?->product
: $product;
if ($productPurchase->status === PurchaseStatus::COMPLETED && $product) {
$product->callActions('purchased', $productPurchase);
} }
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 // updated purchase from unpaid to paid
static::updated(function ($productPurchase) { static::updated(function ($productPurchase) {
$product = ($productPurchase->purchasable instanceof Product) if ($productPurchase->status !== PurchaseStatus::COMPLETED) {
? $productPurchase->purchasable return;
: null; }
$product ??= ($productPurchase->purchasable instanceof ProductPrice) static::runCompletedFulfillment($productPurchase);
? $productPurchase->purchasable?->product
: $product;
// Only announce the transition into COMPLETED once — not on every
if ($productPurchase->status === PurchaseStatus::COMPLETED && $product) { // later save of an already-completed purchase (e.g. a meta touch).
$product->callActions('purchased', $productPurchase); 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 * Run-log of every {@see ProductAction} fired against the underlying
* product for this purchase (welcome email, fulfilment webhook, etc.). * product for this purchase (welcome email, fulfilment webhook, etc.).

View File

@ -9,7 +9,9 @@ use Blax\Shop\Events\OrderCreated;
use Blax\Shop\Events\ProductDeleted; use Blax\Shop\Events\ProductDeleted;
use Blax\Shop\Events\ProductPublished; use Blax\Shop\Events\ProductPublished;
use Blax\Shop\Events\ProductUnpublished; use Blax\Shop\Events\ProductUnpublished;
use Blax\Shop\Events\PurchaseCompleted;
use Blax\Shop\Events\PurchaseCreated; use Blax\Shop\Events\PurchaseCreated;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Events\StockBecameLow; use Blax\Shop\Events\StockBecameLow;
use Blax\Shop\Events\StockClaimed; use Blax\Shop\Events\StockClaimed;
use Blax\Shop\Events\StockClaimExpired; use Blax\Shop\Events\StockClaimExpired;
@ -268,4 +270,100 @@ class EventsWiredUpTest extends TestCase
Event::assertDispatched(PurchaseCreated::class, fn (PurchaseCreated $e) => $e->purchase->is($purchase)); 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);
}
} }