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:
parent
30dc3755d2
commit
96b9a19287
|
|
@ -3,8 +3,8 @@
|
||||||
# Laravel Shop
|
# Laravel Shop
|
||||||
|
|
||||||
[](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml)
|
[](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml)
|
||||||
[](#testing)
|
[](#testing)
|
||||||
[](#testing)
|
[](#testing)
|
||||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
[](https://packagist.org/packages/blax-software/laravel-shop)
|
||||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
[](https://packagist.org/packages/blax-software/laravel-shop)
|
||||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
[](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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -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.).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue