Compare commits

..

No commits in common. "master" and "testbranch" have entirely different histories.

244 changed files with 778 additions and 14773 deletions

40
.github/kaizen.md vendored
View File

@ -49,42 +49,4 @@ if ($singlePrice !== null) {
- `src/Traits/MayBePoolProduct.php` - removed pricing strategy comparison
- `src/Models/Cart.php` - removed pricing strategy comparison
**Key Learning:** ALWAYS verify understanding of business logic before implementing. Pool pricing strategy is about allocation order, not price comparison.
### 2026-01-05: Cart Item Price/Currency Fixes
**Issues Fixed:**
1. Pool singles bookings should show `unit_amount` when added (not 0), even without dates
2. Bug: Date range adjustment was showing wrong price (5000 instead of 1755) when singles had no price
3. Added `currency` column to cart_items table to store currency from selected price
4. Removed obsolete `allocated_single_item_name` from meta (replaced by `product_id` column)
**Root Cause of Price Bug:**
- `updateDates()` was calling `$allocatedSingle->defaultPrice()->first()` instead of using `$this->price()->first()`
- When single has no price, `reallocatePoolItems` sets `price_id` to the pool's price model
- `updateDates()` was ignoring this and going back to the single's (non-existent) price
**Fix Applied:**
```php
// In CartItem::updateDates()
// IMPORTANT: Use the price_id relationship first, as it was set by reallocatePoolItems
$priceModel = $this->price_id ? $this->price()->first() : null;
if ($priceModel) {
$pricePerDay = $priceModel->getCurrentPrice(...);
} else {
// Fallback: Get price from the allocated single, with fallback to pool price
...
}
```
**New CartItem Fields:**
- `currency`: Currency from the selected price model (e.g., 'USD', 'EUR')
**Removed:**
- `meta->allocated_single_item_name` - use `$cartItem->product->name` instead via the `product_id` relationship
**Files Modified:**
- `src/Models/CartItem.php` - added currency, fixed updateDates price resolution
- `src/Models/Cart.php` - added currency to addToCart and reallocatePoolItems
- `src/Traits/MayBePoolProduct.php` - added currency to getNextAvailablePoolItemWithPrice return
- `database/migrations/create_blax_shop_tables.php.stub` - added currency column
**Key Learning:** ALWAYS verify understanding of business logic before implementing. Pool pricing strategy is about allocation order, not price comparison.

13
.github/models.md vendored
View File

@ -40,17 +40,8 @@ The goal of this file is to not miss any important model traits, relationships,
### CartItem
- An item within a Cart.
- Links a `Product` (purchasable) and a specific `ProductPrice`.
- **Key Attributes**:
- `purchasable_id`, `purchasable_type`: The product being purchased
- `product_id`: For pool items, the allocated single item; otherwise null
- `price_id`: The selected price model
- `currency`: Currency from the selected price
- `quantity`: Number of items
- `unit_amount`: Base price per unit (per day for bookings)
- `price`: Calculated price (unit_amount × days for bookings, same as unit_amount for simple)
- `subtotal`: Total (price × quantity)
- `from`, `until`: Booking date range (for booking products)
- Links a `Product` and a specific `ProductPrice`.
- **Key Attributes**: `quantity`, `dates` (for bookings), `configuration`.
## Order Management
### Order

View File

@ -1,10 +1,6 @@
[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software)
# Laravel Shop
# Laravel Shop Package
[![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-1409%20passing-success?style=flat-square)](#testing)
[![Assertions](https://img.shields.io/badge/assertions-3774-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)
@ -31,25 +27,20 @@ A comprehensive headless e-commerce package for Laravel with stock management, S
```bash
composer require blax-software/laravel-shop
```
Publish the configuration:
```bash
php artisan vendor:publish --provider="Blax\Shop\ShopServiceProvider"
```
Run migrations:
```bash
php artisan migrate
```
That's it — the package's migrations are auto-loaded from `vendor/` so a fresh `migrate` is all you need.
Optionally publish the config:
```bash
php artisan vendor:publish --tag="shop-config"
```
If you'd rather own the migrations in your own `database/migrations/` directory (e.g. to customise schemas, switch ID types, etc.):
```bash
php artisan vendor:publish --tag="shop-migrations"
```
To stop the package from also auto-loading them, set `'run_migrations' => false` in `config/shop.php`.
## Configuration
The main configuration file is located at `config/shop.php`. Here you can configure:
@ -186,39 +177,14 @@ $isAvailable = $room->availableOnDate(now(), now()->addHour());
## Testing
We test this package for many edge cases across every surface — products,
stock, pricing strategies, cart/checkout, loan lifecycle, pool aggregation,
booking, Stripe sync and the event surface — so host applications can lean
on the behaviour with confidence.
```
Tests: 1409, Assertions: 3774
```
CI runs the full suite on every push (see the badge above). To run it
locally:
To run the package tests:
```bash
./vendor/bin/phpunit
```
The tests use an in-memory SQLite database and Orchestra Testbench, so they
run in roughly a minute with no external services required.
The tests use an in-memory SQLite database and Orchestra Testbench.
## Documentation
For more detailed documentation, please refer to the `docs/` directory in the repository.
## License
MIT. See [LICENSE](LICENSE).
## Star History
<a href="https://www.star-history.com/?repos=blax-software%2Flaravel-shop&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=blax-software/laravel-shop&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=blax-software/laravel-shop&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=blax-software/laravel-shop&type=date&legend=top-left" />
</picture>
</a>

View File

@ -18,9 +18,9 @@
}
},
"require": {
"php": "^8.2|^8.3|^8.4",
"illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.2|^8.3",
"illuminate/support": "^9.0|^10.0|^11.0|^12.0",
"illuminate/database": "^9.0|^10.0|^11.0|^12.0",
"blax-software/laravel-workkit": "dev-master|*",
"laravel/cashier": "^14.0|^15.0"
},

View File

@ -1,24 +1,6 @@
<?php
return [
/*
* Whether the package should auto-run its migrations.
*
* Default: true fresh installs work plug-and-play (composer require +
* php artisan migrate). The package's own migrations live in vendor/ and
* are auto-loaded.
*
* Set to false if you have already published migrations to your project's
* database/migrations directory and want to manage the schema yourself.
* If you publish *and* leave this true, Laravel's migrator will see the
* same filenames in both locations and run each migration once but
* that requires the published filename to match the source filename. If
* you've published with a different timestamp prefix, disable this flag
* to avoid re-runs.
*/
'run_migrations' => true,
// Table names (customizable for multi-tenancy)
'tables' => [
'cart_items' => 'cart_items',
@ -35,11 +17,8 @@ return [
'product_purchases' => 'product_purchases',
'product_actions' => 'product_actions',
'product_stocks' => 'product_stocks',
'product_price_tiers' => 'product_price_tiers',
'products' => 'products',
'cart_discounts' => 'cart_discounts',
'subscriptions' => 'subscriptions',
'subscription_items' => 'subscription_items',
],
// Model classes (allow overriding in main instance)
@ -48,7 +27,6 @@ return [
'product_price' => \Blax\Shop\Models\ProductPrice::class,
'product_category' => \Blax\Shop\Models\ProductCategory::class,
'product_stock' => \Blax\Shop\Models\ProductStock::class,
'product_price_tier' => \Blax\Shop\Models\ProductPriceTier::class,
'product_attribute' => \Blax\Shop\Models\ProductAttribute::class,
'product_purchase' => \Blax\Shop\Models\ProductPurchase::class,
'cart' => \Blax\Shop\Models\Cart::class,
@ -57,23 +35,6 @@ return [
'order_note' => \Blax\Shop\Models\OrderNote::class,
'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class,
'payment_method' => \Blax\Shop\Models\PaymentMethod::class,
'subscription' => \Blax\Shop\Models\Subscription::class,
'subscription_item' => \Blax\Shop\Models\SubscriptionItem::class,
],
/*
* Subscriptions are Cashier-backed. The package binds its own
* Cashier-extending Subscription / SubscriptionItem models (above) so it
* can link a subscription to a product and run product actions on the
* billing lifecycle. Set `subscriptions.register_cashier_models` to false
* if the host app wants to point Cashier at its own models instead.
*/
'subscriptions' => [
'register_cashier_models' => env('SHOP_REGISTER_CASHIER_MODELS', true),
// Stripe interval => the product-action event fired on a NEW subscription.
'started_event' => 'subscription.started',
'renewed_event' => 'subscription.renewed',
'canceled_event' => 'subscription.canceled',
],
// API Routes configuration
@ -174,40 +135,4 @@ return [
'response_key' => 'data',
],
/*
|--------------------------------------------------------------------------
| Loan / rental defaults
|--------------------------------------------------------------------------
|
| Consumed by ProductPurchase::extend() and ProductPurchase::canExtend()
| when no overrides are passed. Lets a host app (library, equipment-rental
| etc.) tune the lifecycle without subclassing the model.
|
*/
'loan' => [
// Loan policy knobs (used by ProductPurchase lifecycle helpers).
// Pricing lives on the ProductPrice itself (billing_scheme=tiered +
// associated product_price_tiers rows), not here.
'default_duration_weeks' => env('SHOP_LOAN_DURATION_WEEKS', 2),
'extension_weeks' => env('SHOP_LOAN_EXTENSION_WEEKS', 1),
'max_extensions' => env('SHOP_LOAN_MAX_EXTENSIONS', 2),
],
/*
|--------------------------------------------------------------------------
| API pagination
|--------------------------------------------------------------------------
|
| Consumed by the public API controllers (Http/Controllers/Api/*).
| `per_page` default page size when the request doesn't specify one.
| `max_per_page` upper bound the controller will honour regardless of
| what the client asks for, so a malicious or careless
| caller can't request all rows in one request.
|
*/
'pagination' => [
'per_page' => env('SHOP_API_PER_PAGE', 24),
'max_per_page' => env('SHOP_API_MAX_PER_PAGE', 100),
],
];

View File

@ -234,28 +234,4 @@ class OrderFactory extends Factory
'payment_reference' => $reference ?? 'pi_' . $this->faker->regexify('[A-Za-z0-9]{24}'),
]);
}
/**
* Set booking date range.
*/
public function withDateRange(
\DateTimeInterface $from,
\DateTimeInterface $until
): static {
return $this->state([
'from' => $from,
'until' => $until,
]);
}
/**
* Set as a booking order with default date range.
*/
public function booking(): static
{
return $this->state([
'from' => now()->addDay(),
'until' => now()->addDays(3),
]);
}
}

View File

