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
|
||||
|
||||
[](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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
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.).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue