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
[![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

View File

@ -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.

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()
{
static::created(function ($productPurchase) {
$product = ($productPurchase->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.).

View File

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