@ -48,8 +48,9 @@ class ProductFactory extends Factory
'is_visible' => true,
'featured' => false,
'manage_stock' => true,
// Stock counts live in the ProductStock ledger — use the
// ->withStocks(int) state to seed an initial INCREASE entry.
'stock_quantity' => $this->faker->numberBetween(0, 100),
'in_stock' => true,
'stock_status' => 'instock',
'published_at' => now(),
'meta' => json_encode(new \stdClass()),
];
@ -57,10 +58,11 @@ class ProductFactory extends Factory
public function outOfStock(): static
{
// manage_stock=true + no ledger entries → isInStock() returns false,
// getAvailableStock() returns 0. That IS the out-of-stock state under
// the ledger-only model — no extra columns required.
return $this->state(['manage_stock' => true]);
return $this->state([
'stock_quantity' => 0,
'in_stock' => false,
'stock_status' => 'outofstock',
]);
}
public function variable(): static
@ -80,11 +82,11 @@ class ProductFactory extends Factory
public function withPrices(
int $count = 1,
null|int $unit_amount = null,
null|int $sale_unit_amount = null
null|float $unit_amount = null,
null|float $sale_unit_amount = null
): static {
return $this->afterCreating(function (Product $product) use ($count, $unit_amount, $sale_unit_amount) {
// All prices are in cents (smallest currency unit)
// Use realistic price range if not specified
$priceAmount = $unit_amount ?? $this->faker->randomElement([
1999, // $19.99
2999, // $29.99

View File

@ -1,22 +0,0 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Models\ProductPriceTier;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<ProductPriceTier> */
class ProductPriceTierFactory extends Factory
{
protected $model = ProductPriceTier::class;
public function definition(): array
{
return [
'up_to' => null,
'unit_amount' => 0,
'flat_amount' => null,
'sort_order' => 0,
];
}
}

View File

@ -1,56 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Tier ladder for a ProductPrice when its billing_scheme is `tiered`.
*
* Each row describes one price tier "rows are charged at this unit_amount
* up to `up_to` units of usage". The last tier in the ladder has `up_to`
* NULL (= unbounded). Mirrors Stripe's tiered-price model so we can sync
* cleanly to the Stripe Price API later.
*
* Usage unit is up to the host's interpretation for loanable products,
* one "unit" = one day of borrowing. For metered subscriptions it could be
* API calls, GB transferred, etc.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable(config('shop.tables.product_price_tiers', 'product_price_tiers'))) {
return;
}
Schema::create(
config('shop.tables.product_price_tiers', 'product_price_tiers'),
function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('price_id')
->constrained(config('shop.tables.product_prices', 'product_prices'))
->cascadeOnDelete();
// null = unbounded ("up to infinity"). Otherwise: this tier
// applies up to this many units (inclusive of the lower
// boundary set by the previous tier's up_to).
$table->unsignedInteger('up_to')->nullable();
// Cents per unit consumed within this tier.
$table->integer('unit_amount')->default(0);
// Optional flat fee added when this tier is entered at all.
$table->integer('flat_amount')->nullable();
// Tie-breaker so the ladder reads in a deterministic order.
$table->unsignedInteger('sort_order')->default(0);
$table->json('meta')->nullable();
$table->timestamps();
$table->index(['price_id', 'sort_order'], 'ppt_price_sort_idx');
}
);
}
public function down(): void
{
Schema::dropIfExists(config('shop.tables.product_price_tiers', 'product_price_tiers'));
}
};

View File

@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Cashier-backed subscription tables, in the package's UUID convention.
*
* Mirrors Laravel Cashier's `subscriptions` / `subscription_items` schema (so
* the package's Cashier-extending models work out of the box) but with UUID
* primary keys to match the rest of the package, plus a nullable `product_id`
* so a subscription can be linked to the {@see \Blax\Shop\Models\Product} it
* sells (used to run product actions on the billing lifecycle), and the
* `current_period_*` columns Cashier 15 syncs from Stripe.
*
* Guarded with `hasTable`, so an app that already owns a `subscriptions` table
* (e.g. one published from Cashier) is left untouched point
* `shop.tables.subscriptions` at a different name if you need both.
*/
return new class extends Migration
{
public function up(): void
{
$subscriptions = config('shop.tables.subscriptions', 'subscriptions');
$subscriptionItems = config('shop.tables.subscription_items', 'subscription_items');
if (! Schema::hasTable($subscriptions)) {
Schema::create($subscriptions, function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('user_id')->index();
$table->uuid('product_id')->nullable()->index();
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamp('current_period_start')->nullable();
$table->timestamp('current_period_end')->nullable();
$table->timestamps();
$table->index(['user_id', 'stripe_status']);
});
}
if (! Schema::hasTable($subscriptionItems)) {
Schema::create($subscriptionItems, function (Blueprint $table) use ($subscriptions) {
$table->uuid('id')->primary();
$table->uuid('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->integer('quantity')->nullable();
$table->timestamps();
$table->index(['subscription_id', 'stripe_price']);
$table->foreign('subscription_id')
->references('id')->on($subscriptions)
->cascadeOnDelete();
});
}
}
public function down(): void
{
Schema::dropIfExists(config('shop.tables.subscription_items', 'subscription_items'));
Schema::dropIfExists(config('shop.tables.subscriptions', 'subscriptions'));
}
};

View File

@ -1,54 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Drop the redundant `stock_quantity` column from `products`.
*
* Stock is the responsibility of the `ProductStock` ledger table; the
* `stock_quantity` column on `products` is a stale denormalisation that
* caused frontends to mis-read availability (e.g. treating "no ledger
* entries yet" as "out of stock" when looking at this column). Drop it.
*
* If you want to seed initial stock during product creation, call
* `$product->increaseStock($qty)` after `Product::create([...])` that
* writes a single INCREASE entry into `ProductStock`, the same way every
* other stock change flows through the system.
*
* The down() restores the column for rollback only; it does NOT backfill
* historical values. Up before rolling back: aggregate the ledger into a
* temporary value if you actually need a number per product.
*/
return new class extends Migration {
public function up(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
if (Schema::hasColumn($table, 'stock_quantity')) {
Schema::table($table, function (Blueprint $t) {
$t->dropColumn('stock_quantity');
});
}
}
public function down(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
if (!Schema::hasColumn($table, 'stock_quantity')) {
Schema::table($table, function (Blueprint $t) {
$t->integer('stock_quantity')->default(0)->after('manage_stock');
});
}
}
};

View File

@ -1,66 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Drop the redundant `in_stock` and `stock_status` columns from `products`.
*
* Both fields were stale denormalisations of "does the product have stock?".
* Every consumer in the package already routes through the ProductStock
* ledger:
*
* - `HasStocks::isInStock()` checks `manage_stock` + getAvailableStock()
* - `HasStocks::scopeInStock()` SUMs the ledger directly
*
* No code reads either column. Dropping them removes a foot-gun where the
* column could disagree with the live ledger state (e.g. an order was placed,
* stock dropped to 0, but nobody updated `in_stock` frontend shows the
* product as orderable when it isn't).
*
* Down restores the columns with their original defaults so a rollback is
* lossless from a schema perspective (the data itself is not backfilled
* if you need it, derive it post-hoc from the ledger).
*/
return new class extends Migration {
public function up(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
Schema::table($table, function (Blueprint $t) use ($table) {
$cols = [];
if (Schema::hasColumn($table, 'in_stock')) {
$cols[] = 'in_stock';
}
if (Schema::hasColumn($table, 'stock_status')) {
$cols[] = 'stock_status';
}
if (!empty($cols)) {
$t->dropColumn($cols);
}
});
}
public function down(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
Schema::table($table, function (Blueprint $t) use ($table) {
if (!Schema::hasColumn($table, 'in_stock')) {
$t->boolean('in_stock')->default(true)->after('manage_stock');
}
if (!Schema::hasColumn($table, 'stock_status')) {
$t->string('stock_status')->default('instock')->after('in_stock');
}
});
}
};

View File

@ -1,61 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Add purchase-limit columns to `products`:
*
* - `max_per_cart`: maximum quantity of this product allowed in a single
* cart at once. NULL = unlimited (the default preserves existing
* behaviour for every already-seeded product).
* - `max_per_user`: maximum quantity a single customer may ever buy across
* all their orders + their currently-open cart. NULL = unlimited.
*
* Both are nullable signed integers because "no cap" is the most common
* configuration and treating 0 as "no cap" would be a foot-gun (an admin
* setting `max_per_cart = 0` clearly means "do not sell," not "unlimited").
* Enforcement lives in {@see \Blax\Shop\Models\Cart::addToCart()}.
*/
return new class extends Migration {
public function up(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
Schema::table($table, function (Blueprint $t) use ($table) {
if (!Schema::hasColumn($table, 'max_per_cart')) {
$t->integer('max_per_cart')->nullable()->after('low_stock_threshold');
}
if (!Schema::hasColumn($table, 'max_per_user')) {
$t->integer('max_per_user')->nullable()->after('max_per_cart');
}
});
}
public function down(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
Schema::table($table, function (Blueprint $t) use ($table) {
$cols = [];
if (Schema::hasColumn($table, 'max_per_cart')) {
$cols[] = 'max_per_cart';
}
if (Schema::hasColumn($table, 'max_per_user')) {
$cols[] = 'max_per_user';
}
if (!empty($cols)) {
$t->dropColumn($cols);
}
});
}
};

View File

@ -25,18 +25,10 @@ return new class extends Migration
$table->timestamp('sale_start')->nullable();
$table->timestamp('sale_end')->nullable();
$table->boolean('manage_stock')->default(false);
// Live stock counts live in the ProductStock ledger — there
// is intentionally no denormalised column on products. See
// HasStocks::getAvailableStock() for the canonical read.
$table->integer('stock_quantity')->default(0);
$table->integer('low_stock_threshold')->nullable();
// Per-product purchase caps. NULL = unlimited (the historical
// default). Enforced in Cart::addToCart() — see
// ExceedsMaxPerCartException / ExceedsMaxPerUserException.
$table->integer('max_per_cart')->nullable();
$table->integer('max_per_user')->nullable();
// Live stock state (in-stock?, status) is computed from the
// ProductStock ledger — see HasStocks::isInStock / scopeInStock.
// No denormalised columns on products.
$table->boolean('in_stock')->default(true);
$table->string('stock_status')->default('instock'); // instock, outofstock, onbackorder
$table->decimal('weight', 10, 2)->nullable();
$table->decimal('length', 10, 2)->nullable();
$table->decimal('width', 10, 2)->nullable();
@ -73,11 +65,7 @@ return new class extends Migration
$table->longText('description')->nullable()->after('short_description');
}
if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'low_stock_threshold')) {
// `manage_stock` is the stable anchor here — the legacy
// `stock_quantity` column is being dropped in a later
// migration, so anchoring against it would fail once that
// runs.
$table->integer('low_stock_threshold')->nullable()->after('manage_stock');
$table->integer('low_stock_threshold')->nullable()->after('stock_quantity');
}
if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'published_at')) {
$table->timestamp('published_at')->nullable()->after('status');
@ -297,7 +285,6 @@ return new class extends Migration
$table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete();
$table->foreignUuid('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete();
$table->integer('quantity')->default(1);
$table->string('currency', 3)->nullable(); // Currency from the selected price
$table->integer('price')->nullable(); // Stored in cents, null = unavailable
$table->integer('regular_price')->nullable(); // Stored in cents
$table->integer('unit_amount')->nullable(); // Base unit price for 1 quantity, 1 day (in cents)
@ -378,10 +365,7 @@ return new class extends Migration
if (!Schema::hasTable(config('shop.tables.product_action_runs', 'product_action_runs'))) {
Schema::create(config('shop.tables.product_action_runs', 'product_action_runs'), function (Blueprint $table) {
$table->id();
// ProductAction uses HasUuids, so action_id must be a uuid/char(36),
// not the bigint that morphs() creates — otherwise logging a run
// (callForProduct) dies with "Incorrect integer value: '<uuid>'".
$table->uuidMorphs('action');
$table->morphs('action');
$table->uuid('product_purchase_id')->nullable();
$table->boolean('success')->default(false);
$table->timestamps();
@ -425,10 +409,6 @@ return new class extends Migration
$table->string('ip_address')->nullable();
$table->text('user_agent')->nullable();
// Booking date range (for booking-related orders)
$table->timestamp('from')->nullable();
$table->timestamp('until')->nullable();
// Important timestamps
$table->timestamp('completed_at')->nullable();
$table->timestamp('paid_at')->nullable();

View File

@ -734,76 +734,3 @@ 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.
## Subscriptions
Subscriptions are **Cashier-backed**. The package ships
`Blax\Shop\Models\Subscription` (extends `Laravel\Cashier\Subscription`) which
adds the commerce link and lifecycle hooks Cashier doesn't have:
- `product()` / `resolveProduct()` — the `Product` this subscription sells
(linked via `product_id`, or resolved from the first item's `stripe_product`).
- `callProductActions($expiresAtOverride, $event)` — run the product's
`ProductAction`s for a lifecycle event, passing the subscription and an
optional access-expiry so grants can be scoped to the billing cycle.
- `recordStarted()` / `recordRenewed()` / `recordCanceled()` — fire the
`SubscriptionStarted` / `SubscriptionRenewed` / `SubscriptionCanceled` events
(and, for started/renewed, run the product actions).
The package points Cashier at these models automatically. If your app already
subclasses Cashier's `Subscription`, set
`shop.subscriptions.register_cashier_models = false` and register your own
models; everything in the package resolves through `shop.models.*` /
`shop.tables.*`, so your models slot in.
```php
use Blax\Shop\Events\SubscriptionRenewed;
class ExtendAccessOnRenewal
{
public function handle(SubscriptionRenewed $event): void
{
// $event->subscription->product, ->current_period_end, …
}
}
```
The subscription tables use the package's UUID convention; the migration is
`hasTable`-guarded, so point `shop.tables.subscriptions` elsewhere if your app
already owns a `subscriptions` table (e.g. a bigint Cashier one).

View File

@ -1,220 +0,0 @@
# Prices — Types and Billing Schemes
Every monetary amount in `laravel-shop` lives on a [`ProductPrice`](../../src/Models/ProductPrice.php) row attached polymorphically to a product. A single product can carry many prices (different currencies, sale prices, tiered ladders, per-variant pricing). Two enums shape what a single price means:
- [`PriceType`](../../src/Enums/PriceType.php) — **when** money changes hands (`ONE_TIME` vs `RECURRING`)
- [`BillingScheme`](../../src/Enums/BillingScheme.php) — **how** the amount is computed (`PER_UNIT` vs `TIERED`)
This document is the reference for both, plus the supporting columns (`sale_unit_amount`, recurring fields, currency).
## ProductPrice anatomy
| Column | Purpose |
|---|---|
| `purchasable_type` / `purchasable_id` | Polymorphic — usually a `Product`, but any `Cartable` can carry prices |
| `name` | Human label (e.g. "EU pricing", "Annual plan", "Member rate") |
| `currency` | ISO 4217 code (`'EUR'`, `'USD'`, …) |
| `unit_amount` | Cents per unit |
| `sale_unit_amount` | Discounted unit_amount; reads via `getCurrentPrice(true)` |
| `sale_start` / `sale_end` | When the sale price is effective (host-app enforced) |
| `is_default` | Exactly one default per (product, currency) — what `getCurrentPrice()` returns |
| `active` | Soft-disable a price without deleting it |
| `type` | [`PriceType`](#price-types) enum cast |
| `billing_scheme` | [`BillingScheme`](#billing-schemes) enum cast |
| `interval` / `interval_count` | For `RECURRING` prices — `MONTH × 1`, `YEAR × 1`, etc. |
| `trial_period_days` | For `RECURRING` — free-trial length |
| `stripe_price_id` | Sync handle (see [Stripe integration](../02-stripe.md)) |
| `meta` | JSON for app-specific extension |
A `ProductPrice` can attach to **any** Cartable, not just Product — see the bottom of this doc.
---
## Price types
### `PriceType::ONE_TIME` — pay-once charges
The default. The buyer pays once. Used by every product type that's bought outright:
- Simple products
- Variation products (a single one-off variant of a Variable parent)
- Booking products (per-day rate, paid at checkout)
- Bundle / Grouped totals
- Loanable rentals (the cost accrues over time but is billed as a single one-time charge at return)
```php
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'type' => PriceType::ONE_TIME,
'currency' => 'EUR',
'unit_amount' => 2999,
'is_default' => true,
]);
```
### `PriceType::RECURRING` — subscriptions
Used for billing that repeats on an interval. Combine with `interval`, `interval_count`, and optionally `trial_period_days`.
```php
ProductPrice::create([
'purchasable_id' => $proPlan->id,
'purchasable_type' => Product::class,
'type' => PriceType::RECURRING,
'interval' => RecurringInterval::MONTH,
'interval_count' => 1,
'trial_period_days' => 14,
'currency' => 'EUR',
'unit_amount' => 999,
'is_default' => true,
]);
```
`RecurringInterval` cases: `DAY`, `WEEK`, `MONTH`, `YEAR`.
Recurring prices sync to Stripe as Prices with `recurring.interval` set; subscriptions live on the customer's Stripe object. See [Stripe integration](../02-stripe.md) for the full sync flow.
---
## Billing schemes
### `BillingScheme::PER_UNIT` — flat per-unit price
The simplest math: `total = unit_amount × quantity` (for Simple / Grouped / Variation), or `total = unit_amount × days × quantity` (for Booking).
```php
ProductPrice::create([
'purchasable_id' => $book->id,
'purchasable_type' => Product::class,
'billing_scheme' => BillingScheme::PER_UNIT,
'currency' => 'EUR',
'unit_amount' => 1499,
'is_default' => true,
]);
$price->calculateForUsage(3); // 4497 cents = 3 × €14.99
```
### Tiered billing (`BillingScheme::TIERED`)
A `TIERED` price walks a ladder of `ProductPriceTier` rows. Each tier covers usage up to its `up_to` mark at `unit_amount` cents per unit, optionally adding a `flat_amount` on tier entry. The last tier (with `up_to = null`) extends to infinity.
**Storage**: tiers live in the `product_price_tiers` table, linked by `price_id`.
**Math**: `ProductPrice::calculateForUsage(float $usage): int` walks the ladder; see [`HasLoanLifecycle::calculateCost()`](../../src/Traits/HasLoanLifecycle.php) for the typical consumer.
**The library example** — free for 2 weeks, then €1/day, then €2/day after 2 months:
```php
$price = ProductPrice::create([
'purchasable_id' => $book->id,
'purchasable_type' => Product::class,
'billing_scheme' => BillingScheme::TIERED,
'currency' => 'EUR',
'is_default' => true,
]);
ProductPriceTier::create(['price_id' => $price->id, 'up_to' => 14, 'unit_amount' => 0, 'sort_order' => 0]);
ProductPriceTier::create(['price_id' => $price->id, 'up_to' => 60, 'unit_amount' => 100, 'sort_order' => 1]);
ProductPriceTier::create(['price_id' => $price->id, 'up_to' => null, 'unit_amount' => 200, 'sort_order' => 2]);
$price->calculateForUsage(20); // 600 — 14 free + 6 × 100
$price->calculateForUsage(75); // 7600 — 14 free + 46 × 100 + 15 × 200
```
**Tier columns**:
| Column | Purpose |
|---|---|
| `price_id` | FK to the parent `ProductPrice` |
| `up_to` | Usage units this tier covers (null = unbounded) |
| `unit_amount` | Cents per unit consumed within this tier |
| `flat_amount` | Optional flat fee added once when the tier is entered |
| `sort_order` | Deterministic walk order |
| `meta` | App-specific extension |
**`flat_amount` use case** — a setup fee + tiered usage:
```php
ProductPriceTier::create(['price_id' => $price->id, 'up_to' => 14, 'unit_amount' => 0, 'flat_amount' => 500, 'sort_order' => 0]); // €5 setup
ProductPriceTier::create(['price_id' => $price->id, 'up_to' => null, 'unit_amount' => 100, 'sort_order' => 1]);
$price->calculateForUsage(20); // 500 (setup) + 0 (free days) + 6 × 100 = 1100
```
The flat amount is charged once per tier *entered*, so a usage value that only touches the first tier still pays €5.
---
## Sale prices
Every `ProductPrice` can carry a `sale_unit_amount` alongside its `unit_amount`. The accessor picks based on the caller:
```php
$price->getCurrentPrice(); // unit_amount
$price->getCurrentPrice(true); // sale_unit_amount (falls back to unit_amount if null)
```
`sale_start` and `sale_end` are stored but the package doesn't enforce them — your application layer decides whether to call `getCurrentPrice(true)` based on the current time.
Sale prices apply to **PER_UNIT** schemes naturally. For **TIERED** schemes, model the discount as alternate tiers on a separate `ProductPrice` row and switch which one is `is_default` for the promotional window — that keeps the math reproducible.
---
## Multiple prices per product
A product can have many `ProductPrice` rows simultaneously:
- One per currency (`EUR`, `USD`, `GBP`)
- A regular price plus a member-only price (tag them via `meta.tier` or `name`)
- A non-default override for specific borrowers (e.g. an institutional rate)
Exactly **one** price per (product, currency) should carry `is_default = true`. The `defaultPrice()` relation on `Product` filters by `is_default` (and active).
To attach a specific price to a purchase at checkout (rather than the default), set `ProductPurchase.price_id` when creating the row. Loanable lifecycle methods then bill against that price for the lifetime of the loan, even if the product's tier ladder changes later.
---
## Which products use which?
| Product type | Idiomatic billing scheme | Idiomatic price type | Sale price? | Notes |
|---|---|---|---|---|
| `SIMPLE` | `PER_UNIT` | `ONE_TIME` (or `RECURRING` for SaaS) | ✅ | The most flexible — supports anything |
| `VARIABLE` | — | — | — | No prices on the parent |
| `VARIATION` | `PER_UNIT` (or `TIERED`) | `ONE_TIME` or `RECURRING` | ✅ | Same flexibility as Simple |
| `GROUPED` | `PER_UNIT` if you set a bundle discount, otherwise none | `ONE_TIME` | ✅ | Children own most pricing |
| `EXTERNAL` | display-only `PER_UNIT` | display-only | display-only | Never charged |
| `BOOKING` | `PER_UNIT` (per-day) | `ONE_TIME` | ✅ | `unit_amount × days × quantity` |
| `POOL` | — | — | — | Derived via `PricingStrategy` from members |
| `LOANABLE` | `TIERED` (or `PER_UNIT` for flat-rate) | `ONE_TIME` | ✅ | Tiers express "free for N days, then €X/day…" |
See each [product type doc](../ProductTypes/00-overview.md) for the worked-out examples.
---
## Prices on non-Product Cartables
`ProductPrice.purchasable_*` is polymorphic. You can attach a price to anything that implements `Blax\Shop\Contracts\Cartable`:
```php
$subscription = SubscriptionPlan::create([...]); // implements Cartable
ProductPrice::create([
'purchasable_id' => $subscription->id,
'purchasable_type' => SubscriptionPlan::class,
'type' => PriceType::RECURRING,
'interval' => RecurringInterval::MONTH,
'unit_amount' => 1999,
'is_default' => true,
]);
```
`HasPrices` (used by Product) provides `prices()` and `defaultPrice()` relations — non-Product hosts can either include the trait or define the relation themselves.
## Related Documentation
- [Product types overview](../ProductTypes/00-overview.md)
- [Loanable products](../ProductTypes/08-loanable-products.md) — the canonical consumer of tiered pricing
- [Stripe integration](../02-stripe.md) — how prices sync to Stripe
- [Product Pool pricing strategies](../ProductTypes/02-pool-products.md) — `LOWEST` / `HIGHEST` / `AVERAGE` aggregation

View File

@ -1,51 +0,0 @@
# Product Types — Overview
Every product in `laravel-shop` declares a `type` from the [`ProductType`](../../src/Enums/ProductType.php) enum. The type controls **how the product behaves** — whether it carries its own stock, how it's added to the cart, what kinds of prices apply, and what the resulting `ProductPurchase` row means.
## At a glance
| Type | What it is | Stock? | Typical price |
|---|---|---|---|
| [`SIMPLE`](./03-simple-products.md) | A stand-alone, single-SKU product | Optional | One-time, per-unit |
| [`VARIABLE`](./04-variable-products.md) | A parent of multiple variants (e.g. T-shirt → S/M/L) | No (variants do) | None on the parent |
| [`VARIATION`](./05-variation-products.md) | One specific variant of a Variable parent | Yes | Per-variation override |
| [`GROUPED`](./06-grouped-products.md) | A bundle / multi-pack of independent child products | No (children do) | Per child |
| [`EXTERNAL`](./07-external-products.md) | A listing that points at an external URL | No | None (no checkout) |
| [`BOOKING`](./01-booking-products.md) | Time-windowed reservation (`from` / `until`) | Yes (date-claimed) | One-time per-day |
| [`POOL`](./02-pool-products.md) | A pool of interchangeable Booking items | No (pool members do) | Aggregated (lowest/highest/avg) |
| [`LOANABLE`](./08-loanable-products.md) | Checked out → extended → returned (library / rental) | Yes (counter) | Tiered usage-priced |
## Which prices apply to which type?
A `ProductPrice` row attaches polymorphically (`purchasable_*`) to any product. Two enums control the price's shape:
- [`PriceType`](../../src/Enums/PriceType.php): `ONE_TIME` or `RECURRING` (subscription-style)
- [`BillingScheme`](../../src/Enums/BillingScheme.php): `PER_UNIT` or `TIERED`
Detailed reference: [Prices — types and billing schemes](../Prices/01-price-types-and-billing-schemes.md).
| Product type | `ONE_TIME / per_unit` | `RECURRING` | `TIERED` | Sale price | Notes |
|---|---|---|---|---|---|
| SIMPLE | ✅ default | ✅ for subscriptions | ✅ for usage-priced SKUs | ✅ | Most flexible — supports anything |
| VARIABLE | — | — | — | — | Variants own the prices; the parent has none |
| VARIATION | ✅ default | ✅ | ✅ | ✅ | Behaves like a Simple product attached to a parent |
| GROUPED | — | — | — | — | Each child carries its own price |
| EXTERNAL | display only | — | — | display only | No checkout — price is shown but never charged |
| BOOKING | ✅ per-day | rare | possible | ✅ | `unit_amount × days` math; tiers would mean tier-per-day |
| POOL | — | — | — | — | Derived from the pool members via `PricingStrategy` |
| LOANABLE | ✅ for flat-rate rentals | rare | ✅ recommended | ✅ | Tiered ladder gives "free for N days, then €X/day, then €Y/day" |
Legend: ✅ supported and idiomatic · "—" not applicable (the type owns no prices of its own) · "rare" technically possible but unusual.
## Pricing strategy vs. billing scheme
These two enums are easy to confuse:
- **`PricingStrategy`** (`LOWEST` / `HIGHEST` / `AVERAGE`) is for **POOL** products only — it tells the pool how to aggregate prices across its member items.
- **`BillingScheme`** (`PER_UNIT` / `TIERED`) is on each **ProductPrice** — it tells the math whether to multiply a flat rate or walk a tier ladder.
## Where to go next
- Browse the product-type docs in this directory ([Booking](./01-booking-products.md), [Pool](./02-pool-products.md), [Simple](./03-simple-products.md), [Variable](./04-variable-products.md), [Variation](./05-variation-products.md), [Grouped](./06-grouped-products.md), [External](./07-external-products.md), [Loanable](./08-loanable-products.md))
- For deep pricing details see [Prices — types and billing schemes](../Prices/01-price-types-and-billing-schemes.md).
- For cart / checkout / purchase mechanics see [Purchasing](../03-purchasing.md).

View File

@ -305,14 +305,13 @@ $cartItem = $cart->addToCart($parkingPool, $quantity = 2, [], $from, $until);
- Claims 1 unit from each: `$spot->claimStock(1, $cartItem, $from, $until)`
3. **Store Claimed Items**
- Cart item's `product_id` column stores which single item was allocated
- Each cart item is linked to one specific single item
- Cart item metadata stores which single items were claimed
- Metadata: `claimed_single_items: [spot1_id, spot2_id]`
4. **Calculate Price**
- Gets price from available single items (using pricing strategy)
- If single has no price, falls back to pool's price
- Multiplies by number of days for booking products
- Stores in cart item (unit_amount, price, subtotal)
- Multiplies by number of days
- Stores in cart item
### Manual Stock Operations
@ -400,15 +399,11 @@ $cartItem = $cart->addToCart($parkingPool, $quantity = 1, [], $from, $until);
// Cart item properties:
// - purchasable_id: Pool Product ID
// - purchasable_type: Product::class
// - product_id: Allocated Single Item ID
// - price_id: Price used (from single or pool fallback)
// - currency: Currency from the selected price
// - product_id: Allocated Single Item ID (NEW!)
// - quantity: 1
// - from: 2025-01-15
// - until: 2025-01-17
// - unit_amount: Price per day (in cents)
// - price: unit_amount × days (calculated booking price)
// - subtotal: price × quantity
// - price: (unit_amount × 2 days)
```
### Product ID Column
@ -692,12 +687,13 @@ $price = $pool->getLowestAvailablePoolPrice($from, $until);
### Single Items Not Released After Cart Deletion
**Cause:** Cart item's `product_id` not properly tracking claimed single
**Cause:** Metadata not properly storing claimed items
**Solution:**
```php
// Check the cart item's product_id
$allocatedSingle = $cartItem->product;
// Ensure cart item has metadata
$meta = $cartItem->getMeta();
$claimedItems = $meta->claimed_single_items ?? [];
// Manually release if needed
$pool->releasePoolStock($cartItem);

View File

@ -1,170 +0,0 @@
# Simple Products
## Overview
Simple products (`ProductType::SIMPLE`) are stand-alone, single-SKU items — the default product shape. They sell as one unit each, optionally track stock, and accept the full range of price configurations. If you're not sure which type to use, start here.
## Key Characteristics
### 1. **One SKU, one cart item**
- Each purchase decrements quantity by N
- No date windows, no variant resolution, no pool aggregation
- The `quantity` on `CartItem` / `ProductPurchase` is literal "N copies sold"
### 2. **Stock is optional**
- `manage_stock = false` (default) — sells with unlimited availability
- `manage_stock = true` — uses the package's stock subsystem (`product_stocks`, `increaseStock` / `decreaseStock`)
### 3. **Most flexible pricing**
- Per-unit one-time prices (the common case)
- Recurring subscription prices
- Tiered prices for usage-based billing
- Sale prices (`sale_unit_amount`)
## How It Works
### Lifecycle
```
add to cart → CartItem(quantity=N)
├─ if manage_stock → decreaseStock(N)
checkout → ProductPurchase(purchasable=Product, quantity=N, amount=N × unit_amount)
```
No date columns are set on the `CartItem` / `ProductPurchase``from` and `until` remain `null`, which is what distinguishes a Simple sale from a Booking / Loan.
## Pricing
Simple products work with every billing shape the package supports. Pick the one that matches your billing semantics.
### One-time, per-unit (most common)
```php
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'type' => PriceType::ONE_TIME,
'billing_scheme' => BillingScheme::PER_UNIT,
'currency' => 'EUR',
'unit_amount' => 2999, // €29.99
'is_default' => true,
]);
```
### With a sale price
```php
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 2999, // regular
'sale_unit_amount' => 1999, // €19.99 on sale
'sale_start' => now(),
'sale_end' => now()->addWeek(),
'is_default' => true,
]);
$product->getCurrentPrice(); // 2999 (regular)
$product->getCurrentPrice(true); // 1999 (sale)
```
### Recurring subscription
```php
ProductPrice::create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'type' => PriceType::RECURRING,
'interval' => RecurringInterval::MONTH,
'interval_count' => 1,
'trial_period_days' => 14,
'unit_amount' => 999, // €9.99/month
'is_default' => true,
]);
```
See [Stripe integration](../02-stripe.md) for how recurring prices sync to Stripe.
### Tiered (usage-based)
For things like API calls, GB transferred, seats. Set `billing_scheme = TIERED` and add `ProductPriceTier` rows — see [Prices — tiered billing](../Prices/01-price-types-and-billing-schemes.md#tiered-billing-billingschemetiered).
## Configuration
### Minimal
```php
$mug = Product::create([
'name' => 'Coffee Mug',
'type' => ProductType::SIMPLE, // also the default if you omit `type`
'slug' => 'coffee-mug',
]);
ProductPrice::create([
'purchasable_id' => $mug->id,
'purchasable_type' => Product::class,
'unit_amount' => 1500,
'is_default' => true,
]);
```
### With stock tracking
```php
$mug = Product::create([
'name' => 'Coffee Mug',
'type' => ProductType::SIMPLE,
'manage_stock' => true,
]);
$mug->increaseStock(50); // we received 50 from the supplier
$mug->getAvailableStock(); // 50
$mug->isInStock(); // true
$mug->isLowStock(); // false until you set low_stock_threshold
```
## Cart Integration
```php
$cart->addToCart($mug, 2); // adds 2 mugs to the cart
```
What happens:
1. A `CartItem` is created with `purchasable_type = Product::class`, `quantity = 2`
2. If `manage_stock = true`, `decreaseStock(2)` runs immediately
3. `subtotal` = `unit_amount × 2`
At checkout the cart item becomes a `ProductPurchase` with `from` and `until` left null.
## Common Use Cases
- **Physical merchandise**: mugs, books (when you don't need loaning), apparel one-offs
- **Digital downloads**: set `virtual = true` and `downloadable = true`
- **Service tickets** without a date attached
- **SaaS plans** (use `RECURRING`)
- **Usage credits** (use `TIERED`)
## Best Practices
1. **Skip stock tracking for unlimited products.** Don't set `manage_stock = true` on infinite digital goods — the audit log churns for no reason.
2. **Set `is_default = true` on exactly one price** per product per currency. The `getCurrentPrice()` accessor picks the default.
3. **Use sale prices instead of writing to `unit_amount`.** `sale_unit_amount` preserves the regular price for after the sale.
4. **Slug uniqueness is enforced.** If you don't set one, the `creating` hook generates `new-product-XXXXXXXX`.
## Troubleshooting
### `getCurrentPrice()` returns 0
You probably forgot to mark the price `is_default = true`. The product has prices but none are flagged as default.
### Stock counter never moves
Make sure `manage_stock = true` is set on the product. `increaseStock` / `decreaseStock` are no-ops otherwise.
## Related Documentation
- [Variable products](./04-variable-products.md) — when one SKU isn't enough
- [Loanable products](./08-loanable-products.md) — when the product is borrowed and returned
- [Prices — types and billing schemes](../Prices/01-price-types-and-billing-schemes.md)
- [Purchasing](../03-purchasing.md)

View File

@ -1,120 +0,0 @@
# Variable Products
## Overview
A Variable product (`ProductType::VARIABLE`) is a **parent** that groups several `VARIATION` children. It exists as a presentation container — "T-shirt available in S, M, L" — but is never itself added to a cart. Buyers always select a specific variation child.
## Key Characteristics
### 1. **Container only**
- The Variable product carries the shared catalogue data (name, description, images, categories)
- It does **not** carry stock and is not added to carts directly
- Each child variation has its own SKU, stock, and (optionally) price
### 2. **Children are linked via `parent_id`**
- `Product.parent_id` on a `VARIATION` row points at the Variable parent
- Eloquent relations `parent()` / `children()` use this column
- Children may also be linked via the `ProductRelationType::VARIATION` relation table for sort order / labels
### 3. **No prices of its own**
- A Variable product typically has zero `ProductPrice` rows
- Listing pages can derive a price range from the children (`min(children.price) max(children.price)`)
## How It Works
```
Product(type=VARIABLE, name='T-Shirt')
├── Product(type=VARIATION, parent_id=<TShirt>, sku='TSHIRT-S', manage_stock=true)
├── Product(type=VARIATION, parent_id=<TShirt>, sku='TSHIRT-M', manage_stock=true)
└── Product(type=VARIATION, parent_id=<TShirt>, sku='TSHIRT-L', manage_stock=true)
```
The customer picks "Medium", and the `M` variation goes into the cart — not the parent.
## Pricing
The Variable parent has **no prices**. All `ProductPrice` rows belong to the individual `VARIATION` children. See the [Variation products doc](./05-variation-products.md) for variant-side pricing.
If your UI shows a "from €X" label on the parent, derive it from the children:
```php
$min = $tshirt->children()
->with('prices')
->get()
->flatMap(fn ($v) => $v->prices)
->where('is_default', true)
->min('unit_amount');
```
## Configuration
```php
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
$tshirt = Product::create([
'name' => 'Logo T-Shirt',
'type' => ProductType::VARIABLE,
'slug' => 'logo-t-shirt',
'manage_stock' => false, // parent never carries stock
]);
foreach (['S', 'M', 'L'] as $size) {
$variant = Product::create([
'name' => "Logo T-Shirt ({$size})",
'type' => ProductType::VARIATION,
'parent_id' => $tshirt->id,
'sku' => "TSHIRT-{$size}",
'manage_stock' => true,
]);
$variant->increaseStock(20);
ProductPrice::create([
'purchasable_id' => $variant->id,
'purchasable_type' => Product::class,
'unit_amount' => 2499,
'is_default' => true,
]);
}
```
## Cart Integration
```php
// ✅ Right — add the child variation
$cart->addToCart($variant, 1);
// ❌ Wrong — adding the parent has no price/stock and will misbehave
// $cart->addToCart($tshirt, 1);
```
## Common Use Cases
- **Apparel** with sizes / colours
- **Coffee** with grind options
- **Software licences** with seat counts (per-seat tier ladders on each variation)
## Best Practices
1. **Hide variable parents from "Add to cart" UI.** Render the variation selector instead.
2. **Keep the parent's data presentational.** Shared marketing copy, images, categories — yes. Inventory and price — no, those live on children.
3. **Set `manage_stock = false` on the parent**, even though it has no stock subsystem activity, to make queries explicit.
4. **Use `parent_id` for the hierarchy** and a `VARIATION` relation row only if you need extra metadata (sort order, displayed label).
## Troubleshooting
### Parent is being added to cart
Filter UI: only show "Add to cart" on children. The package has no built-in guard preventing the parent from being added — it'll create a cart item with no price.
### Children don't show in lists
By default `Product::query()` returns everything including children. Scope the listing to exclude variations or to children of a specific parent:
```php
Product::whereNull('parent_id')->where('type', '!=', ProductType::VARIATION->value);
```
## Related Documentation
- [Variation products](./05-variation-products.md) — the child entities
- [Simple products](./03-simple-products.md) — when you don't need variants
- [Product Relations](../05-product-relations.md)

View File

@ -1,144 +0,0 @@
# Variation Products
## Overview
A Variation (`ProductType::VARIATION`) is a **child** of a [Variable](./04-variable-products.md) parent — one specific variant the customer can actually buy. Functionally a Variation behaves like a [Simple product](./03-simple-products.md) bolted to a parent: it owns its SKU, its stock, and its prices.
## Key Characteristics
### 1. **Always has a parent**
- `parent_id` points at a `VARIABLE` product (`Product::parent()`)
- Catalogue presentation flows from the parent (name, images, description)
### 2. **Owns its own commerce data**
- `sku` is the per-variant code
- Each Variation can `manage_stock` independently
- Each Variation has its own `ProductPrice` rows
### 3. **Cartable on its own**
- The cart accepts variations directly: `$cart->addToCart($variation, 1)`
- The resulting `CartItem.purchasable` is the Variation, not the parent
## How It Works
Visually identical to a Simple product, with one extra column:
```
Product(type=VARIATION, parent_id=<Variable>)
sku → unique code per variation
prices() → its own ProductPrice rows
stocks() → its own product_stocks rows when manage_stock=true
```
The `parent()` relation reads back the Variable parent so listing pages can hydrate shared data.
## Pricing
Variations accept the full pricing surface available to [Simple products](./03-simple-products.md):
| Scheme | When to use |
|---|---|
| `ONE_TIME` / `PER_UNIT` | Standard pricing per variant (e.g. €24.99 for the Medium shirt) |
| `RECURRING` | Subscription variants (e.g. monthly vs annual plans of the same SaaS) |
| `TIERED` | Per-variant usage tiers (e.g. one variant of an API plan that charges by call volume) |
| `sale_unit_amount` | Promotion on a specific variant without disturbing the others |
Example: monthly vs annual plans as variations of one SaaS product.
```php
$saas = Product::create(['name' => 'Pro Plan', 'type' => ProductType::VARIABLE]);
$monthly = Product::create([
'name' => 'Pro Plan — Monthly',
'type' => ProductType::VARIATION,
'parent_id' => $saas->id,
'sku' => 'PRO-MO',
]);
ProductPrice::create([
'purchasable_id' => $monthly->id,
'purchasable_type' => Product::class,
'type' => PriceType::RECURRING,
'interval' => RecurringInterval::MONTH,
'interval_count' => 1,
'unit_amount' => 1900,
'is_default' => true,
]);
$annual = Product::create([
'name' => 'Pro Plan — Annual',
'type' => ProductType::VARIATION,
'parent_id' => $saas->id,
'sku' => 'PRO-YR',
]);
ProductPrice::create([
'purchasable_id' => $annual->id,
'purchasable_type' => Product::class,
'type' => PriceType::RECURRING,
'interval' => RecurringInterval::YEAR,
'unit_amount' => 19000,
'is_default' => true,
]);
```
## Configuration
```php
$variant = Product::create([
'name' => 'Logo T-Shirt (Medium)',
'type' => ProductType::VARIATION,
'parent_id' => $tshirt->id,
'sku' => 'TSHIRT-M',
'manage_stock' => true,
]);
$variant->increaseStock(20);
ProductPrice::create([
'purchasable_id' => $variant->id,
'purchasable_type' => Product::class,
'unit_amount' => 2499,
'is_default' => true,
]);
```
## Cart Integration
```php
$cart->addToCart($variant, 1);
// Resulting purchase:
$purchase->purchasable_type; // App\Models\Product (or your subclass)
$purchase->purchasable_id; // $variant->id
$purchase->product->parent; // the Variable parent, if you need it
```
## Common Use Cases
- **Apparel sizes / colours**
- **Subscription billing intervals** (monthly / annual)
- **Software seat-count plans** (5-seat / 25-seat / unlimited)
- **Hardware configurations** (CPU / RAM tiers of one base machine)
## Best Practices
1. **Always populate `parent_id`.** Orphaned variations are confusing — they'll show in catalogue queries but have no shared marketing copy.
2. **One default price per currency per variation.**
3. **Keep variant-specific data on the variant** (price, stock, SKU, attributes). Keep shared data on the parent (description, images, categories).
4. **Filter listings**. When you query "all products for the catalogue", exclude variations explicitly — they're not standalone catalogue entries.
```php
Product::where('type', '!=', ProductType::VARIATION->value)->get();
```
## Troubleshooting
### Variation shows up in main catalogue listings
Add a `where('type', '!=', ProductType::VARIATION->value)` clause to your listing query, or use a global scope.
### Stock lives on the wrong row
Variations carry their own stock, Variable parents don't. If `getAvailableStock()` returns 0 on a variation, check `manage_stock = true` and that `increaseStock()` was called on the **variation**, not the parent.
## Related Documentation
- [Variable products](./04-variable-products.md) — the parent type
- [Simple products](./03-simple-products.md) — for the un-varianted single-SKU case
- [Product Relations](../05-product-relations.md)

View File

@ -1,133 +0,0 @@
# Grouped Products
## Overview
A Grouped product (`ProductType::GROUPED`) is a **bundle / multi-pack of independent products** that are sold together. Unlike a Variable product (where the children are alternatives), a Grouped product's children are companions — buying the group adds each child to the order.
A "starter kit", a "his + hers set", or "buy this whole season at a discount" are all grouped products.
## Key Characteristics
### 1. **Container of independent SKUs**
- The Grouped product itself has no stock and (usually) no price
- Each child is a fully fledged product (most commonly a `SIMPLE`)
- Children remain individually purchasable
### 2. **Linked via `BUNDLE` relations**
- Children attach through the `product_relations` pivot with `type = 'bundle'`
- Bundles can carry per-relation metadata (quantity, sort order)
### 3. **Per-child pricing**
- The group's total is the sum of its children's effective prices (after sales)
- You can optionally set a `ProductPrice` on the Grouped product itself as a fixed discount price ("normally €X, the bundle is €Y")
## How It Works
```
Product(type=GROUPED, name='Espresso Starter Kit')
└── BUNDLE relations
├── Product(SIMPLE, name='Espresso Machine')
├── Product(SIMPLE, name='Grinder')
└── Product(SIMPLE, name='Beans, 1kg')
```
When the buyer adds the kit to the cart, your checkout flow expands it into per-child cart items (each preserving its own stock and price), or — depending on UI — adds the group as one line item priced as a sum.
## Pricing
| Strategy | How |
|---|---|
| **Sum of children** | No price on the group. Total = `sum(child.unit_amount)`. UI shows the breakdown. |
| **Bundle discount** | Add a single `ProductPrice` to the group with the discounted total. The system shows both the sum-of-children and the bundle price. |
| **Sale on the bundle** | `sale_unit_amount` on the group's price for limited-time bundle promotions. |
Recurring or tiered prices on the bundle itself are unusual — children handle those.
```php
$kit = Product::create([
'name' => 'Espresso Starter Kit',
'type' => ProductType::GROUPED,
'slug' => 'espresso-starter-kit',
]);
// Attach children with a sort order
$kit->productRelations()->attach([
$machine->id => ['type' => ProductRelationType::BUNDLE->value, 'sort_order' => 0],
$grinder->id => ['type' => ProductRelationType::BUNDLE->value, 'sort_order' => 1],
$beans->id => ['type' => ProductRelationType::BUNDLE->value, 'sort_order' => 2],
]);
// Optional: discounted bundle price
ProductPrice::create([
'purchasable_id' => $kit->id,
'purchasable_type' => Product::class,
'unit_amount' => 39900, // €399 instead of €459 individually
'is_default' => true,
]);
```
## Configuration
```php
$kit = Product::create([
'name' => 'Espresso Starter Kit',
'type' => ProductType::GROUPED,
'manage_stock' => false, // each child handles its own stock
]);
// Fetch the bundled items
$items = $kit->productRelations()
->wherePivot('type', ProductRelationType::BUNDLE->value)
->get();
```
## Cart Integration
Two reasonable UI patterns:
### Pattern A — expand on add (recommended for stock accuracy)
```php
foreach ($kit->bundleProducts as $child) {
$cart->addToCart($child, 1);
}
```
Stock decrements per child, and an order shows each line item.
### Pattern B — single bundle line item
```php
$cart->addToCart($kit, 1);
```
Order shows one line. You're responsible for fulfilment logic that ships all children. Stock won't auto-decrement on the children — handle that in your own listener if you need it.
## Common Use Cases
- **Starter kits** (machine + accessories)
- **Seasonal bundles** ("Black Friday set")
- **His + hers / multi-pack** (gift sets)
- **Course + ebook combos** (digital)
## Best Practices
1. **Decide expansion strategy up front.** Pattern A (expand on add) is the safest for inventory; Pattern B is cleaner UX but needs custom stock logic.
2. **Don't manage stock on the group itself.** Children own inventory.
3. **Use `sort_order` on `BUNDLE` relations** to control display order — important for kits where one item is the "headline".
4. **Use a bundle ProductPrice only as a discount.** Otherwise pricing is implicit from the children.
## Troubleshooting
### Bundle total looks wrong
Sum the children explicitly when rendering. The group's own price (if set) is the discount price — it doesn't sum.
### Children not shipping with the order
If you used Pattern B (single line item), wire a `ProductPurchase::created` listener to dispatch a "fulfil bundle children" job, or expand at checkout time.
## Related Documentation
- [Variable products](./04-variable-products.md) — alternatives, not companions
- [Product Relations](../05-product-relations.md) — `BUNDLE` is just one relation type
- [Simple products](./03-simple-products.md) — the typical child of a grouped product

View File

@ -1,115 +0,0 @@
# External Products
## Overview
An External product (`ProductType::EXTERNAL`) is a catalogue entry that **doesn't transact inside the shop** — it points the buyer at a URL on a third-party site. Common pattern for affiliate listings, "view on Amazon" buttons, or referral programmes where the actual checkout happens elsewhere.
## Key Characteristics
### 1. **No internal checkout**
- The product cannot be added to a cart
- No `ProductPurchase` is ever created from this row
- Stock is meaningless and is forced off by the seeder (`manage_stock = false`)
### 2. **Holds a destination link**
- The external URL lives in `meta.external_url` (convention)
- Optional "button label" / affiliate tag also in `meta`
### 3. **Prices are display-only**
- You can attach a `ProductPrice` to show "from €X" for SEO / catalogue parity
- That price is never charged by this package
## How It Works
External products exist purely for catalogue presentation: they render alongside other products with the same images, description, categories, but the "Add to cart" button is replaced with a "View on partner site" link that uses `meta.external_url`.
```
Product(type=EXTERNAL, name='Hyperion paperback')
meta = {
"external_url": "https://amzn.example.com/dp/B00...?tag=mylib-20",
"external_label": "Buy on Amazon"
}
```
## Pricing
External products **don't charge money through the package**. Any `ProductPrice` attached is informational only — useful for showing parity prices in listings.
```php
ProductPrice::create([
'purchasable_id' => $book->id,
'purchasable_type' => Product::class,
'unit_amount' => 1299, // "Was €12.99 on Amazon" — display only
'currency' => 'EUR',
'is_default' => true,
'active' => true,
]);
```
`sale_unit_amount` works the same way — informational, no checkout.
## Configuration
```php
$book = Product::create([
'name' => 'Hyperion',
'type' => ProductType::EXTERNAL,
'slug' => 'hyperion-paperback',
'manage_stock' => false,
'meta' => [
'external_url' => 'https://amzn.example.com/dp/B00...',
'external_label' => 'Buy on Amazon',
],
]);
```
## Cart Integration
There is none — the cart will not process an External product. If you accidentally call `$cart->addToCart($externalProduct)`, the cartable contract still works but the resulting cart item points at a product that has no real price and no fulfilment path. Your UI should never offer the option.
In practice, your storefront renders this:
```blade
@if ($product->type === ProductType::EXTERNAL)
<a href="{{ $product->meta->external_url }}" rel="sponsored" target="_blank">
{{ $product->meta->external_label ?? 'View product' }}
</a>
@else
<button data-add-to-cart="{{ $product->id }}">Add to cart</button>
@endif
```
## Common Use Cases
- **Affiliate listings** with tracked URLs
- **"Out of print, available elsewhere"** library notes
- **Referral programs** to partner stores
- **Catalogue completeness** — book / album / film entries you want indexed in your catalogue but don't actually sell
## Best Practices
1. **Always set `manage_stock = false`.** External products don't have stock.
2. **Store the URL under `meta.external_url`** so any storefront / frontend understands the convention.
3. **Add `rel="sponsored"`** (or `nofollow` for non-affiliate links) to outbound links — SEO hygiene.
4. **Don't sync external products to Stripe.** They have no internal price you'd want to mirror.
## Troubleshooting
### Cart accidentally accepted an external product
Add a guard in your application layer before `$cart->addToCart()`:
```php
if ($product->type === ProductType::EXTERNAL) {
abort(422, 'External products cannot be added to the cart.');
}
```
The package treats every `Cartable` uniformly — type-aware blocking is the host app's job.
### URL renders blank
The `meta` column is cast to `object`, so reading `meta->external_url` (or `meta['external_url']` after `(array)` casting) needs to match how you wrote it. Stick with one convention across writes and reads.
## Related Documentation
- [Simple products](./03-simple-products.md) — the same shape but transactable
- [Product Relations](../05-product-relations.md) — link related internal products to an external listing

View File

@ -1,230 +0,0 @@
# Loanable Products
## Overview
A Loanable product (`ProductType::LOANABLE`) is **checked out, possibly extended, and returned** — the rental / library pattern. Unlike a Booking product (which reserves a fixed date window upfront), a Loanable's end date is open-ended: the borrower picks up an item now, has a due date that can be pushed back via extensions, and the loan ends only when the item is returned.
Loanables are the natural fit for:
- Library books
- Equipment / tool rental
- Vehicle hire by the day with no fixed return date
- Time-priced metered usage (free for N days, then €X/day, then €Y/day after some threshold)
## Key Characteristics
### 1. **`from` is the checkout time, `until` is the (mutable) due date**
- `from` is set when the loan is created
- `until` is the current due date; `extend()` moves it forward
- `meta.returned_at` is stamped when `markReturned()` is called
### 2. **Manage stock as a plain counter**
- A Loanable Product has `manage_stock = true`
- Each loan calls `$product->decreaseStock(1)` at checkout and `$product->increaseStock(1)` on return
- The package's stock subsystem keeps the audit log automatically
### 3. **Tiered usage pricing**
- A Loanable typically carries a `ProductPrice` with `billing_scheme = TIERED`
- The tiers describe "free for the first N days, then €X/day, then €Y/day after some threshold"
- The cost accrues over time and is computed by `ProductPurchase::accruedCost()` (delegating to `ProductPrice::calculateForUsage($days)`)
### 4. **Open-ended lifecycle**
- The loan stays `pending` until the borrower marks it returned
- Cost is computed against `now()` while active, and frozen at `meta.returned_at` after return
## How It Works
### Lifecycle
```
checkout
│ $product->decreaseStock(1)
│ ProductPurchase(from=now, until=now+2w, meta.extensions_used=0, status=pending)
[ active — borrower has the item ]
│ Borrower wants more time?
│ $purchase->extend() // bumps `until` forward by config('shop.loan.extension_weeks')
│ Due date passes without return?
│ $purchase->isOverdue() === true
│ $purchase->accruedCost() keeps growing per the tier ladder
return
$purchase->markReturned() // meta.returned_at = now, status = completed
$product->increaseStock(1)
```
### Loan policy knobs
| Config key | Default | What it controls |
|---|---|---|
| `shop.loan.default_duration_weeks` | 2 | Initial `until` offset from `from` |
| `shop.loan.extension_weeks` | 1 | How far `extend()` pushes `until` per call |
| `shop.loan.max_extensions` | 2 | Cap enforced by `canExtend()` |
These are policy / UI knobs — the lifecycle methods accept overrides per-call, so host apps can layer additional rules without touching config.
## Pricing
Loanable products are priced by **usage** (days), and tiered pricing is the idiomatic shape. The price ladder lives on `ProductPriceTier` rows attached to the `ProductPrice`.
### The library scenario: free for 2 weeks, then €1/day, then €2/day after 2 months
```php
$book = Product::create([
'name' => 'Hyperion',
'type' => ProductType::LOANABLE,
'manage_stock' => true,
]);
$book->increaseStock(3); // three copies available
$price = ProductPrice::create([
'purchasable_id' => $book->id,
'purchasable_type' => Product::class,
'currency' => 'EUR',
'billing_scheme' => BillingScheme::TIERED,
'is_default' => true,
]);
ProductPriceTier::create(['price_id' => $price->id, 'up_to' => 14, 'unit_amount' => 0, 'sort_order' => 0]);
ProductPriceTier::create(['price_id' => $price->id, 'up_to' => 60, 'unit_amount' => 100, 'sort_order' => 1]);
ProductPriceTier::create(['price_id' => $price->id, 'up_to' => null, 'unit_amount' => 200, 'sort_order' => 2]);
```
Day-by-day cost from this ladder:
| Days out | Cost (cents) | Breakdown |
|---|---|---|
| 014 | 0 | Free grace period |
| 15 | 100 | 14 free + 1 day × €1 |
| 30 | 1 600 | 14 free + 16 × €1 |
| 60 | 4 600 | 14 free + 46 × €1 |
| 61 | 4 800 | + 1 day × €2 |
| 90 | 10 600 | 14 free + 46 × €1 + 30 × €2 |
See [Prices — tiered billing](../Prices/01-price-types-and-billing-schemes.md#tiered-billing-billingschemetiered) for the full tier mechanic.
### Other pricing options
| Scheme | When to use |
|---|---|
| `TIERED` | The default for libraries / rentals. Use a single all-free tier if loans are entirely free. |
| `PER_UNIT` (one-time) | Simple flat-rate rentals — `unit_amount` × days. |
| `RECURRING` | Unusual; for subscription-style "unlimited borrowing for €X/month" — use a Simple subscription product, not a Loanable, and gate the loan API on subscription status in your app. |
| `sale_unit_amount` | For promotional "free this week" overrides without rewriting the tier ladder. |
## Configuration
### Cart and checkout
The package's cart accepts a Loanable product like any Cartable. In a typical library API you'd bypass the cart and create the `ProductPurchase` directly:
```php
DB::transaction(function () use ($book, $user) {
$book->decreaseStock(1); // throws NotEnoughStockException if no copy available
return $book->purchases()->create([
'purchaser_id' => $user->id,
'purchaser_type' => User::class,
'price_id' => $book->defaultPrice()->first()?->id,
'quantity' => 1,
'amount' => 0,
'amount_paid' => 0,
'status' => PurchaseStatus::PENDING,
'from' => now(),
'until' => now()->addWeeks(config('shop.loan.default_duration_weeks')),
'meta' => ['extensions_used' => 0],
]);
});
```
### Extending
```php
if ($loan->canExtend()) { // respects config('shop.loan.max_extensions')
$loan->extend(); // shifts `until` by config('shop.loan.extension_weeks')
}
```
`canExtend()` returns false if the loan is already returned **or** overdue. `extend()` itself is permissive — guard your endpoint with `canExtend()`.
### Returning
```php
$loan->markReturned(); // meta.returned_at = now(), status = completed
$loan->purchasable->increaseStock(1);
```
After this, `accruedCost()` stays frozen at the cost-as-of-`returned_at` value.
## Lifecycle helpers
Provided by [`HasLoanLifecycle`](../../src/Traits/HasLoanLifecycle.php) on `ProductPurchase`:
| Method | Returns | Purpose |
|---|---|---|
| `isReturned()` | `bool` | `meta.returned_at` is set |
| `isOverdue()` | `bool` | Active and `until < now()` |
| `returnedAt()` | `?string` (ISO) | The return timestamp, if any |
| `extensionsUsed()` | `int` | How many times `extend()` has been called |
| `canExtend(?int $max)` | `bool` | Honours the max cap, refuses overdue or returned loans |
| `extend(?int $weeks)` | `self` | Bumps `until`, increments meta counter, saves |
| `markReturned(?DateTimeInterface)` | `self` | Sets `meta.returned_at`, flips status to `completed`, saves |
| `getDomainStatus()` | `string` | `'active'`, `'overdue'`, or `'returned'` |
| `accruedCost()` | `int` (cents) | Cost as of now (or `returned_at` if returned) |
| `calculateCost(?$asOf, ?ProductPrice)` | `int` (cents) | Cost at an arbitrary moment, optionally against an override price |
### Scopes
```php
ProductPurchase::activeLoans(); // status=pending, not yet returned
ProductPurchase::returned(); // meta.returned_at not null
ProductPurchase::overdue(); // active + past due date
```
## Common Use Cases
- **Public / private libraries** — the canonical case
- **Tool libraries / equipment rental**
- **Internal device fleet** — laptops, AV gear, lab kit
- **Car / bike sharing** with day-based pricing
- **Conference loaner kits**
## Best Practices
1. **Use the package's stock subsystem.** Don't keep a parallel counter on the host model — `increase/decreaseStock` already does the audit log right.
2. **Wrap `decreaseStock` + `purchases()->create()` in a transaction.** If the purchase insert fails, the stock movement rolls back too.
3. **Always check `canExtend()` before `extend()`.** `extend()` is intentionally permissive so custom policies can compose; the check protects the standard flow.
4. **Surface `accruedCost` through your resource layer.** [`PurchaseResource`](../../src/Http/Resources/PurchaseResource.php) already includes it.
5. **Configure tiers per-product.** Different products (a brand-new bestseller vs. a 1990s paperback) can have entirely different ladders by attaching different `ProductPrice` rows.
6. **Use `price_id` on the purchase** to lock in pricing at checkout — if you later change the product's tier ladder, existing loans still bill against the price they started under.
## Troubleshooting
### `accruedCost()` returns 0 even though days have passed
Either:
- No `ProductPrice` is attached to the product (`purchasable->defaultPrice()` returns null)
- The price's `billing_scheme` is `tiered` but `tiers` is empty
- Tiers are present but all carry `unit_amount = 0`
### Overdue loans don't fall into the `overdue` scope
The scope is `activeLoans + until < now`. If `markReturned()` was already called, the loan is no longer active and won't appear — that's correct behaviour.
### Stock doesn't restore after return
`markReturned()` only updates the purchase row. You must call `$book->increaseStock(1)` (or equivalent) yourself — the package keeps the two operations separate so your business logic can decide whether a damaged-on-return item should restock or not.
### Returning an already-returned loan double-counts stock
Guard your endpoint:
```php
if ($loan->isReturned()) {
abort(422, 'Already returned');
}
```
## Related Documentation
- [Booking products](./01-booking-products.md) — when the loan window is fixed at checkout
- [Simple products](./03-simple-products.md) — for outright sales rather than loans
- [Prices — tiered billing](../Prices/01-price-types-and-billing-schemes.md#tiered-billing-billingschemetiered)
- [Purchasing](../03-purchasing.md)

View File

@ -3,18 +3,8 @@
## Table of Contents
### Product Types
- [**Overview & matrix**](./ProductTypes/00-overview.md) — every product type at a glance plus which prices apply
- [Booking Products](./ProductTypes/01-booking-products.md) — time-windowed reservations (`from` / `until`)
- [Pool Products](./ProductTypes/02-pool-products.md) — interchangeable groups of booking items
- [Simple Products](./ProductTypes/03-simple-products.md) — stand-alone single-SKU items (the default)
- [Variable Products](./ProductTypes/04-variable-products.md) — parents of variants (T-shirt → S/M/L)
- [Variation Products](./ProductTypes/05-variation-products.md) — the actual cartable variants
- [Grouped Products](./ProductTypes/06-grouped-products.md) — bundles / multi-packs
- [External Products](./ProductTypes/07-external-products.md) — affiliate / "view on partner site"
- [Loanable Products](./ProductTypes/08-loanable-products.md) — borrow → extend → return (library / rental)
### Prices
- [Price types & billing schemes](./Prices/01-price-types-and-billing-schemes.md) — `ONE_TIME` vs `RECURRING`, `PER_UNIT` vs `TIERED`, sale prices, `ProductPriceTier`
- [Booking Products](./ProductTypes/01-booking-products.md) - Time-based reservations and rentals
- [Pool Products](./ProductTypes/02-pool-products.md) - Managing groups of booking items
### Core Features
- [Products Overview](./01-products.md) - Basic product management
@ -158,14 +148,13 @@ $upsell = $basicPlan->upsellProducts->first();
```
ProductType Enum:
├── SIMPLE → Stand-alone single-SKU products (default)
├── VARIABLE → Parents of variants
├── VARIATION → A specific variant of a Variable parent
├── GROUPED → Bundle / multi-pack of independent children
├── EXTERNAL → External / affiliate listing (no checkout)
├── BOOKING → Time-windowed reservations ⭐
├── POOL → Interchangeable group of booking items ⭐
└── LOANABLE → Borrow → extend → return (library / rental) ⭐
├── SIMPLE → Standard products
├── VARIABLE → Products with variations
├── GROUPED → Product groups
├── EXTERNAL → External/affiliate products
├── BOOKING → Time-based reservations ⭐
├── VARIATION → Variant of a variable product
└── POOL → Container for booking items ⭐
```
### Relation Types

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductStock;

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Enums\ProductStatus;
@ -228,10 +226,8 @@ class ShopAddExampleProducts extends Command
} elseif ($type === ProductType::POOL->value) {
$this->addPoolItemsForHotel($product, $productData);
} elseif ($type === ProductType::BOOKING->value) {
// Bookings need stock to be bookable. Read the canonical live
// count from the ledger (getAvailableStock) — no `stock_quantity`
// column to consult anymore.
if ($product->getAvailableStock() === 0) {
// Bookings need stock to be bookable
if ($product->stock_quantity === 0) {
$product->increaseStock($productData['stock'] ?? 10);
}
}
@ -677,7 +673,7 @@ class ShopAddExampleProducts extends Command
$parking = Product::create([
'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName),
'name' => $itemName,
'sku' => $pool->sku . '-' . str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT),
'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT),
'type' => ProductType::BOOKING,
'status' => ProductStatus::PUBLISHED,
'is_visible' => false,

View File

@ -1,341 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\Product;
use Carbon\Carbon;
use Illuminate\Console\Command;
class ShopAvailabilityCommand extends Command
{
protected $signature = 'shop:stocks:availability
{product : Product ID, slug, SKU, or partial name}
{--from= : First day of the calendar (YYYY-MM-DD, defaults to start of current month)}
{--to= : Last day of the calendar (YYYY-MM-DD, defaults to end of the --from month)}
{--day= : Show a detail timeline for a single day instead of the calendar (YYYY-MM-DD)}';
protected $description = 'Render an ASCII availability calendar for a product (pool, loanable, booking, or simple)';
private const CELL_WIDTH = 6;
public function handle(): int
{
$product = $this->resolveProduct((string) $this->argument('product'));
if (! $product) {
$this->error("No product matched '{$this->argument('product')}'.");
return self::FAILURE;
}
if ($this->option('day')) {
return $this->renderDayDetail($product, Carbon::parse((string) $this->option('day'))->startOfDay());
}
$rangeStart = $this->option('from')
? Carbon::parse((string) $this->option('from'))->startOfDay()
: Carbon::now()->startOfMonth();
$rangeEnd = $this->option('to')
? Carbon::parse((string) $this->option('to'))->endOfDay()
: $rangeStart->copy()->endOfMonth();
// Snap to a Mon→Sun grid so weeks line up.
$gridStart = $rangeStart->copy()->startOfWeek(Carbon::MONDAY);
$gridEnd = $rangeEnd->copy()->endOfWeek(Carbon::SUNDAY);
$calendar = $product->calendarAvailability($gridStart, $gridEnd);
$this->renderProductHeader($product);
$this->renderSummaryCounters($product);
$this->renderMonthLabel($rangeStart);
$this->renderLegend();
$this->renderCalendarGrid($calendar['dates'], $gridStart, $gridEnd, $rangeStart);
$this->renderFooterStats($calendar);
return self::SUCCESS;
}
private function resolveProduct(string $identifier): ?Product
{
$model = config('shop.models.product', Product::class);
return $model::query()
->where('id', $identifier)
->orWhere('slug', $identifier)
->orWhere('sku', $identifier)
->orWhere('name', 'like', "%{$identifier}%")
->first();
}
private function renderProductHeader(Product $product): void
{
$type = $product->type instanceof \BackedEnum ? $product->type->value : (string) ($product->type ?? '—');
$sku = $product->sku ?: '—';
$this->newLine();
$this->line(' <fg=cyan;options=bold>'.$product->name.'</>');
$this->line(' <fg=gray>type:</> '.$type.' <fg=gray>sku:</> '.$sku.' <fg=gray>id:</> '.$product->id);
$this->newLine();
}
private function renderSummaryCounters(Product $product): void
{
$physical = $product->getPhysicalStock();
$available = $product->getAvailableStock();
$currentClaims = $product->getCurrentlyClaimedStock();
$futureClaims = $product->getFutureClaimedStock();
$activeAndPlanned = $product->getActiveAndPlannedClaimedStock();
// "Physical" is the count of units the business still owns — sums
// available + currently claimed + active loans. For a tomato shop it
// matches available (sales are permanent); for a library it stays at
// the catalogue size regardless of how many copies are currently out.
$this->line(sprintf(
' <fg=cyan;options=bold>Physical %s</> <fg=green;options=bold>Available %s</> <fg=yellow>Currently claimed %d</> <fg=blue>Future claims %d</> <fg=magenta>Active & planned %d</>',
$this->infinityOr($physical),
$this->infinityOr($available),
$currentClaims,
$futureClaims,
$activeAndPlanned,
));
$this->newLine();
}
private function renderMonthLabel(Carbon $focus): void
{
$this->line(' <options=bold>'.$focus->format('F Y').'</>');
$this->newLine();
}
private function renderLegend(): void
{
$this->line(
' <fg=green>━━━━━</> Full availability '.
'<fg=yellow>━━━━━</> Partial '.
'<fg=red>━━━━━</> No stock'
);
$this->newLine();
}
/**
* @param array<string, array{min: int, max: int}> $days
*/
private function renderCalendarGrid(array $days, Carbon $gridStart, Carbon $gridEnd, Carbon $focus): void
{
$w = self::CELL_WIDTH;
$hr = '┌'.implode('┬', array_fill(0, 7, str_repeat('─', $w))).'┐';
$midRule = '├'.implode('┼', array_fill(0, 7, str_repeat('─', $w))).'┤';
$bot = '└'.implode('┴', array_fill(0, 7, str_repeat('─', $w))).'┘';
$this->line(' '.$hr);
$this->line(' │'.collect(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])
->map(fn ($h) => $this->pad($h, $w))->implode('│').'│');
$this->line(' '.$midRule);
$cursor = $gridStart->copy();
$weeks = [];
while ($cursor <= $gridEnd) {
$week = [];
for ($i = 0; $i < 7; $i++) {
$week[] = $cursor->copy();
$cursor->addDay();
}
$weeks[] = $week;
}
foreach ($weeks as $i => $week) {
$dayLine = '│';
$barLine = '│';
$statLine = '│';
foreach ($week as $day) {
$key = $day->toDateString();
$cell = $days[$key] ?? ['min' => 0, 'max' => 0];
$status = $this->statusFor($cell);
$color = $this->colorFor($status);
$inMonth = $day->month === $focus->month;
$isToday = $day->isToday();
$numText = (string) $day->day;
if ($isToday) {
$numText = '['.$numText.']';
}
$numCell = $this->pad($numText, $w);
if ($isToday) {
$numCell = "<fg=cyan;options=bold>$numCell</>";
} elseif (! $inMonth) {
$numCell = "<fg=gray>$numCell</>";
}
$dayLine .= $numCell.'│';
$bar = str_repeat('━', $w - 2);
$barCell = ' '."<fg=$color;options=bold>$bar</>".' ';
$barLine .= $barCell.'│';
$stat = $this->infinityOr($cell['min']).'-'.$this->infinityOr($cell['max']);
$statCell = $this->pad($stat, $w);
if (! $inMonth) {
$statCell = "<fg=gray>$statCell</>";
} else {
$statCell = "<fg=$color>$statCell</>";
}
$statLine .= $statCell.'│';
}
$this->line(' '.$dayLine);
$this->line(' '.$barLine);
$this->line(' '.$statLine);
$this->line(' '.($i === count($weeks) - 1 ? $bot : $midRule));
}
$this->newLine();
}
/**
* @param array{max_available: int, min_available: int, dates: array<string, array{min: int, max: int}>} $calendar
*/
private function renderFooterStats(array $calendar): void
{
$days = $calendar['dates'];
$maxAvailable = $this->infinityOr($calendar['max_available']);
$minAvailable = $this->infinityOr($calendar['min_available']);
$daysTracked = (string) count($days);
$lowStockDays = (string) count(array_filter(
$days,
fn (array $d) => $d['min'] === 0 || $d['max'] === 0,
));
$boxes = [
['MAX AVAILABLE', $maxAvailable, 'cyan'],
['MIN AVAILABLE', $minAvailable, 'red'],
['DAYS TRACKED', $daysTracked, 'gray'],
['LOW STOCK DAYS', $lowStockDays, 'yellow'],
];
$boxWidth = 16;
$top = '┌'.implode('┬', array_fill(0, count($boxes), str_repeat('─', $boxWidth))).'┐';
$bot = '└'.implode('┴', array_fill(0, count($boxes), str_repeat('─', $boxWidth))).'┘';
$labelLine = '│';
$valueLine = '│';
foreach ($boxes as [$label, $value, $color]) {
$labelLine .= "<fg=$color>".$this->pad($label, $boxWidth).'</>│';
$valueLine .= "<fg=$color;options=bold>".$this->pad($value, $boxWidth).'</>│';
}
$this->line(' '.$top);
$this->line(' '.$labelLine);
$this->line(' '.$valueLine);
$this->line(' '.$bot);
$this->newLine();
}
private function renderDayDetail(Product $product, Carbon $day): int
{
$timeline = $product->dayAvailability($day);
$this->renderProductHeader($product);
$this->line(' <fg=cyan;options=bold>'.$day->format('l, F j, Y').'</>');
$this->newLine();
if ($timeline === PHP_INT_MAX) {
$this->line(' <fg=green;options=bold>Unlimited availability all day.</> <fg=gray>(manage_stock = false)</>');
$this->newLine();
return self::SUCCESS;
}
// $timeline is array<string HH:MM, int available>
$this->line(' <fg=gray>Stock changes throughout the day:</>');
$this->newLine();
$rows = [];
$previous = null;
foreach ($timeline as $time => $available) {
// Skip redundant rows where nothing actually changed since the last event.
if ($previous !== null && $available === $previous) {
continue;
}
$previous = $available;
$rows[] = [$time, $available];
}
$timeWidth = 8;
$availWidth = 14;
$noteWidth = 22;
$top = '┌'.str_repeat('─', $timeWidth).'┬'.str_repeat('─', $availWidth).'┬'.str_repeat('─', $noteWidth).'┐';
$mid = '├'.str_repeat('─', $timeWidth).'┼'.str_repeat('─', $availWidth).'┼'.str_repeat('─', $noteWidth).'┤';
$bot = '└'.str_repeat('─', $timeWidth).'┴'.str_repeat('─', $availWidth).'┴'.str_repeat('─', $noteWidth).'┘';
$this->line(' '.$top);
$this->line(' │'.$this->pad('TIME', $timeWidth).'│'.$this->pad('AVAILABLE', $availWidth).'│'.$this->pad('NOTE', $noteWidth).'│');
$this->line(' '.$mid);
foreach ($rows as [$time, $available]) {
$unitWord = $available === 1 ? 'unit' : 'units';
$availText = $available.' '.$unitWord;
$note = $available === 0 ? '⚠ Out of stock' : '';
$color = $available === 0 ? 'red' : 'green';
$timeCell = $this->pad($time, $timeWidth);
$availCell = "<fg=$color;options=bold>".$this->pad($availText, $availWidth).'</>';
$noteCell = "<fg=$color>".$this->pad($note, $noteWidth).'</>';
$this->line(' │'.$timeCell.'│'.$availCell.'│'.$noteCell.'│');
}
$this->line(' '.$bot);
$this->newLine();
$values = array_values($timeline);
$this->line(sprintf(
' <fg=cyan>MIN STOCK</> %d <fg=cyan>MAX STOCK</> %d <fg=gray>EVENTS</> %d',
min($values),
max($values),
count($rows),
));
$this->newLine();
return self::SUCCESS;
}
/**
* @param array{min: int, max: int} $cell
*/
private function statusFor(array $cell): string
{
if ($cell['max'] <= 0) {
return 'none';
}
if ($cell['min'] <= 0) {
return 'partial';
}
return 'full';
}
private function colorFor(string $status): string
{
return match ($status) {
'full' => 'green',
'partial' => 'yellow',
default => 'red',
};
}
private function infinityOr(int $value): string
{
return $value === PHP_INT_MAX ? '∞' : (string) $value;
}
private function pad(string $value, int $width): string
{
$len = mb_strlen($value);
if ($len >= $width) {
return mb_substr($value, 0, $width);
}
$extra = $width - $len;
$left = (int) floor($extra / 2);
$right = $extra - $left;
return str_repeat(' ', $left).$value.str_repeat(' ', $right);
}
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Facades\Shop;
@ -100,7 +98,7 @@ class ShopCleanupCartsCommand extends Command
['ID', 'Status', 'Customer', 'Items', 'Last Activity', 'Created'],
$cartsToDelete->map(fn($cart) => [
substr($cart->id, 0, 8) . '...',
$cart->status?->value ?? '—',
$cart->status->value,
$cart->customer_id ? substr($cart->customer_id, 0, 8) . '...' : 'Guest',
$cart->items()->count(),
$cart->last_activity_at?->diffForHumans() ?? $cart->updated_at->diffForHumans(),

View File

@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListCartsCommand extends Command
{
protected $signature = 'shop:list:carts
{--guest : Only show guest (session-based) carts}
{--with-items : Include item counts}
{--limit=50 : Maximum number of carts to display}';
protected $description = 'List shopping carts (active or guest), optionally with item counts';
public function handle(): int
{
$model = config('shop.models.cart');
$query = $model::query()->latest();
if ($this->option('guest')) {
$query->whereNull('customer_id');
}
if ($this->option('with-items')) {
$query->withCount('items');
}
$limit = max(1, (int) $this->option('limit'));
$carts = $query->limit($limit)->get();
if ($carts->isEmpty()) {
$this->info('No carts found.');
return self::SUCCESS;
}
$headers = ['ID', 'Customer', 'Session', 'Status', 'Last Activity'];
if ($this->option('with-items')) {
$headers[] = 'Items';
}
$rows = $carts->map(function ($cart) {
$customer = $cart->customer_id
? class_basename((string) $cart->customer_type).'#'.substr((string) $cart->customer_id, 0, 8)
: '<guest>';
$status = $cart->status instanceof \BackedEnum ? $cart->status->value : (string) ($cart->status ?? '—');
$row = [
substr((string) $cart->id, 0, 8).'…',
$customer,
$cart->session_id ? substr((string) $cart->session_id, 0, 12).'…' : '—',
$status,
$cart->last_activity_at?->format('Y-m-d H:i') ?? '—',
];
if ($this->option('with-items')) {
$row[] = (int) ($cart->items_count ?? 0);
}
return $row;
});
$this->table($headers, $rows);
$this->info("Showing {$carts->count()} cart(s)");
return self::SUCCESS;
}
}

View File

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListCategoriesCommand extends Command
{
protected $signature = 'shop:list:categories
{--with-products : Include the count of products in each category}';
protected $description = 'List all product categories';
public function handle(): int
{
$model = config('shop.models.product_category');
$query = $model::query()->orderBy('name');
if ($this->option('with-products')) {
$query->withCount('products');
}
$categories = $query->get();
if ($categories->isEmpty()) {
$this->info('No categories found.');
return self::SUCCESS;
}
$headers = ['ID', 'Name', 'Slug', 'Parent'];
if ($this->option('with-products')) {
$headers[] = 'Products';
}
$rows = $categories->map(function ($cat) {
$row = [
$cat->id,
$cat->name,
$cat->slug ?? '—',
$cat->parent_id ? substr((string) $cat->parent_id, 0, 8).'…' : '—',
];
if ($this->option('with-products')) {
$row[] = (int) ($cat->products_count ?? 0);
}
return $row;
});
$this->table($headers, $rows);
$this->info("Total categories: {$categories->count()}");
return self::SUCCESS;
}
}

View File

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListCommand extends Command
{
protected $signature = 'shop:list';
protected $description = 'Show every listable resource in the shop along with its total count';
/**
* Keyed by the subcommand suffix; value is the config key under `shop.models`
* whose bound model is counted. Adding a new shop:list:<thing> command only
* requires extending this map (and registering the new command).
*/
private const LISTABLES = [
'products' => 'product',
'purchases' => 'product_purchase',
'categories' => 'product_category',
'orders' => 'order',
'carts' => 'cart',
];
public function handle(): int
{
$this->newLine();
$this->line(' <fg=cyan;options=bold>Shop listings</>');
$this->newLine();
$rows = [];
foreach (self::LISTABLES as $suffix => $modelKey) {
$modelClass = config("shop.models.{$modelKey}");
$count = $modelClass ? (int) $modelClass::query()->count() : 0;
$rows[] = ["shop:list:{$suffix}", number_format($count).' entries'];
}
$this->table(['Command', 'Total'], $rows);
$this->line(' <fg=gray>Run any of the above to see the full table. Most accept filter options — `<command> --help` shows what.</>');
$this->newLine();
return self::SUCCESS;
}
}

View File

@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListOrdersCommand extends Command
{
protected $signature = 'shop:list:orders
{--customer= : Filter by customer ID (polymorphic)}
{--status= : Filter by order status}
{--limit=50 : Maximum number of orders to display}';
protected $description = 'List orders, optionally filtered by customer or status';
public function handle(): int
{
$model = config('shop.models.order');
$query = $model::query()->latest();
if ($customer = $this->option('customer')) {
$query->where('customer_id', $customer);
}
if ($status = $this->option('status')) {
$query->where('status', $status);
}
$limit = max(1, (int) $this->option('limit'));
$orders = $query->limit($limit)->get();
if ($orders->isEmpty()) {
$this->info('No orders found.');
return self::SUCCESS;
}
$rows = $orders->map(fn ($order) => [
substr((string) $order->id, 0, 8).'…',
(string) ($order->order_number ?? '—'),
$order->customer_id
? class_basename((string) $order->customer_type).'#'.substr((string) $order->customer_id, 0, 8)
: '—',
$order->status instanceof \BackedEnum ? $order->status->value : (string) ($order->status ?? '—'),
number_format(((int) $order->amount_total) / 100, 2),
(string) ($order->currency ?? '—'),
$order->created_at?->format('Y-m-d H:i') ?? '—',
]);
$this->table(['ID', 'Number', 'Customer', 'Status', 'Total', 'Currency', 'Created'], $rows);
$this->info("Showing {$orders->count()} order(s)");
return self::SUCCESS;
}
}

View File

@ -1,81 +1,70 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Enums\ProductStatus;
use Illuminate\Console\Command;
class ShopListProductsCommand extends Command
{
protected $signature = 'shop:list:products
protected $signature = 'shop:list-products
{--with-actions : Include action counts}
{--with-purchases : Include purchase counts}
{--status= : Filter by status (e.g. published, draft, archived)}
{--visible : Only show is_visible=true products}
{--hidden : Only show is_visible=false products}
{--type= : Filter by product type (simple, variable, grouped, external, booking, pool, loanable)}';
{--enabled : Only show enabled products}
{--disabled : Only show disabled products}';
protected $description = 'List all products in the shop';
public function handle(): int
public function handle()
{
$productModel = config('shop.models.product');
$query = $productModel::query();
if ($status = $this->option('status')) {
$query->where('status', $status);
}
if ($this->option('visible')) {
$query->where('is_visible', true);
} elseif ($this->option('hidden')) {
$query->where('is_visible', false);
}
if ($type = $this->option('type')) {
$query->where('type', $type);
if ($this->option('enabled')) {
$query->where('enabled', true);
} elseif ($this->option('disabled')) {
$query->where('enabled', false);
}
if ($this->option('with-actions')) {
$query->withCount('actions');
}
if ($this->option('with-purchases')) {
$query->withCount('purchases');
}
$products = $query->orderBy('name')->get();
$products = $query->orderBy('id')->get();
if ($products->isEmpty()) {
$this->info('No products found.');
return self::SUCCESS;
return 0;
}
$headers = ['ID', 'Name', 'SKU', 'Type', 'Status', 'Visible', 'Default Price'];
$headers = ['ID', 'Name', 'Price', 'Type', 'Enabled'];
if ($this->option('with-actions')) {
$headers[] = 'Actions';
}
if ($this->option('with-purchases')) {
$headers[] = 'Purchases';
}
$rows = $products->map(function ($product) {
$defaultPrice = optional($product->defaultPrice()->first())->getCurrentPrice();
$row = [
substr((string) $product->id, 0, 8).'…',
$product->id,
$product->name,
$product->sku ?: '—',
$this->enumValue($product->type),
$this->enumValue($product->status),
$product->is_visible ? '✓' : '✗',
$defaultPrice !== null ? number_format((float) $defaultPrice, 2) : '—',
$product->price,
$product->type ?? 'N/A',
$product->enabled ? '✓' : '✗',
];
if ($this->option('with-actions')) {
$row[] = (int) ($product->actions_count ?? 0);
$row[] = $product->actions_count ?? 0;
}
if ($this->option('with-purchases')) {
$row[] = (int) ($product->purchases_count ?? 0);
$row[] = $product->purchases_count ?? 0;
}
return $row;
@ -84,14 +73,6 @@ class ShopListProductsCommand extends Command
$this->table($headers, $rows);
$this->info("Total products: {$products->count()}");
return self::SUCCESS;
}
private function enumValue(mixed $value): string
{
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
return $value === null ? '—' : (string) $value;
return 0;
}
}

View File

@ -1,93 +1,60 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
class ShopListPurchasesCommand extends Command
{
protected $signature = 'shop:list:purchases
{product? : Filter by purchasable (Product) ID}
{--purchaser= : Filter by purchaser ID (any polymorphic type)}
{--status= : Filter by purchase status}
protected $signature = 'shop:list-purchases
{product? : Product ID to filter by}
{--user= : Filter by user ID}
{--status= : Filter by status}
{--limit=50 : Number of purchases to show}';
protected $description = 'List product purchases (loans, bookings, sales, …)';
protected $description = 'List product purchases';
public function handle(): int
public function handle()
{
$purchaseModel = config('shop.models.product_purchase');
$query = $purchaseModel::with(['purchasable', 'purchaser', 'price']);
$query = $purchaseModel::with(['product', 'user']);
if ($productId = $this->argument('product')) {
$query->where('purchasable_id', $productId);
$query->where('product_id', $productId);
}
if ($purchaserId = $this->option('purchaser')) {
$query->where('purchaser_id', $purchaserId);
if ($userId = $this->option('user')) {
$query->where('user_id', $userId);
}
if ($status = $this->option('status')) {
$query->where('status', $status);
}
$limit = max(1, (int) $this->option('limit'));
$limit = (int) $this->option('limit');
$purchases = $query->latest()->limit($limit)->get();
if ($purchases->isEmpty()) {
$this->info('No purchases found.');
return self::SUCCESS;
return 0;
}
$rows = $purchases->map(fn ($purchase) => [
substr((string) $purchase->id, 0, 8).'…',
$this->describePurchasable($purchase),
$this->describePurchaser($purchase),
$this->money((int) $purchase->amount),
$this->money((int) $purchase->amount_paid),
$this->enumValue($purchase->status),
$purchase->created_at?->format('Y-m-d H:i') ?? '—',
]);
$headers = ['ID', 'Product', 'User', 'Price', 'Status', 'Date'];
$this->table(
['ID', 'Item', 'Purchaser', 'Amount', 'Paid', 'Status', 'Created'],
$rows,
);
$rows = $purchases->map(function ($purchase) {
return [
$purchase->id,
$purchase->product->name ?? "ID: {$purchase->product_id}",
$purchase->user->name ?? "ID: {$purchase->user_id}",
$purchase->price,
$purchase->status ?? 'N/A',
$purchase->created_at->format('Y-m-d H:i:s'),
];
});
$this->table($headers, $rows);
$this->info("Showing {$purchases->count()} purchase(s)");
return self::SUCCESS;
}
private function describePurchasable($purchase): string
{
$item = $purchase->purchasable;
if ($item === null) {
return 'ID: '.substr((string) $purchase->purchasable_id, 0, 8).'…';
}
$name = $item->name ?? class_basename($item::class);
return $name;
}
private function describePurchaser($purchase): string
{
$by = $purchase->purchaser;
if ($by === null) {
return 'ID: '.substr((string) $purchase->purchaser_id, 0, 8).'…';
}
$label = $by->name ?? $by->email ?? class_basename($by::class);
return $label;
}
private function money(int $cents): string
{
return number_format($cents / 100, 2);
}
private function enumValue(mixed $value): string
{
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
return $value === null ? '—' : (string) $value;
return 0;
}
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
@ -100,6 +98,7 @@ class ShopReinstallCommand extends Command
$this->info('Running shop migrations...');
$this->call('migrate', [
'--path' => 'database/migrations/create_blax_shop_tables.php.stub',
'--force' => true,
]);
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;

View File

@ -1,11 +1,7 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Models\ProductAction;
use Illuminate\Console\Command;
@ -15,68 +11,41 @@ class ShopStatsCommand extends Command
protected $description = 'Display shop statistics';
public function handle(): int
public function handle()
{
$productModel = config('shop.models.product');
$purchaseModel = config('shop.models.product_purchase');
$cartModel = config('shop.models.cart');
$orderModel = config('shop.models.order');
$rows = [];
// Products
$totalProducts = $productModel::count();
$publishedProducts = $productModel::where('status', ProductStatus::PUBLISHED->value)->count();
$visibleProducts = $productModel::where('is_visible', true)->count();
$rows[] = ['Products: total', $totalProducts];
$rows[] = ['Products: published', $publishedProducts];
$rows[] = ['Products: visible', $visibleProducts];
$enabledProducts = $productModel::where('enabled', true)->count();
$disabledProducts = $productModel::where('enabled', false)->count();
// Physical inventory rollup — how many units the business still owns
// across every managed product (loaned/claimed copies count). Skips
// unmanaged products so a single "no scarcity" item doesn't render
// ∞ at the rollup level.
$physicalUnits = $productModel::where('manage_stock', true)
->get()
->sum(fn ($product) => $product->getPhysicalStock());
$rows[] = ['Products: physical units', $physicalUnits];
$rows[] = ['---', '---'];
// Actions
$totalActions = ProductAction::count();
$activeActions = ProductAction::where('active', true)->count();
$rows[] = ['Actions: total', $totalActions];
$rows[] = ['Actions: active', $activeActions];
$rows[] = ['Actions: inactive', $totalActions - $activeActions];
$enabledActions = ProductAction::where('enabled', true)->count();
$disabledActions = ProductAction::where('enabled', false)->count();
$rows[] = ['---', '---'];
// Purchases (loans, bookings, sales)
$totalPurchases = $purchaseModel::count();
$completedPurchases = $purchaseModel::where('status', PurchaseStatus::COMPLETED->value)->count();
$pendingPurchases = $purchaseModel::where('status', PurchaseStatus::PENDING->value)->count();
$revenueCents = (int) $purchaseModel::sum('amount_paid');
$rows[] = ['Purchases: total', $totalPurchases];
$rows[] = ['Purchases: completed', $completedPurchases];
$rows[] = ['Purchases: pending', $pendingPurchases];
$rows[] = ['Revenue (paid)', number_format($revenueCents / 100, 2)];
// Carts (model may be absent in minimal installs — guard accordingly)
if ($cartModel) {
$rows[] = ['---', '---'];
$rows[] = ['Carts: total', $cartModel::count()];
}
// Orders
if ($orderModel) {
$rows[] = ['Orders: total', $orderModel::count()];
}
$totalRevenue = $purchaseModel::sum('price');
$this->info('=== Shop Statistics ===');
$this->newLine();
$this->table(['Metric', 'Value'], $rows);
return self::SUCCESS;
$this->table(
['Metric', 'Count'],
[
['Total Products', $totalProducts],
['Enabled Products', $enabledProducts],
['Disabled Products', $disabledProducts],
['---', '---'],
['Total Actions', $totalActions],
['Enabled Actions', $enabledActions],
['Disabled Actions', $disabledActions],
['---', '---'],
['Total Purchases', $totalPurchases],
['Total Revenue', number_format($totalRevenue, 2)],
]
);
return 0;
}
}

View File

@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Carbon\Carbon;
use Illuminate\Console\Command;
class ShopStocksClaimsCommand extends Command
{
protected $signature = 'shop:stocks:claims
{product? : Limit to one product (ID, slug, SKU, or partial name). Omit to list claims across the catalogue.}
{--active : Only show claims that are active right now}
{--limit=50 : Maximum number of claims to display}';
protected $description = 'List pending stock claims (active or upcoming reservations) — useful for "why is this not available?" investigations';
public function handle(): int
{
$limit = max(1, (int) $this->option('limit'));
$onlyActive = (bool) $this->option('active');
$now = Carbon::now();
$query = ProductStock::query()
->withoutGlobalScope('willExpire')
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value);
if ($identifier = $this->argument('product')) {
$product = $this->resolveProduct((string) $identifier);
if (! $product) {
$this->error("No product matched '{$identifier}'.");
return self::FAILURE;
}
$query->where('product_id', $product->getKey());
$this->newLine();
$this->line(' <fg=cyan;options=bold>'.$product->name.'</> <fg=gray>('.($product->sku ?: $product->id).')</>');
} else {
$this->newLine();
$this->line(' <fg=cyan;options=bold>All pending claims across the catalogue</>');
}
if ($onlyActive) {
$query->where(function ($q) use ($now) {
$q->whereNull('claimed_from')->orWhere('claimed_from', '<=', $now);
})->where(function ($q) use ($now) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', $now);
});
$this->line(' <fg=gray>Filter: currently active only</>');
}
$claims = $query
->orderBy('claimed_from')
->orderBy('expires_at')
->limit($limit)
->get();
$this->newLine();
if ($claims->isEmpty()) {
$this->line(' <fg=gray>(no pending claims found)</>');
$this->newLine();
return self::SUCCESS;
}
$rows = $claims->map(function (ProductStock $stock) use ($now): array {
$state = $this->classify($stock, $now);
return [
$stock->product?->name ? $this->truncate($stock->product->name, 22) : (string) $stock->product_id,
(int) abs((int) $stock->quantity),
$stock->claimed_from?->format('Y-m-d H:i') ?? 'immediate',
$stock->expires_at?->format('Y-m-d H:i') ?? 'no expiry',
$state,
$stock->reference_type ? class_basename($stock->reference_type).'#'.substr((string) $stock->reference_id, 0, 8) : '—',
$this->truncate((string) ($stock->note ?? ''), 28),
];
})->all();
$this->table(
['Product', 'Qty', 'Claim From', 'Expires', 'State', 'Reference', 'Note'],
$rows,
);
$this->line(' <fg=gray>Showing '.$claims->count().' claim'.($claims->count() === 1 ? '' : 's').' (limit '.$limit.').</>');
$this->newLine();
return self::SUCCESS;
}
private function classify(ProductStock $stock, Carbon $now): string
{
if ($stock->claimed_from && $stock->claimed_from > $now) {
return 'upcoming';
}
if ($stock->expires_at && $stock->expires_at <= $now) {
return 'expired';
}
return 'active';
}
private function resolveProduct(string $identifier): ?Product
{
$model = config('shop.models.product', Product::class);
return $model::query()
->where('id', $identifier)
->orWhere('slug', $identifier)
->orWhere('sku', $identifier)
->orWhere('name', 'like', "%{$identifier}%")
->first();
}
private function truncate(string $value, int $max): string
{
return mb_strlen($value) > $max ? mb_substr($value, 0, $max - 1).'…' : $value;
}
}

View File

@ -1,221 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Models\Product;
use Illuminate\Console\Command;
class ShopStocksCommand extends Command
{
protected $signature = 'shop:stocks
{product? : Product ID, slug, SKU, or partial name. Omit to see a stock overview across all products.}
{--limit=20 : Maximum number of ledger entries to show in detail view}';
protected $description = 'Show stock totals and the recent ledger for a product, or an overview across all products';
public function handle(): int
{
$identifier = $this->argument('product');
return $identifier
? $this->renderProductDetail((string) $identifier, max(1, (int) $this->option('limit')))
: $this->renderOverview();
}
private function renderOverview(): int
{
$productModel = config('shop.models.product', Product::class);
$products = $productModel::query()->orderBy('name')->get();
if ($products->isEmpty()) {
$this->info('No products found.');
return self::SUCCESS;
}
$rows = $products->map(function (Product $product): array {
$assigned = $product->manage_stock ? $this->assignedCapacity($product) : null;
$physical = $product->manage_stock ? $product->getPhysicalStock() : null;
$used = $product->manage_stock ? $this->totalUsed($product) : null;
$available = $product->getAvailableStock();
$claimed = $product->getCurrentlyClaimedStock();
$type = $product->type instanceof \BackedEnum ? $product->type->value : (string) ($product->type ?? '—');
return [
'id' => substr((string) $product->id, 0, 8).'…',
'name' => $this->truncate((string) $product->name, 30),
'type' => $type,
'assigned' => $assigned === null ? '∞' : (string) $assigned,
'physical' => $physical === null ? '∞' : (string) $physical,
'used' => $used === null ? '—' : (string) $used,
'available' => $available === PHP_INT_MAX ? '∞' : (string) $available,
'claimed' => (string) $claimed,
];
})->all();
$this->newLine();
$this->table(
['ID', 'Name', 'Type', 'Assigned', 'Physical', 'Used', 'Available', 'Claimed'],
$rows,
);
$this->line(' <fg=gray>Total products: '.$products->count().' '.
'Run <fg=cyan>shop:stocks {product}</> for a detailed report.</>');
$this->newLine();
return self::SUCCESS;
}
private function renderProductDetail(string $identifier, int $limit): int
{
$product = $this->resolveProduct($identifier);
if (! $product) {
$this->error("No product matched '{$identifier}'.");
return self::FAILURE;
}
$type = $product->type instanceof \BackedEnum ? $product->type->value : (string) ($product->type ?? '—');
$sku = $product->sku ?: '—';
$this->newLine();
$this->line(' <fg=cyan;options=bold>'.$product->name.'</>');
$this->line(' <fg=gray>type:</> '.$type.' <fg=gray>sku:</> '.$sku.' <fg=gray>id:</> '.$product->id);
$this->newLine();
if (! $product->manage_stock) {
$this->line(' <fg=green;options=bold>Stock management is OFF.</> <fg=gray>(unlimited availability)</>');
$this->newLine();
return self::SUCCESS;
}
$assigned = $this->assignedCapacity($product);
$physical = $product->getPhysicalStock();
$used = $this->totalUsed($product);
$available = $product->getAvailableStock();
$currentClaims = $product->getCurrentlyClaimedStock();
$futureClaims = $product->getFutureClaimedStock();
$activeAndPlanned = $product->getActiveAndPlannedClaimedStock();
$this->renderTotalsBox([
['ASSIGNED', $assigned, 'cyan'],
['PHYSICAL', $physical, 'cyan'],
['USED', $used, 'gray'],
['AVAILABLE', $available, $available > 0 ? 'green' : 'red'],
['CLAIMED NOW', $currentClaims, $currentClaims > 0 ? 'yellow' : 'gray'],
['CLAIMED LATER', $futureClaims, $futureClaims > 0 ? 'blue' : 'gray'],
['ACTIVE+PLANNED', $activeAndPlanned, 'magenta'],
]);
$this->line(' <fg=gray>Recent stock ledger (newest first, capped at '.$limit.' entries):</>');
$this->newLine();
$ledger = $product->stocks()
->withoutGlobalScope('willExpire')
->orderByDesc('created_at')
->limit($limit)
->get();
if ($ledger->isEmpty()) {
$this->line(' <fg=gray>(no ledger entries yet)</>');
$this->newLine();
return self::SUCCESS;
}
$this->table(
['When', 'Type', 'Status', 'Qty', 'Claim From', 'Expires', 'Note'],
$ledger->map(fn ($s) => [
$s->created_at?->format('Y-m-d H:i') ?? '—',
$s->type instanceof \BackedEnum ? $s->type->value : (string) $s->type,
$s->status instanceof \BackedEnum ? $s->status->value : (string) $s->status,
(int) $s->quantity,
$s->claimed_from?->format('Y-m-d H:i') ?? '—',
$s->expires_at?->format('Y-m-d H:i') ?? '—',
$this->truncate((string) ($s->note ?? ''), 30),
])->all(),
);
return self::SUCCESS;
}
private function totalUsed(Product $product): int
{
return (int) abs(
(int) $product->stocks()
->withoutGlobalScope('willExpire')
->where('type', StockType::DECREASE->value)
->where('status', StockStatus::COMPLETED->value)
->sum('quantity')
);
}
/**
* Physical inventory the operator should see as "Assigned" i.e. how many
* copies the business actually owns.
*
* For non-loanable products this is just `getMaxStocksAttribute()` (sum of
* INCREASE + RETURN entries). For loanable products that calc inflates
* after every borrow→return cycle because the host's restock fires a
* fresh INCREASE row; MayBeLoanableProduct's `total_quantity` accessor
* sidesteps that by computing "available + active loans" instead. The
* trait is mixed into Product unconditionally, so it's safe to consult
* here for every product type.
*/
private function assignedCapacity(Product $product): int
{
return (int) $product->total_quantity;
}
/**
* @param list<array{0: string, 1: int, 2: string}> $boxes
*/
private function renderTotalsBox(array $boxes): void
{
$boxWidth = 16;
$rule = fn (string $l, string $j, string $r) => $l.implode($j, array_fill(0, count($boxes), str_repeat('─', $boxWidth))).$r;
$labelLine = '│';
$valueLine = '│';
foreach ($boxes as [$label, $value, $color]) {
$display = $value === PHP_INT_MAX ? '∞' : (string) $value;
$labelLine .= "<fg=$color>".$this->pad($label, $boxWidth).'</>│';
$valueLine .= "<fg=$color;options=bold>".$this->pad($display, $boxWidth).'</>│';
}
$this->line(' '.$rule('┌', '┬', '┐'));
$this->line(' '.$labelLine);
$this->line(' '.$valueLine);
$this->line(' '.$rule('└', '┴', '┘'));
$this->newLine();
}
private function resolveProduct(string $identifier): ?Product
{
$model = config('shop.models.product', Product::class);
return $model::query()
->where('id', $identifier)
->orWhere('slug', $identifier)
->orWhere('sku', $identifier)
->orWhere('name', 'like', "%{$identifier}%")
->first();
}
private function pad(string $value, int $width): string
{
$len = mb_strlen($value);
if ($len >= $width) {
return mb_substr($value, 0, $width);
}
$extra = $width - $len;
$left = (int) floor($extra / 2);
return str_repeat(' ', $left).$value.str_repeat(' ', $extra - $left);
}
private function truncate(string $value, int $max): string
{
return mb_strlen($value) > $max ? mb_substr($value, 0, $max - 1).'…' : $value;
}
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction;
@ -25,7 +23,7 @@ class ShopTestActionCommand extends Command
return 1;
}
$this->info("Testing action: {$action->class}");
$this->info("Testing action: {$action->action_class}");
$this->info("Product: {$action->product->name} (ID: {$action->product_id})");
$this->info("Event: {$action->event}");
@ -36,14 +34,8 @@ class ShopTestActionCommand extends Command
try {
if ($this->option('sync')) {
// Resolve the action class. If a fully-qualified name is set
// on `class`, use it as-is; otherwise prefix the configured
// actions namespace so short names still resolve.
$actionClass = $action->class;
if (! str_contains($actionClass, '\\')) {
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
$actionClass = $namespace . '\\' . $actionClass;
}
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
$action_job = $namespace . '\\' . $action->action_class;
$params = [
'product' => $action->product,
@ -52,22 +44,10 @@ class ShopTestActionCommand extends Command
...($action->parameters ?? []),
];
$instance = new $actionClass(...$params);
if (method_exists($instance, 'handle')) {
$instance->handle();
} elseif (is_callable($instance)) {
$instance();
} else {
throw new \RuntimeException("Action class {$actionClass} is neither callable nor has a handle() method.");
}
(new $action_job(...$params))->handle();
$this->info('Action executed synchronously.');
} else {
// Run the action through the package's normal runner — which
// honours defer/method/parameters and writes a ProductActionRun
// row. ProductAction::callForProduct() looks up matching
// actions by event; passing the action's own first event
// guarantees this single action fires.
ProductAction::callForProduct($action->product, $action->event, null, []);
$action->execute($action->product, null, []);
$this->info('Action dispatched to queue.');
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Blax\Shop\Models\ProductAction;
@ -26,25 +24,20 @@ class ShopToggleActionCommand extends Command
return 1;
}
// The ProductAction column is `active`, not `enabled` — the old code
// wrote to a non-existent attribute, which silently no-op'd (or threw
// a SQL error in strict mode). The user-facing verbs stay
// "enabled"/"disabled" since that's what operators understand, but the
// column we touch is `active`.
if ($this->option('enable')) {
$action->active = true;
$action->enabled = true;
$status = 'enabled';
} elseif ($this->option('disable')) {
$action->active = false;
$action->enabled = false;
$status = 'disabled';
} else {
$action->active = ! $action->active;
$status = $action->active ? 'enabled' : 'disabled';
$action->enabled = !$action->enabled;
$status = $action->enabled ? 'enabled' : 'disabled';
}
$action->save();
$this->info("Action #{$action->id} ({$action->class}) has been {$status}.");
$this->info("Action #{$action->id} ({$action->action_class}) has been {$status}.");
return 0;
}

View File

@ -1,30 +1,7 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Contracts;
/**
* Marker contract for anything that can be added to a {@see \Blax\Shop\Models\Cart}.
*
* The contract is intentionally empty implementing `Cartable` declares
* intent. {@see \Blax\Shop\Models\Cart::addToCart()} checks
* `$item instanceof Cartable` and rejects models that haven't opted in,
* to prevent accidentally cart-ing rows that have no domain meaning as a
* purchase line (a `User` or `Address`, say).
*
* Implementors typically also implement {@see Purchasable} so the cart
* can resolve a price, but the two are independent a `ProductPrice`
* row is `Cartable` only (the {@see Purchasable} half lives on the
* parent `Product`).
*
* Pair this with {@see \Blax\Shop\Traits\IsSimplePurchasable} on a plain
* Eloquent model for a no-subclass integration, or extend
* {@see \Blax\Shop\Models\Product} for the full e-commerce surface.
*
* @see \Blax\Shop\Models\Cart::addToCart()
* @see \Blax\Shop\Exceptions\CartableInterfaceException Thrown when a non-Cartable model is passed to the cart.
*/
interface Cartable
{
}

View File

@ -1,48 +1,10 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Contracts;
/**
* Contract for billable parties (typically the customer / authenticated user)
* that own one or more stored payment methods on a payment provider.
*
* A `Chargable` actor is the *who* in "who pays for this {@see Purchasable}".
* Orders and recurring charges resolve the actor's preferred provider +
* method through this contract before handing off to the payment service.
*
* Reference implementation: pair {@see \Blax\Shop\Traits\HasPaymentMethods}
* onto a `User` model the trait satisfies both contract methods (the
* Collection it returns degrades to an array via Eloquent's serialization,
* which is what the array return type on this interface captures).
*
* Note: this contract is deliberately small. Anything beyond "which method
* do I bill against" lives on {@see \Blax\Shop\Models\PaymentMethod} or
* the provider service ({@see \Blax\Shop\Services\PaymentProvider\PaymentProviderService}).
*/
interface Chargable
{
/**
* Return the provider key of the actor's default payment method
* (e.g. `'stripe'`), or `null` when none is configured yet.
*
* The order pipeline uses this to pick the right provider service
* when no method is supplied explicitly at checkout.
*/
public function getDefaultPaymentMethod(): ?string;
/**
* Return the actor's payment methods.
*
* Implementations may return an indexed array of method
* descriptors or any iterable that array-casts cleanly the
* canonical implementation on {@see \Blax\Shop\Traits\HasPaymentMethods}
* returns an {@see \Illuminate\Support\Collection} of
* {@see \Blax\Shop\Models\PaymentMethod}, which satisfies this
* contract via Collection-to-array coercion.
*
* @return array<int, mixed>|\Illuminate\Support\Collection
*/
public function paymentMethods(): array;
}

View File

@ -1,96 +1,19 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Contracts;
/**
* Contract for models that can be priced, stocked, and recorded as a purchase.
*
* Where {@see Cartable} is a pure marker, `Purchasable` is the *behavioural*
* surface the cart, checkout, and order machinery rely on:
*
* - **Pricing** {@see self::getCurrentPrice()} and
* {@see self::getPriceAttribute()} resolve the unit price the cart
* will charge. Sale-aware implementations toggle on
* {@see self::isOnSale()}.
* - **Inventory** {@see self::decreaseStock()} runs when a unit is
* consumed (cart checkout, loan checkout); {@see self::increaseStock()}
* when one is returned. Both return `bool` so the caller can detect
* a sold-out condition without an exception.
* - **Audit trail** {@see self::purchases()} exposes the historic
* record of consumption events as a polymorphic relation against
* {@see \Blax\Shop\Models\ProductPurchase}.
*
* Two reference implementations ship in the package:
*
* - {@see \Blax\Shop\Models\Product} the full e-commerce model.
* - {@see \Blax\Shop\Traits\IsSimplePurchasable} drop-in trait for
* host models that want the contract without subclassing `Product`.
*
* @see \Blax\Shop\Models\ProductPurchase
* @see \Blax\Shop\Traits\HasStocks Reference inventory implementation.
*/
interface Purchasable
{
/**
* Resolve the unit price this item should currently be charged at,
* in the package's monetary unit (integer cents floated for math).
*
* Return `null` when no price is configured; callers (cart, order
* total) treat `null` as "free" or as an error depending on context.
*/
public function getCurrentPrice(): ?float;
/**
* Eloquent attribute accessor for `$model->price` the same value
* {@see self::getCurrentPrice()} resolves, exposed for convenience
* in Blade / JSON serialization.
*/
public function getPriceAttribute(): ?float;
/**
* Whether the item is currently selling at a discounted price.
*
* Pricing logic uses this to pick between {@see self::getCurrentPrice()}
* and a sale price source. Implementations that don't support sales
* may always return `false`.
*/
public function isOnSale(): bool;
/**
* Consume `$quantity` units of inventory.
*
* Returns `true` when stock was successfully reduced (or when the
* implementation doesn't track stock and so always reports success).
* Returns `false` to signal "not enough"; implementations may
* alternatively throw {@see \Blax\Shop\Exceptions\NotEnoughStockException}.
*
* Race-safety: implementations should prefer an atomic conditional
* UPDATE over a `lockForUpdate` dance see the laravel-shop
* principles doc for the canonical pattern.
*/
public function decreaseStock(int $quantity = 1): bool;
/**
* Restore `$quantity` units to inventory (e.g. on a return / refund).
*
* Symmetric to {@see self::decreaseStock()}. Returning `false` means
* the implementation declined to record the change (typically because
* stock management is disabled on this record).
*/
public function increaseStock(int $quantity = 1): bool;
/**
* Polymorphic relation to the purchase history.
*
* Implementations return a {@see \Illuminate\Database\Eloquent\Relations\MorphMany}
* pointing at {@see \Blax\Shop\Models\ProductPurchase} via the
* `purchasable_*` columns. The return type is intentionally
* unconstrained on the interface to preserve backward compatibility
* see the canonical implementations on `Product` and `IsSimplePurchasable`.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany<\Blax\Shop\Models\ProductPurchase, $this>
*/
public function purchases();
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum BillingScheme: string

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum CartStatus: string

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
/**

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum PriceType: string

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum PricingStrategy: string

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum ProductAttributeType: string

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum ProductRelationType: string

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum ProductStatus: string

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum ProductType: string
@ -13,27 +11,6 @@ enum ProductType: string
case BOOKING = 'booking';
case VARIATION = 'variation';
case POOL = 'pool';
/**
* Loanable: a checked-out-and-returned product. Pair with
* {@see \Blax\Shop\Traits\HasLoanLifecycle} on ProductPurchase to operate
* the borrow extend return flow.
*/
case LOANABLE = 'loanable';
/**
* Service: an intangible/served product (subscriptions, access licences,
* consulting) with no physical stock. Behaves like SIMPLE for cart/stock
* purposes; the distinct type just lets hosts and reporting tell goods
* from services apart.
*/
case SERVICE = 'service';
/**
* Subscription: a service sold on a recurring basis (the actual cadence
* lives on the {@see \Blax\Shop\Models\ProductPrice} as a recurring price).
* Like SERVICE it carries no physical stock; the distinct type lets hosts
* model "this product is fundamentally a subscription" for catalogue and
* reporting.
*/
case SUBSCRIPTION = 'subscription';
public function label(): string
{
@ -45,9 +22,6 @@ enum ProductType: string
self::BOOKING => 'Booking',
self::VARIATION => 'Variation',
self::POOL => 'Pool',
self::LOANABLE => 'Loanable',
self::SERVICE => 'Service',
self::SUBSCRIPTION => 'Subscription',
};
}
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum PurchaseStatus: string

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
enum RecurringInterval: string

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
/**

View File

@ -1,41 +1,34 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
/**
* StockType the kind of movement a {@see \Blax\Shop\Models\ProductStock}
* row represents.
*
* Two-axis classification:
*
* 1. Sign of the stock movement
* INCREASE / RETURN positive (stock added)
* DECREASE negative (stock removed)
* CLAIMED positive quantity but stored as a PENDING
* reservation that nets to negative against
* available stock until the claim is released
* PHYSICALLY_CLAIMED same as CLAIMED, but never auto-released by
* {@see \Blax\Shop\Models\ProductStock::releaseExpired()}.
* Used for loans: the borrower physically has the
* item until they return it the expires_at column
* carries the due date for overdue tracking, not a
* release deadline.
*
* 2. Release model
* INCREASE / DECREASE / RETURN COMPLETED at write time, permanent
* CLAIMED PENDING, auto-released at expires_at
* PHYSICALLY_CLAIMED PENDING, manual release only
*
* The two claim types share availability semantics both subtract from
* `available` and both contribute to `currently_claimed` so most queries
* filter by {@see self::claimTypeValues()} rather than a single case.
* StockType Enum
*
* Defines the types of stock movements that can occur.
*
* Types:
* - CLAIMED: Stock claimed for reservation/booking (creates PENDING entry)
* Used for temporary allocations that can be released
* Examples: hotel bookings, equipment rentals, cart reservations
*
* - RETURN: Stock returned to inventory (e.g., customer returns)
* Creates a positive adjustment to physical stock
*
* - INCREASE: Stock added to inventory (e.g., new purchases, restocking)
* Creates a positive adjustment to physical stock
*
* - DECREASE: Stock removed from inventory (e.g., sales, damage, loss)
* Creates a negative adjustment to physical stock
*
* Usage Flow:
* 1. INCREASE/DECREASE: Direct physical stock changes (COMPLETED status)
* 2. CLAIMED: Temporary allocation (PENDING status, can be released)
* 3. RETURN: Special case of INCREASE for returned items
*/
enum StockType: string
{
case CLAIMED = 'claimed';
case PHYSICALLY_CLAIMED = 'physically_claimed';
case RETURN = 'return';
case INCREASE = 'increase';
case DECREASE = 'decrease';
@ -44,38 +37,9 @@ enum StockType: string
{
return match ($this) {
self::CLAIMED => 'Claimed',
self::PHYSICALLY_CLAIMED => 'Physically claimed',
self::RETURN => 'Return',
self::INCREASE => 'Increase',
self::DECREASE => 'Decrease',
};
}
/**
* The claim-style types both reserve stock against availability and
* keep a PENDING row in the ledger until released. CLAIMED auto-releases
* at `expires_at`; PHYSICALLY_CLAIMED is manual-release only.
*
* @return array<int, self>
*/
public static function claimTypes(): array
{
return [self::CLAIMED, self::PHYSICALLY_CLAIMED];
}
/**
* Same as {@see self::claimTypes()} but as string values, for use in
* `whereIn(...)` SQL clauses.
*
* @return array<int, string>
*/
public static function claimTypeValues(): array
{
return [self::CLAIMED->value, self::PHYSICALLY_CLAIMED->value];
}
public function isClaim(): bool
{
return in_array($this, self::claimTypes(), strict: true);
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a booking is cancelled before it would have started
* shopper cancellation, no-show policy, payment failure, etc. The package
* does not auto-release stock claims on cancellation; that's a listener's
* job (often paired with {@see StockReleased}).
*/
class BookingCancelled
{
use Dispatchable, SerializesModels;
public function __construct(public ProductPurchase $booking) {}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Booking-flavoured counterpart to {@see LoanCreated} / {@see PurchaseCreated}.
* Hosts that build on BOOKING-typed products dispatch this when a
* reservation is confirmed (after the booking calendar has been claimed
* and the price is locked in).
*/
class BookingConfirmed
{
use Dispatchable, SerializesModels;
public function __construct(public ProductPurchase $booking) {}
}

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched by the cart-cleanup sweeper (see
* {@see \Blax\Shop\Console\Commands\ShopCleanupCartsCommand}) when a cart
* has been inactive past the abandon threshold but is not yet hard-expired.
* Listeners typically use this to send recovery emails or release temporary
* stock claims attached to the cart.
*/
class CartAbandoned
{
use Dispatchable, SerializesModels;
public function __construct(public Cart $cart) {}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched at the moment a cart becomes an order usually from the
* checkout flow after the order row is persisted and the cart is marked
* converted. Use to trigger receipt emails, fulfilment workflows, analytics
* conversion pings, etc.
*/
class CartConverted
{
use Dispatchable, SerializesModels;
public function __construct(
public Cart $cart,
public Order $order,
) {}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fires when a new cart row is persisted. Dispatched automatically by
* {@see Cart}'s `$dispatchesEvents` map. Listeners commonly use this for
* analytics ("session X started a cart") or to attach a default currency
* inferred from the request.
*/
class CartCreated
{
use Dispatchable, SerializesModels;
public function __construct(public Cart $cart) {}
}

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a cart hits its `expires_at` (or the cleanup sweeper
* decides to retire it). The cart is about to be deleted; listeners can
* snapshot useful analytics before it goes away.
*/
class CartExpired
{
use Dispatchable, SerializesModels;
public function __construct(public Cart $cart) {}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched after a {@see CartItem} row is created (via the cart service
* or directly). Carries both the cart and the new item so listeners can
* recompute totals, surface "added to cart" toasts, or claim stock.
*/
class CartItemAdded
{
use Dispatchable, SerializesModels;
public function __construct(
public Cart $cart,
public CartItem $item,
) {}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched after a cart item is removed (either by the shopper or via the
* cart service when a product becomes unavailable). The model carried here
* is the already-deleted instance listeners can read its attributes but
* not save back.
*/
class CartItemRemoved
{
use Dispatchable, SerializesModels;
public function __construct(
public Cart $cart,
public CartItem $item,
) {}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired when a cart item's quantity, dates, or pricing fields change.
* Distinct from {@see CartItemAdded} so listeners can treat "added once"
* and "quantity ticked up" as separate signals (e.g. for funnel metrics).
*/
class CartItemUpdated
{
use Dispatchable, SerializesModels;
public function __construct(
public Cart $cart,
public CartItem $item,
) {}
}

View File

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired when a host app has just created a loan i.e. a ProductPurchase
* representing a checked-out item that the borrower will return later.
*
* The package doesn't dispatch this itself because the same model also
* represents cart-stage rows, plain e-commerce purchases, and bookings;
* only the host app knows when a particular create() means "loan started".
*
* Host apps should call:
*
* event(new LoanCreated($purchase));
*
* right after persisting the purchase. Bind a listener if you need to send
* welcome / receipt emails, write to an audit log, etc.
*/
class LoanCreated
{
use Dispatchable, SerializesModels;
public function __construct(public ProductPurchase $loan) {}
}

View File

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired by {@see \Blax\Shop\Traits\HasLoanLifecycle::extend()} after the
* due date is pushed forward and the extensions_used counter ticks.
*
* Listeners receive the loan plus the number of weeks that were added on
* this particular extension call useful for "your loan has been extended
* by N weeks" notifications without recomputing from the meta column.
*/
class LoanExtended
{
use Dispatchable, SerializesModels;
public function __construct(
public ProductPurchase $loan,
public int $addedWeeks,
) {}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired by {@see \Blax\Shop\Traits\HasLoanLifecycle::markReturned()} after
* the loan's meta.returned_at is stamped and status flipped to completed.
*
* Listeners can use this to:
* - restock the item ($loan->purchasable->increaseStock(...))
* - finalise billing ($loan->accruedCost() is now stable)
* - send "thanks for returning" notifications
* - record audit-log entries
*/
class LoanReturned
{
use Dispatchable, SerializesModels;
public function __construct(public ProductPurchase $loan) {}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when an order is cancelled by the shopper, by support, or
* by a payment-failure path that decides the order can't proceed. Stock
* claims tied to the order should be released by listeners (the package
* does not do this automatically).
*/
class OrderCancelled
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched automatically when an {@see Order} row is created (via the
* model's `$dispatchesEvents` map). Distinct from {@see CartConverted},
* which also carries the originating cart listen to whichever signal
* matches your domain language.
*/
class OrderCreated
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when an order is marked fulfilled (shipped, delivered, picked
* up, or otherwise handed off the package is fulfilment-channel
* agnostic). Hosts that distinguish "shipped" from "delivered" can listen
* here for the final hand-off and define their own intermediate events.
*/
class OrderFulfilled
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when an order transitions to a paid state (in-app payment
* captured, Stripe webhook reconciled, or manual mark-as-paid action).
* Use this to fire off fulfilment workflows or payout reconciliation.
*/
class OrderPaid
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order) {}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when an order is refunded partially or fully. $amount
* carries the refunded amount (in the order's currency) so listeners can
* keep running totals without re-querying refund history.
*/
class OrderRefunded
{
use Dispatchable, SerializesModels;
public function __construct(
public Order $order,
public float $amount,
public bool $partial,
) {}
}

View File

@ -1,21 +1,11 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched automatically by {@see Product} via the model's
* `$dispatchesEvents` map after a new product row is inserted.
*
* Listeners typically use this for downstream sync (push to Stripe, build a
* search index entry, warm a cache). The model is passed already persisted
* listeners can read `$event->product->id`.
*/
class ProductCreated
{
use Dispatchable, SerializesModels;

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched automatically by {@see Product} when a row is deleted (or
* soft-deleted, if the host enables it). Useful for search-index cleanup,
* Stripe archival, or cache invalidation.
*/
class ProductDeleted
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired when a product's status transitions to PUBLISHED (or it is created
* directly in the published state). Listeners typically push to the public
* sales surface, kick off launch notifications, or warm caches.
*/
class ProductPublished
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired when a product moves away from PUBLISHED (to DRAFT, ARCHIVED,
* etc.). Listeners commonly use this to retract the product from sales
* surfaces or freeze ongoing operations referencing it.
*/
class ProductUnpublished
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -1,22 +1,11 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched automatically by {@see Product} via the model's
* `$dispatchesEvents` map whenever an existing product row is saved
* (after the `updated` model event fires).
*
* Pairs with {@see ProductCreated} for any sink that needs to react to
* the full product write surface search reindex, cache invalidation,
* Stripe price sync, etc.
*/
class ProductUpdated
{
use Dispatchable, SerializesModels;

View File

@ -1,33 +0,0 @@
<?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

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Generic counterpart to {@see LoanCreated} / {@see BookingConfirmed}
* fires for any newly created ProductPurchase row regardless of domain
* shape. Listen here when you don't care which kind of purchase it is.
*
* Hosts can rely on the model's $dispatchesEvents map, or call
* `event(new PurchaseCreated($purchase))` directly when assembling rows
* outside the normal save() path.
*/
class PurchaseCreated
{
use Dispatchable, SerializesModels;
public function __construct(public ProductPurchase $purchase) {}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPurchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a single purchase (line-item, loan, or booking) is
* refunded separate from {@see OrderRefunded}, which represents the
* order-level event. Both can fire from the same operator action; listen
* to whichever level matches your reporting needs.
*/
class PurchaseRefunded
{
use Dispatchable, SerializesModels;
public function __construct(
public ProductPurchase $purchase,
public float $amount,
) {}
}

View File

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched once when available stock crosses below the product's
* `low_stock_threshold` (and was above it immediately before). Fires from
* stock-change paths in {@see \Blax\Shop\Traits\HasStocks}; the post-change
* available count plus the threshold are carried in the payload so a
* listener can build the alert message without re-querying.
*/
class StockBecameLow
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public int $availableAfter,
public int $threshold,
) {}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched from the {@see \Blax\Shop\Console\Commands\ReleaseExpiredStocks}
* sweeper when a pending claim's `expires_at` has passed and the package
* automatically returns its quantity to available stock. Pair with
* {@see StockReleased} if a listener needs to handle either path uniformly.
*/
class StockClaimExpired
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
) {}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched from {@see \Blax\Shop\Traits\HasStocks::claimStock()} after a
* reservation (PENDING/CLAIMED row) is created. The associated $reference
* (cart, booking, anything polymorphic) is reachable via the $entry's
* `reference_type` / `reference_id` columns.
*/
class StockClaimed
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
) {}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched from {@see \Blax\Shop\Traits\HasStocks::decreaseStock()} after
* a negative stock entry is written. Use this to track depletion sources,
* trigger reorder workflows, or fan out availability-change notifications.
*/
class StockDecreased
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
public int $availableAfter,
) {}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched the moment available stock drops to zero (and was positive
* immediately before). The product can no longer be sold/loaned/booked at
* its current quantity until restocked. Listeners typically hide the
* product from sales surfaces or send "now sold out" notifications.
*/
class StockDepleted
{
use Dispatchable, SerializesModels;
public function __construct(public Product $product) {}
}

View File

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Host-dispatched event marking "stock is back at full capacity" useful
* for inventory health dashboards or "no copies checked out" signals.
*
* Not fired automatically by the package: the canonical notion of "max"
* varies by domain (library physical copies, venue capacity, shelf SKU
* count) and the package's stock ledger is grow-only, so any auto-rule
* would overlap with {@see StockIncreased}. Hosts dispatch this themselves
* when their domain-specific ceiling is met.
*/
class StockFullyAvailable
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public int $availableAfter,
) {}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched from {@see \Blax\Shop\Traits\HasStocks::increaseStock()} after
* a positive stock entry is written. Listeners commonly use this to log
* inventory deliveries, push to an external WMS, or recompute aggregates.
*/
class StockIncreased
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
public int $availableAfter,
) {}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a previously-pending claim is explicitly released back to
* the available pool (typically via {@see ProductStock::release()} or a
* cart-abandonment path). Distinct from {@see StockClaimExpired}, which
* fires from the scheduled sweeper.
*/
class StockReleased
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public ProductStock $entry,
) {}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a product transitions from "out of stock" back to having
* at least one available unit. Counterpart to {@see StockDepleted}. Useful
* for waitlist fan-outs ("the book you wanted is back") or reactivating the
* product on sales surfaces.
*/
class StockReplenished
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public int $availableAfter,
) {}
}

View File

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired on Stripe payment failure (card declined, authentication failed,
* provider error). Listeners typically retry, notify the shopper, or roll
* back any optimistic state the order had assumed.
*/
class StripePaymentFailed
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public ?Order $order,
public array $payload,
public ?string $reason = null,
) {}
}

View File

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Fired when Stripe confirms a successful charge both the matched Order
* (if the package can resolve it from the payment_intent metadata) and the
* raw Stripe payload are carried so listeners can act on either layer.
*
* If the Order resolution failed (orphan payment), $order is null.
*/
class StripePaymentSucceeded
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public ?Order $order,
public array $payload,
) {}
}

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\ProductPrice;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched after a ProductPrice is pushed to Stripe and the resulting
* Stripe price ID is persisted. Distinct from {@see StripeProductSynced}
* so listeners can react specifically when pricing changes propagate.
*/
class StripePriceSynced
{
use Dispatchable, SerializesModels;
public function __construct(
public ProductPrice $price,
public string $stripePriceId,
) {}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Product;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched after a Product is pushed to Stripe and the resulting
* stripe_product_id has been persisted on the model. Useful for confirming
* the round-trip succeeded or for downstream replication to other catalog
* systems that derive from Stripe IDs.
*/
class StripeProductSynced
{
use Dispatchable, SerializesModels;
public function __construct(
public Product $product,
public string $stripeProductId,
) {}
}

View File

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when Stripe reports a refund has been processed separate
* from {@see OrderRefunded} (which is the package's domain event) so
* listeners can distinguish refund decisions made internally from refunds
* confirmed by the gateway.
*/
class StripeRefundProcessed
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public ?Order $order,
public float $amount,
public array $payload,
) {}
}

View File

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Catch-all event fired for every incoming Stripe webhook the package
* processes. Listen here when you need a single hook for audit/logging or
* to route to custom handlers based on `$type`. The more specific events
* ({@see StripePaymentSucceeded}, {@see StripePaymentFailed}, etc.) carry
* the same payload but only fire for their respective Stripe types.
*
* `$payload` is the decoded JSON body as Stripe sent it; do not mutate it.
*/
class StripeWebhookReceived
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public string $type,
public array $payload,
) {}
}

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Cashier\Subscription;
/**
* Dispatched when a subscription is canceled. Access typically lapses at period end via Cashier's grace handling rather than being revoked immediately.
*
* Carries the Cashier subscription (the package's {@see \Blax\Shop\Models\Subscription}
* is a Cashier subscription, so this works for host subclasses too). Listen
* here to drive fulfillment without coupling to billing internals.
*/
class SubscriptionCanceled
{
use Dispatchable, SerializesModels;
public function __construct(public Subscription $subscription) {}
}

Some files were not shown because too many files have changed in this diff Show More