I loanable product type, tiered pricing, lifecycle events, host helpers
This commit is contained in:
parent
80bc7293b1
commit
fe41475c84
29
README.md
29
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
[](https://github.com/blax-software)
|
[](https://github.com/blax-software)
|
||||||
|
|
||||||
# Laravel Shop Package
|
# Laravel Shop
|
||||||
|
|
||||||
[](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml)
|
[](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml)
|
||||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
[](https://packagist.org/packages/blax-software/laravel-shop)
|
||||||
|
|
@ -29,20 +29,25 @@ A comprehensive headless e-commerce package for Laravel with stock management, S
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer require blax-software/laravel-shop
|
composer require blax-software/laravel-shop
|
||||||
```
|
|
||||||
|
|
||||||
Publish the configuration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan vendor:publish --provider="Blax\Shop\ShopServiceProvider"
|
|
||||||
```
|
|
||||||
|
|
||||||
Run migrations:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan migrate
|
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
|
## Configuration
|
||||||
|
|
||||||
The main configuration file is located at `config/shop.php`. Here you can configure:
|
The main configuration file is located at `config/shop.php`. Here you can configure:
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2|^8.3",
|
"php": "^8.2|^8.3",
|
||||||
"illuminate/support": "^9.0|^10.0|^11.0|^12.0",
|
"illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
"illuminate/database": "^9.0|^10.0|^11.0|^12.0",
|
"illuminate/database": "^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||||
"blax-software/laravel-workkit": "dev-master|*",
|
"blax-software/laravel-workkit": "dev-master|*",
|
||||||
"laravel/cashier": "^14.0|^15.0"
|
"laravel/cashier": "^14.0|^15.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
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)
|
// Table names (customizable for multi-tenancy)
|
||||||
'tables' => [
|
'tables' => [
|
||||||
'cart_items' => 'cart_items',
|
'cart_items' => 'cart_items',
|
||||||
|
|
@ -17,6 +35,7 @@ return [
|
||||||
'product_purchases' => 'product_purchases',
|
'product_purchases' => 'product_purchases',
|
||||||
'product_actions' => 'product_actions',
|
'product_actions' => 'product_actions',
|
||||||
'product_stocks' => 'product_stocks',
|
'product_stocks' => 'product_stocks',
|
||||||
|
'product_price_tiers' => 'product_price_tiers',
|
||||||
'products' => 'products',
|
'products' => 'products',
|
||||||
'cart_discounts' => 'cart_discounts',
|
'cart_discounts' => 'cart_discounts',
|
||||||
],
|
],
|
||||||
|
|
@ -27,6 +46,7 @@ return [
|
||||||
'product_price' => \Blax\Shop\Models\ProductPrice::class,
|
'product_price' => \Blax\Shop\Models\ProductPrice::class,
|
||||||
'product_category' => \Blax\Shop\Models\ProductCategory::class,
|
'product_category' => \Blax\Shop\Models\ProductCategory::class,
|
||||||
'product_stock' => \Blax\Shop\Models\ProductStock::class,
|
'product_stock' => \Blax\Shop\Models\ProductStock::class,
|
||||||
|
'product_price_tier' => \Blax\Shop\Models\ProductPriceTier::class,
|
||||||
'product_attribute' => \Blax\Shop\Models\ProductAttribute::class,
|
'product_attribute' => \Blax\Shop\Models\ProductAttribute::class,
|
||||||
'product_purchase' => \Blax\Shop\Models\ProductPurchase::class,
|
'product_purchase' => \Blax\Shop\Models\ProductPurchase::class,
|
||||||
'cart' => \Blax\Shop\Models\Cart::class,
|
'cart' => \Blax\Shop\Models\Cart::class,
|
||||||
|
|
@ -135,4 +155,23 @@ return [
|
||||||
'response_key' => 'data',
|
'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),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
# 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
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# 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).
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
# 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)
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
# 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)
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
# 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)
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
# 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
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
# 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
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
# 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 |
|
||||||
|
|---|---|---|
|
||||||
|
| 0–14 | 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)
|
||||||
|
|
@ -3,8 +3,18 @@
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
### Product Types
|
### Product Types
|
||||||
- [Booking Products](./ProductTypes/01-booking-products.md) - Time-based reservations and rentals
|
- [**Overview & matrix**](./ProductTypes/00-overview.md) — every product type at a glance plus which prices apply
|
||||||
- [Pool Products](./ProductTypes/02-pool-products.md) - Managing groups of booking items
|
- [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`
|
||||||
|
|
||||||
### Core Features
|
### Core Features
|
||||||
- [Products Overview](./01-products.md) - Basic product management
|
- [Products Overview](./01-products.md) - Basic product management
|
||||||
|
|
@ -148,13 +158,14 @@ $upsell = $basicPlan->upsellProducts->first();
|
||||||
|
|
||||||
```
|
```
|
||||||
ProductType Enum:
|
ProductType Enum:
|
||||||
├── SIMPLE → Standard products
|
├── SIMPLE → Stand-alone single-SKU products (default)
|
||||||
├── VARIABLE → Products with variations
|
├── VARIABLE → Parents of variants
|
||||||
├── GROUPED → Product groups
|
├── VARIATION → A specific variant of a Variable parent
|
||||||
├── EXTERNAL → External/affiliate products
|
├── GROUPED → Bundle / multi-pack of independent children
|
||||||
├── BOOKING → Time-based reservations ⭐
|
├── EXTERNAL → External / affiliate listing (no checkout)
|
||||||
├── VARIATION → Variant of a variable product
|
├── BOOKING → Time-windowed reservations ⭐
|
||||||
└── POOL → Container for booking items ⭐
|
├── POOL → Interchangeable group of booking items ⭐
|
||||||
|
└── LOANABLE → Borrow → extend → return (library / rental) ⭐
|
||||||
```
|
```
|
||||||
|
|
||||||
### Relation Types
|
### Relation Types
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,6 @@ class ShopReinstallCommand extends Command
|
||||||
$this->info('Running shop migrations...');
|
$this->info('Running shop migrations...');
|
||||||
|
|
||||||
$this->call('migrate', [
|
$this->call('migrate', [
|
||||||
'--path' => 'database/migrations/create_blax_shop_tables.php.stub',
|
|
||||||
'--force' => true,
|
'--force' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ enum ProductType: string
|
||||||
case BOOKING = 'booking';
|
case BOOKING = 'booking';
|
||||||
case VARIATION = 'variation';
|
case VARIATION = 'variation';
|
||||||
case POOL = 'pool';
|
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';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
|
|
@ -22,6 +28,7 @@ enum ProductType: string
|
||||||
self::BOOKING => 'Booking',
|
self::BOOKING => 'Booking',
|
||||||
self::VARIATION => 'Variation',
|
self::VARIATION => 'Variation',
|
||||||
self::POOL => 'Pool',
|
self::POOL => 'Pool',
|
||||||
|
self::LOANABLE => 'Loanable',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Http\Controllers\Concerns;
|
||||||
|
|
||||||
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||||
|
use Blax\Shop\Http\Requests\StoreLoanRequest;
|
||||||
|
use Blax\Shop\Http\Resources\LoanResource;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Blax\Workkit\Services\MiscService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop this trait on a host controller to expose the full loan lifecycle —
|
||||||
|
* list / create / show / extend / return — without re-implementing any of
|
||||||
|
* the standard machinery (atomic stock decrement, ownership guard, event
|
||||||
|
* dispatch, status filtering, pagination, resource envelopes).
|
||||||
|
*
|
||||||
|
* Required override:
|
||||||
|
* - {@see loanableModel()} — return the host model class name (e.g.
|
||||||
|
* Book::class). Must implement Cartable +
|
||||||
|
* Purchasable; usually a {@see \Blax\Shop\Models\Product}
|
||||||
|
* subclass using {@see \Blax\Shop\Traits\IsLoanableProduct}.
|
||||||
|
*
|
||||||
|
* Optional overrides:
|
||||||
|
* - {@see loanResource()} — the JsonResource subclass to serialise
|
||||||
|
* ProductPurchase rows. Defaults to
|
||||||
|
* {@see LoanResource}; override to point at
|
||||||
|
* a host subclass that customises the
|
||||||
|
* purchasable resource (e.g. BookResource).
|
||||||
|
* - {@see storeRequest()} — FormRequest class used by store(). Defaults
|
||||||
|
* to {@see StoreLoanRequest} which validates
|
||||||
|
* a `loanable_id` field. Override if you
|
||||||
|
* want the body key to be `book_id` etc.
|
||||||
|
*
|
||||||
|
* Wire endpoints in your routes file (per-method) or use the
|
||||||
|
* `Route::shopLoans()` macro to register all five at once.
|
||||||
|
*/
|
||||||
|
trait HandlesLoans
|
||||||
|
{
|
||||||
|
/** Required — name of the Cartable+Purchasable model being loaned. */
|
||||||
|
abstract protected function loanableModel(): string;
|
||||||
|
|
||||||
|
/** Override to customise the response shape (purchasable subresource). */
|
||||||
|
protected function loanResource(): string
|
||||||
|
{
|
||||||
|
return LoanResource::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Override to swap the FormRequest used by store(). */
|
||||||
|
protected function storeRequest(): string
|
||||||
|
{
|
||||||
|
return StoreLoanRequest::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$query = ProductPurchase::query()
|
||||||
|
->where('purchaser_type', $user::class)
|
||||||
|
->where('purchaser_id', $user->getKey())
|
||||||
|
->where('purchasable_type', $this->loanableModel())
|
||||||
|
->with('purchasable')
|
||||||
|
->orderByDesc('from');
|
||||||
|
|
||||||
|
match ($request->query('status')) {
|
||||||
|
'active' => $query->activeLoans(),
|
||||||
|
'returned' => $query->returned(),
|
||||||
|
'overdue' => $query->overdue(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$perPage = min(max((int) $request->query('per_page', 25), 1), 100);
|
||||||
|
|
||||||
|
return response()->json(MiscService::apiPaginated(
|
||||||
|
$query->paginate($perPage),
|
||||||
|
$this->loanResource(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
// Resolve the request class lazily so subclass overrides win.
|
||||||
|
/** @var StoreLoanRequest $validated */
|
||||||
|
$validated = app($this->storeRequest());
|
||||||
|
$validated->setContainer(app())->setRedirector(app('redirect'));
|
||||||
|
$validated->initialize(
|
||||||
|
$request->query(),
|
||||||
|
$request->request->all(),
|
||||||
|
$request->attributes->all(),
|
||||||
|
$request->cookies->all(),
|
||||||
|
$request->files->all(),
|
||||||
|
$request->server->all(),
|
||||||
|
$request->getContent(),
|
||||||
|
);
|
||||||
|
$validated->setUserResolver($request->getUserResolver());
|
||||||
|
$validated->validateResolved();
|
||||||
|
|
||||||
|
$model = $this->loanableModel();
|
||||||
|
/** @var \Blax\Shop\Models\Product $item */
|
||||||
|
$item = $model::query()->findOrFail($validated->loanableId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$purchase = $item->checkOutTo($request->user());
|
||||||
|
} catch (NotEnoughStockException) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$validated->validationKey() => ['No copies of this item are currently available.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
MiscService::apiItem($purchase->load('purchasable'), $this->loanResource()),
|
||||||
|
Response::HTTP_CREATED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, ProductPurchase $purchase): JsonResponse
|
||||||
|
{
|
||||||
|
$this->ensureLoanOwner($request, $purchase);
|
||||||
|
|
||||||
|
return response()->json(MiscService::apiItem(
|
||||||
|
$purchase->load('purchasable'),
|
||||||
|
$this->loanResource(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extend(Request $request, ProductPurchase $purchase): JsonResponse
|
||||||
|
{
|
||||||
|
$this->ensureLoanOwner($request, $purchase);
|
||||||
|
|
||||||
|
if ($purchase->isReturned()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'loan' => ['This loan has already been returned.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $purchase->canExtend()) {
|
||||||
|
$message = $purchase->isOverdue()
|
||||||
|
? 'Overdue loans cannot be extended — please return the item first.'
|
||||||
|
: 'This loan has reached the maximum number of extensions.';
|
||||||
|
|
||||||
|
throw ValidationException::withMessages(['loan' => [$message]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->extend();
|
||||||
|
|
||||||
|
return response()->json(MiscService::apiItem(
|
||||||
|
$purchase->load('purchasable'),
|
||||||
|
$this->loanResource(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function returnLoan(Request $request, ProductPurchase $purchase): JsonResponse
|
||||||
|
{
|
||||||
|
$this->ensureLoanOwner($request, $purchase);
|
||||||
|
|
||||||
|
if ($purchase->isReturned()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'loan' => ['This loan has already been returned.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->markReturned();
|
||||||
|
|
||||||
|
$purchase->load('purchasable');
|
||||||
|
if ($purchase->purchasable !== null && method_exists($purchase->purchasable, 'increaseStock')) {
|
||||||
|
$purchase->purchasable->increaseStock(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(MiscService::apiItem($purchase, $this->loanResource()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 403 unless the authenticated user is the loan's purchaser.
|
||||||
|
*/
|
||||||
|
protected function ensureLoanOwner(Request $request, ProductPurchase $purchase): void
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$isOwner = $purchase->purchaser_type === $user::class
|
||||||
|
&& (string) $purchase->purchaser_id === (string) $user->getKey();
|
||||||
|
|
||||||
|
if (! $isOwner) {
|
||||||
|
abort(Response::HTTP_FORBIDDEN, 'This loan does not belong to you.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic store-loan validation. Accepts a `loanable_id` referencing a row
|
||||||
|
* in the products table. Host apps that want a different request key
|
||||||
|
* (e.g. `book_id`) can subclass and override rules() / prepareForValidation().
|
||||||
|
*/
|
||||||
|
class StoreLoanRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'loanable_id' => [
|
||||||
|
'required',
|
||||||
|
'uuid',
|
||||||
|
'exists:'.config('shop.tables.products', 'products').',id',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The validated id of the model to be checked out. Subclasses that
|
||||||
|
* rename the field can override this to keep HandlesLoans agnostic.
|
||||||
|
*/
|
||||||
|
public function loanableId(): string
|
||||||
|
{
|
||||||
|
return $this->validated($this->validationKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body / validation key used in `rules()`. HandlesLoans uses this
|
||||||
|
* to attach the "no copies available" error to the correct field, so
|
||||||
|
* subclasses that rename `loanable_id` should override here too.
|
||||||
|
*/
|
||||||
|
public function validationKey(): string
|
||||||
|
{
|
||||||
|
return 'loanable_id';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Http\Resources;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loan-flavoured purchase resource — same shape as {@see PurchaseResource}
|
||||||
|
* but named explicitly for loan/rental contexts. Host apps generally only
|
||||||
|
* need to override `purchasableResource()` to point at their domain resource:
|
||||||
|
*
|
||||||
|
* class LoanResource extends \Blax\Shop\Http\Resources\LoanResource
|
||||||
|
* {
|
||||||
|
* protected function purchasableResource(): ?string
|
||||||
|
* {
|
||||||
|
* return BookResource::class;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Or skip the subclass entirely and use this class directly when the raw
|
||||||
|
* `item` shape on the wire is fine.
|
||||||
|
*/
|
||||||
|
class LoanResource extends PurchaseResource
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Http\Resources;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-vocabulary translation for ProductPurchase rows.
|
||||||
|
*
|
||||||
|
* The underlying model carries e-commerce naming (`from`, `until`,
|
||||||
|
* `amount_paid`, polymorphic `purchasable_*` / `purchaser_*`). API consumers
|
||||||
|
* generally want a flatter, loan/booking-flavoured payload. Extend this in
|
||||||
|
* app code if you need extra fields:
|
||||||
|
*
|
||||||
|
* class LoanResource extends PurchaseResource
|
||||||
|
* {
|
||||||
|
* protected function purchasableResource(): ?string
|
||||||
|
* {
|
||||||
|
* return BookResource::class;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* `status` is the **domain status** (active|overdue|returned) — not the raw
|
||||||
|
* PurchaseStatus enum, which is exposed separately as `lifecycle_status`.
|
||||||
|
*
|
||||||
|
* @mixin ProductPurchase
|
||||||
|
*/
|
||||||
|
class PurchaseResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$meta = (array) ($this->meta ?? []);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'item' => $this->resolveItem(),
|
||||||
|
'loaned_at' => optional($this->from)->toIso8601String(),
|
||||||
|
'due_at' => optional($this->until)->toIso8601String(),
|
||||||
|
'returned_at' => $meta['returned_at'] ?? null,
|
||||||
|
'status' => $this->getDomainStatus(),
|
||||||
|
'lifecycle_status' => $this->status?->value ?? (string) $this->status,
|
||||||
|
'extensions_used' => (int) ($meta['extensions_used'] ?? 0),
|
||||||
|
'quantity' => $this->quantity,
|
||||||
|
// Accrued cost in cents per the configured tier ladder. Only set
|
||||||
|
// when the purchase has a `from` timestamp (i.e. is a loan/booking);
|
||||||
|
// a plain e-commerce purchase reports null.
|
||||||
|
'accrued_cost' => $this->from !== null ? $this->accruedCost() : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override to point at the resource that should serialise the purchasable.
|
||||||
|
* Returning null falls through to the raw purchasable model (or null when
|
||||||
|
* not loaded).
|
||||||
|
*/
|
||||||
|
protected function purchasableResource(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveItem(): mixed
|
||||||
|
{
|
||||||
|
$resource = $this->purchasableResource();
|
||||||
|
$purchasable = $this->whenLoaded('purchasable', fn () => $this->purchasable);
|
||||||
|
|
||||||
|
if ($purchasable === null || $resource === null) {
|
||||||
|
return $purchasable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resource::make($purchasable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -987,6 +987,12 @@ class Cart extends Model
|
||||||
throw new CartableInterfaceException();
|
throw new CartableInterfaceException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defaults for cartables that aren't Product / ProductPrice (e.g. an app
|
||||||
|
// model using IsSimplePurchasable). Pool + booking are Product-specific
|
||||||
|
// features; non-Product cartables flow through the simple path.
|
||||||
|
$is_pool = false;
|
||||||
|
$is_booking = false;
|
||||||
|
|
||||||
if ($cartable instanceof Product) {
|
if ($cartable instanceof Product) {
|
||||||
$is_pool = $cartable->isPool();
|
$is_pool = $cartable->isPool();
|
||||||
$is_booking = $cartable->isBooking();
|
$is_booking = $cartable->isBooking();
|
||||||
|
|
|
||||||
|
|
@ -153,12 +153,20 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
|
|
||||||
public function attributes(): HasMany
|
public function attributes(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute'));
|
// Explicit FK so the relation still targets `product_id` when a host
|
||||||
|
// app subclasses Product (e.g. `Book extends Product`).
|
||||||
|
return $this->hasMany(
|
||||||
|
config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute'),
|
||||||
|
'product_id'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function actions(): HasMany
|
public function actions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(config('shop.models.product_action', ProductAction::class));
|
return $this->hasMany(
|
||||||
|
config('shop.models.product_action', ProductAction::class),
|
||||||
|
'product_id'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function purchases(): MorphMany
|
public function purchases(): MorphMany
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use Blax\Workkit\Traits\HasMetaTranslation;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class ProductPrice extends Model implements Cartable
|
class ProductPrice extends Model implements Cartable
|
||||||
{
|
{
|
||||||
|
|
@ -65,4 +66,73 @@ class ProductPrice extends Model implements Cartable
|
||||||
|
|
||||||
return $this->unit_amount;
|
return $this->unit_amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier ladder used when {@see $billing_scheme} is `tiered`. Each tier
|
||||||
|
* applies up to its `up_to` mark; the last tier (up_to = null) extends
|
||||||
|
* to infinity. See {@see calculateForUsage()} for the walker.
|
||||||
|
*
|
||||||
|
* @return HasMany<ProductPriceTier, $this>
|
||||||
|
*/
|
||||||
|
public function tiers(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(
|
||||||
|
config('shop.models.product_price_tier', ProductPriceTier::class),
|
||||||
|
'price_id'
|
||||||
|
)->orderBy('sort_order')->orderByRaw('up_to IS NULL, up_to ASC');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the total charge in cents for `$usage` units (e.g. days of
|
||||||
|
* loan, GB consumed, API calls). Walks the {@see $tiers} ladder Stripe-
|
||||||
|
* style: each tier covers usage from the previous tier's `up_to` up to
|
||||||
|
* its own `up_to` (or infinity for the last tier), at `unit_amount`
|
||||||
|
* cents per unit, plus an optional `flat_amount` if the tier is entered.
|
||||||
|
*
|
||||||
|
* Falls back to `unit_amount * usage` when billing_scheme is not tiered
|
||||||
|
* or no tiers are configured — so a price with billing_scheme=per_unit
|
||||||
|
* still computes a sensible total here.
|
||||||
|
*/
|
||||||
|
public function calculateForUsage(float $usage): int
|
||||||
|
{
|
||||||
|
if ($usage <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isTiered = $this->billing_scheme === BillingScheme::TIERED;
|
||||||
|
$tiers = $isTiered ? $this->tiers : null;
|
||||||
|
|
||||||
|
if (! $isTiered || $tiers === null || $tiers->isEmpty()) {
|
||||||
|
return (int) round($this->unit_amount * $usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cost = 0.0;
|
||||||
|
$consumed = 0.0;
|
||||||
|
|
||||||
|
foreach ($tiers as $tier) {
|
||||||
|
$upTo = $tier->up_to;
|
||||||
|
$tierCap = $upTo === null ? INF : (float) $upTo;
|
||||||
|
|
||||||
|
if ($consumed >= $tierCap) {
|
||||||
|
// Past this tier's ceiling (shouldn't happen with sorted
|
||||||
|
// tiers, but guards against bad data).
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$unitsInTier = min($usage, $tierCap) - $consumed;
|
||||||
|
if ($unitsInTier <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cost += $unitsInTier * (float) $tier->unit_amount;
|
||||||
|
$cost += (float) ($tier->flat_amount ?? 0);
|
||||||
|
$consumed += $unitsInTier;
|
||||||
|
|
||||||
|
if ($consumed >= $usage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round($cost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
|
use Blax\Shop\Database\Factories\ProductPriceTierFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One step in a {@see ProductPrice}'s tier ladder. Used when the parent
|
||||||
|
* price's `billing_scheme` is `tiered`.
|
||||||
|
*
|
||||||
|
* up_to — usage units this tier covers (null = unbounded). For a
|
||||||
|
* loanable product, "units" are days of borrowing. The
|
||||||
|
* previous tier's `up_to` is the implicit lower bound.
|
||||||
|
* unit_amount — cents charged per unit consumed within this tier.
|
||||||
|
* flat_amount — optional flat fee added once when the tier is entered.
|
||||||
|
* sort_order — deterministic walk order.
|
||||||
|
*/
|
||||||
|
class ProductPriceTier extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasUuids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'price_id',
|
||||||
|
'up_to',
|
||||||
|
'unit_amount',
|
||||||
|
'flat_amount',
|
||||||
|
'sort_order',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'up_to' => 'integer',
|
||||||
|
'unit_amount' => 'integer',
|
||||||
|
'flat_amount' => 'integer',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
'meta' => 'object',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(array $attributes = [])
|
||||||
|
{
|
||||||
|
parent::__construct($attributes);
|
||||||
|
$this->setTable(config('shop.tables.product_price_tiers', 'product_price_tiers'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function price(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(
|
||||||
|
config('shop.models.product_price', ProductPrice::class),
|
||||||
|
'price_id'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function newFactory(): ProductPriceTierFactory
|
||||||
|
{
|
||||||
|
return ProductPriceTierFactory::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,14 @@
|
||||||
namespace Blax\Shop\Models;
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
use Blax\Shop\Enums\PurchaseStatus;
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Traits\HasBookingLifecycle;
|
||||||
|
use Blax\Shop\Traits\HasLoanLifecycle;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ProductPurchase extends Model
|
class ProductPurchase extends Model
|
||||||
{
|
{
|
||||||
use HasUuids;
|
use HasBookingLifecycle, HasLoanLifecycle, HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'status',
|
'status',
|
||||||
|
|
@ -58,6 +60,17 @@ class ProductPurchase extends Model
|
||||||
return $this->belongsTo(config('shop.models.product', Product::class));
|
return $this->belongsTo(config('shop.models.product', Product::class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The price this purchase bills against (see HasLoanLifecycle::calculateCost).
|
||||||
|
*/
|
||||||
|
public function price()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(
|
||||||
|
config('shop.models.product_price', ProductPrice::class),
|
||||||
|
'price_id'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function user()
|
public function user()
|
||||||
{
|
{
|
||||||
if ($this->purchasable_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) {
|
if ($this->purchasable_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) {
|
||||||
|
|
@ -126,39 +139,13 @@ class ProductPurchase extends Model
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Check if this is a booking purchase
|
* Lifecycle methods live in product-type traits:
|
||||||
|
* - HasBookingLifecycle → isBooking, isBookingEnded, scopeBookings,
|
||||||
|
* scopeEndedBookings (booking products)
|
||||||
|
* - HasLoanLifecycle → isReturned, isOverdue, getDomainStatus,
|
||||||
|
* returnedAt, extensionsUsed, canExtend,
|
||||||
|
* extend, markReturned, scopeActiveLoans,
|
||||||
|
* scopeReturned, scopeOverdue (loanable products)
|
||||||
*/
|
*/
|
||||||
public function isBooking(): bool
|
|
||||||
{
|
|
||||||
return !is_null($this->from) && !is_null($this->until);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the booking has ended
|
|
||||||
*/
|
|
||||||
public function isBookingEnded(): bool
|
|
||||||
{
|
|
||||||
if (!$this->isBooking()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return now()->isAfter($this->until);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scope for booking purchases
|
|
||||||
*/
|
|
||||||
public function scopeBookings($query)
|
|
||||||
{
|
|
||||||
return $query->whereNotNull('from')->whereNotNull('until');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scope for ended bookings
|
|
||||||
*/
|
|
||||||
public function scopeEndedBookings($query)
|
|
||||||
{
|
|
||||||
return $query->bookings()->where('until', '<', now());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,23 +28,11 @@ class ShopServiceProvider extends ServiceProvider
|
||||||
|
|
||||||
public function boot()
|
public function boot()
|
||||||
{
|
{
|
||||||
// Publish config
|
$this->offerPublishing();
|
||||||
$this->publishes([
|
|
||||||
__DIR__ . '/../config/shop.php' => config_path('shop.php'),
|
|
||||||
], ['shop-config', 'config']);
|
|
||||||
|
|
||||||
// Publish migrations
|
$this->registerMigrations();
|
||||||
$this->publishes([
|
|
||||||
__DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub' => $this->getMigrationFileName('create_blax_shop_tables.php'),
|
|
||||||
__DIR__ . '/../database/migrations/add_stripe_to_users_table.php.stub' => $this->getMigrationFileName('add_stripe_to_users_table.php'),
|
|
||||||
], ['shop-migrations', 'migrations']);
|
|
||||||
|
|
||||||
// Publish all shop assets
|
$this->registerRouteMacros();
|
||||||
$this->publishes([
|
|
||||||
__DIR__ . '/../config/shop.php' => config_path('shop.php'),
|
|
||||||
__DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub' => $this->getMigrationFileName('create_blax_shop_tables.php'),
|
|
||||||
__DIR__ . '/../database/migrations/add_stripe_to_users_table.php.stub' => $this->getMigrationFileName('add_stripe_to_users_table.php'),
|
|
||||||
], 'shop');
|
|
||||||
|
|
||||||
// Load routes if enabled (API only)
|
// Load routes if enabled (API only)
|
||||||
if (config('shop.routes.enabled', true)) {
|
if (config('shop.routes.enabled', true)) {
|
||||||
|
|
@ -87,17 +75,76 @@ class ShopServiceProvider extends ServiceProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns existing migration file if found, else uses the current timestamp.
|
* Auto-load the package's migrations so fresh installs work without
|
||||||
|
* publishing. Disabled via `shop.run_migrations = false` for projects
|
||||||
|
* that prefer to publish + manage migrations themselves.
|
||||||
*/
|
*/
|
||||||
protected function getMigrationFileName(string $migrationFileName): string
|
protected function registerMigrations(): void
|
||||||
{
|
{
|
||||||
$timestamp = date('Y_m_d_His');
|
if (! config('shop.run_migrations', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$filesystem = $this->app->make(\Illuminate\Filesystem\Filesystem::class);
|
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||||
|
}
|
||||||
|
|
||||||
return \Illuminate\Support\Collection::make([$this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR])
|
/**
|
||||||
->flatMap(fn($path) => $filesystem->glob($path . '*_' . $migrationFileName))
|
* Register Route macros that hosts can use to wire shop endpoints
|
||||||
->push($this->app->databasePath() . "/migrations/{$timestamp}_{$migrationFileName}")
|
* concisely. Currently provides:
|
||||||
->first();
|
*
|
||||||
|
* Route::shopLoans('loans', \App\Http\Controllers\LoanController::class)
|
||||||
|
* → POST {prefix} index of caller's loans
|
||||||
|
* → POST {prefix} check out a new loan
|
||||||
|
* → GET {prefix}/{purchase} show a single loan
|
||||||
|
* → POST {prefix}/{purchase}/extend extend the due date
|
||||||
|
* → POST {prefix}/{purchase}/return return the item
|
||||||
|
*
|
||||||
|
* The controller is expected to use {@see \Blax\Shop\Http\Controllers\Concerns\HandlesLoans}.
|
||||||
|
*/
|
||||||
|
protected function registerRouteMacros(): void
|
||||||
|
{
|
||||||
|
\Illuminate\Support\Facades\Route::macro('shopLoans', function (string $prefix, string $controller): void {
|
||||||
|
\Illuminate\Support\Facades\Route::prefix($prefix)->group(function () use ($controller): void {
|
||||||
|
\Illuminate\Support\Facades\Route::get('/', [$controller, 'index']);
|
||||||
|
\Illuminate\Support\Facades\Route::post('/', [$controller, 'store']);
|
||||||
|
\Illuminate\Support\Facades\Route::get('/{purchase}', [$controller, 'show']);
|
||||||
|
\Illuminate\Support\Facades\Route::post('/{purchase}/extend', [$controller, 'extend']);
|
||||||
|
\Illuminate\Support\Facades\Route::post('/{purchase}/return', [$controller, 'returnLoan']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up publishing of config and migrations for `php artisan vendor:publish`.
|
||||||
|
*
|
||||||
|
* Migrations are published keeping the source filename so that any
|
||||||
|
* migration already run via auto-load is marked as run for the
|
||||||
|
* published copy too — no duplicate execution.
|
||||||
|
*/
|
||||||
|
protected function offerPublishing(): void
|
||||||
|
{
|
||||||
|
if (! $this->app->runningInConsole()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->publishes([
|
||||||
|
__DIR__ . '/../config/shop.php' => $this->app->configPath('shop.php'),
|
||||||
|
], ['shop-config', 'config']);
|
||||||
|
|
||||||
|
$migrationsPath = __DIR__ . '/../database/migrations';
|
||||||
|
$publishMap = [];
|
||||||
|
foreach (glob($migrationsPath . '/*.php') as $sourcePath) {
|
||||||
|
$publishMap[$sourcePath] = $this->app->databasePath('migrations/' . basename($sourcePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->publishes($publishMap, ['shop-migrations', 'migrations']);
|
||||||
|
|
||||||
|
$this->publishes(
|
||||||
|
array_merge(
|
||||||
|
[__DIR__ . '/../config/shop.php' => $this->app->configPath('shop.php')],
|
||||||
|
$publishMap,
|
||||||
|
),
|
||||||
|
'shop',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row.
|
||||||
|
*
|
||||||
|
* "Booking" here means a purchase whose dates (`from` / `until`) define a
|
||||||
|
* time-bounded reservation. The trait is purchase-side — the corresponding
|
||||||
|
* product-side concept is the BOOKING product type plus
|
||||||
|
* {@see ChecksIfBooking}.
|
||||||
|
*/
|
||||||
|
trait HasBookingLifecycle
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Has this purchase been booked across a date range?
|
||||||
|
*/
|
||||||
|
public function isBooking(): bool
|
||||||
|
{
|
||||||
|
return ! is_null($this->from) && ! is_null($this->until);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has the booking window ended? False if not a booking at all.
|
||||||
|
*/
|
||||||
|
public function isBookingEnded(): bool
|
||||||
|
{
|
||||||
|
if (! $this->isBooking()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return now()->isAfter($this->until);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to date-bounded bookings only.
|
||||||
|
*/
|
||||||
|
public function scopeBookings($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('from')->whereNotNull('until');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to bookings whose window is in the past.
|
||||||
|
*/
|
||||||
|
public function scopeEndedBookings($query)
|
||||||
|
{
|
||||||
|
return $query->bookings()->where('until', '<', now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Events\LoanExtended;
|
||||||
|
use Blax\Shop\Events\LoanReturned;
|
||||||
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loan / rental lifecycle for a {@see \Blax\Shop\Models\ProductPurchase} row.
|
||||||
|
*
|
||||||
|
* Loans are modelled directly as ProductPurchase rows: `from` is when the
|
||||||
|
* borrower checked the item out, `until` is the due date, and the `meta`
|
||||||
|
* column carries two domain-specific keys:
|
||||||
|
*
|
||||||
|
* meta.returned_at ISO timestamp; null means the item is still out
|
||||||
|
* meta.extensions_used int; counts how many times extend() was called
|
||||||
|
*
|
||||||
|
* The package's e-commerce status enum stays orthogonal to loan state:
|
||||||
|
* pending → loan is in progress (default for new rows with no payment)
|
||||||
|
* completed → bookkeeping-final (set automatically on markReturned())
|
||||||
|
*
|
||||||
|
* Domain status (returned / overdue / active) is derived; see
|
||||||
|
* {@see getDomainStatus()} and the corresponding scopes.
|
||||||
|
*
|
||||||
|
* The product-side counterpart is {@see IsLoanableProduct}, which exposes a
|
||||||
|
* `loan()` helper to create a purchase row pre-filled for this lifecycle.
|
||||||
|
*/
|
||||||
|
trait HasLoanLifecycle
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* True once the borrower has returned the item.
|
||||||
|
*/
|
||||||
|
public function isReturned(): bool
|
||||||
|
{
|
||||||
|
return $this->returnedAt() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has the due date passed without a return?
|
||||||
|
*/
|
||||||
|
public function isOverdue(): bool
|
||||||
|
{
|
||||||
|
if ($this->isReturned() || $this->until === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->until->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-flavoured status for resource serialisation:
|
||||||
|
* active → loan is in progress, never extended
|
||||||
|
* extended → loan is in progress and has been extended at least once
|
||||||
|
* overdue → past due_at, not returned (regardless of extensions)
|
||||||
|
* returned → borrower has handed it back (regardless of extensions)
|
||||||
|
*
|
||||||
|
* `overdue` and `returned` take precedence over `extended` — once a
|
||||||
|
* loan is past due or handed back, the fact that it was extended is
|
||||||
|
* less informative than its terminal state.
|
||||||
|
*/
|
||||||
|
public function getDomainStatus(): string
|
||||||
|
{
|
||||||
|
if ($this->isReturned()) {
|
||||||
|
return 'returned';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isOverdue()) {
|
||||||
|
return 'overdue';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->extensionsUsed() > 0 ? 'extended' : 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read meta.returned_at safely against either array or object meta cast.
|
||||||
|
*/
|
||||||
|
public function returnedAt(): ?string
|
||||||
|
{
|
||||||
|
$meta = (array) ($this->meta ?? []);
|
||||||
|
|
||||||
|
return $meta['returned_at'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of extensions the borrower has consumed on this loan.
|
||||||
|
*/
|
||||||
|
public function extensionsUsed(): int
|
||||||
|
{
|
||||||
|
$meta = (array) ($this->meta ?? []);
|
||||||
|
|
||||||
|
return (int) ($meta['extensions_used'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can this loan still be extended? Defaults to config('shop.loan.max_extensions').
|
||||||
|
*/
|
||||||
|
public function canExtend(?int $max = null): bool
|
||||||
|
{
|
||||||
|
if ($this->isReturned() || $this->isOverdue()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$max ??= (int) config('shop.loan.max_extensions', 2);
|
||||||
|
|
||||||
|
return $this->extensionsUsed() < $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the due date forward by the given week count (or
|
||||||
|
* shop.loan.extension_weeks if null) and increment extensions_used.
|
||||||
|
*
|
||||||
|
* Callers should check canExtend() first — extend() is permissive and
|
||||||
|
* does not enforce the cap, so it stays composable with custom policies.
|
||||||
|
*/
|
||||||
|
public function extend(?int $weeks = null): self
|
||||||
|
{
|
||||||
|
$weeks ??= (int) config('shop.loan.extension_weeks', 1);
|
||||||
|
|
||||||
|
if ($this->until !== null) {
|
||||||
|
$this->until = $this->until->copy()->addWeeks($weeks);
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = (array) ($this->meta ?? []);
|
||||||
|
$meta['extensions_used'] = (int) ($meta['extensions_used'] ?? 0) + 1;
|
||||||
|
$this->meta = $meta;
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
event(new LoanExtended($this, $weeks));
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the item returned: stamp meta.returned_at with the given moment
|
||||||
|
* (or now()) and flip status to completed so the row reads as final.
|
||||||
|
*/
|
||||||
|
public function markReturned(?\DateTimeInterface $at = null): self
|
||||||
|
{
|
||||||
|
$at ??= now();
|
||||||
|
|
||||||
|
$meta = (array) ($this->meta ?? []);
|
||||||
|
$meta['returned_at'] = Carbon::instance($at)->toIso8601String();
|
||||||
|
$this->meta = $meta;
|
||||||
|
$this->status = PurchaseStatus::COMPLETED;
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
event(new LoanReturned($this));
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: loans currently in the borrower's hands (not returned).
|
||||||
|
*/
|
||||||
|
public function scopeActiveLoans($query)
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->where('status', PurchaseStatus::PENDING->value)
|
||||||
|
->whereNull('meta->returned_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: loans that have been handed back.
|
||||||
|
*/
|
||||||
|
public function scopeReturned($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('meta->returned_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: loans past their due date and not yet returned.
|
||||||
|
*/
|
||||||
|
public function scopeOverdue($query)
|
||||||
|
{
|
||||||
|
return $query->activeLoans()->where('until', '<', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────────
|
||||||
|
* Cost calculation
|
||||||
|
*
|
||||||
|
* A loan accrues cost based on the {@see ProductPrice} attached to the
|
||||||
|
* purchase (`$this->price`). The price model owns the tier ladder, the
|
||||||
|
* billing scheme (per-unit vs tiered), and the currency — so the math
|
||||||
|
* here is simple: count fractional days between `from` and the relevant
|
||||||
|
* end timestamp, then ask the price what that adds up to.
|
||||||
|
*
|
||||||
|
* Day count is fractional (minute precision / 1440), matching
|
||||||
|
* {@see HasBookingPriceCalculation}.
|
||||||
|
*
|
||||||
|
* For returned loans, the end timestamp is `meta.returned_at` so the
|
||||||
|
* cost stays stable post-return.
|
||||||
|
* ────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute accrued loan cost in cents as of $asOf (defaults to now).
|
||||||
|
*
|
||||||
|
* If $price is omitted the purchase's attached price ($this->price_id)
|
||||||
|
* is used. If no price is associated, the cost is 0 — the loan is free.
|
||||||
|
*/
|
||||||
|
public function calculateCost(
|
||||||
|
?\DateTimeInterface $asOf = null,
|
||||||
|
?ProductPrice $price = null
|
||||||
|
): int {
|
||||||
|
if ($this->from === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = Carbon::instance($this->from);
|
||||||
|
$returnedAt = $this->returnedAt();
|
||||||
|
if ($returnedAt !== null) {
|
||||||
|
$end = Carbon::parse($returnedAt);
|
||||||
|
} else {
|
||||||
|
$end = $asOf !== null ? Carbon::instance($asOf) : Carbon::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($end->lessThanOrEqualTo($start)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalDays = max(0.0, $start->diffInMinutes($end) / 1440.0);
|
||||||
|
|
||||||
|
$price ??= $this->resolvePriceForCost();
|
||||||
|
if ($price === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $price->calculateForUsage($totalDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: accrued cost as of now, in cents. Useful inside resources
|
||||||
|
* where no parameters are available.
|
||||||
|
*/
|
||||||
|
public function accruedCost(): int
|
||||||
|
{
|
||||||
|
return $this->calculateCost();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the price to bill this loan against. Order of resolution:
|
||||||
|
* 1. The purchase's `price_id` (explicit choice at checkout)
|
||||||
|
* 2. The purchasable's default price (Product->defaultPrice())
|
||||||
|
* Returns null when neither is set — interpreted as a free loan.
|
||||||
|
*/
|
||||||
|
protected function resolvePriceForCost(): ?ProductPrice
|
||||||
|
{
|
||||||
|
if ($this->price_id !== null) {
|
||||||
|
$relation = $this->relationLoaded('price')
|
||||||
|
? $this->getRelation('price')
|
||||||
|
: $this->price;
|
||||||
|
if ($relation instanceof ProductPrice) {
|
||||||
|
return $relation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchasable = $this->relationLoaded('purchasable')
|
||||||
|
? $this->getRelation('purchasable')
|
||||||
|
: $this->purchasable;
|
||||||
|
|
||||||
|
if ($purchasable !== null && method_exists($purchasable, 'defaultPrice')) {
|
||||||
|
$default = $purchasable->defaultPrice()->first();
|
||||||
|
if ($default instanceof ProductPrice) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,19 +39,29 @@ use Illuminate\Support\Facades\DB;
|
||||||
trait HasStocks
|
trait HasStocks
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Get all available stock entries for this product
|
* Get all available stock entries for this product.
|
||||||
|
*
|
||||||
|
* The foreign key is named explicitly so this trait works on Product
|
||||||
|
* subclasses too (e.g. a library Book extending Product) — Eloquent's
|
||||||
|
* default convention would otherwise infer `{subclass}_id`.
|
||||||
*/
|
*/
|
||||||
public function stocks(): HasMany
|
public function stocks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'));
|
return $this->hasMany(
|
||||||
|
config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'),
|
||||||
|
'product_id'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all stock entries for this product including unavailable ones
|
* Get all stock entries for this product including unavailable ones.
|
||||||
*/
|
*/
|
||||||
public function allStocks(): HasMany
|
public function allStocks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'))
|
return $this->hasMany(
|
||||||
|
config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'),
|
||||||
|
'product_id'
|
||||||
|
)
|
||||||
->withExpired()
|
->withExpired()
|
||||||
->where('status', 'LIKE', '%');
|
->where('status', 'LIKE', '%');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductStatus;
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Events\LoanCreated;
|
||||||
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||||
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop on a {@see \Blax\Shop\Models\Product} subclass to declare it a
|
||||||
|
* loanable item. Provides:
|
||||||
|
*
|
||||||
|
* - Sensible defaults on `creating` (type=LOANABLE, manage_stock=true,
|
||||||
|
* status=PUBLISHED, is_visible=true) so callers can omit the e-commerce
|
||||||
|
* columns and just give the product its domain attributes.
|
||||||
|
*
|
||||||
|
* - A virtual `total_quantity` setter that's translated into a stock
|
||||||
|
* INCREASE entry the moment the row is saved — so a single
|
||||||
|
* `Book::create(['title' => …, 'total_quantity' => 3])` produces a
|
||||||
|
* book with three copies in stock.
|
||||||
|
*
|
||||||
|
* - `checkOutTo($borrower, $weeks, $price)` — atomic decrement + purchase
|
||||||
|
* creation + LoanCreated event dispatch, all in one call. Replaces the
|
||||||
|
* DB::transaction + decreaseStock + purchases()->create + event boilerplate
|
||||||
|
* every host controller would otherwise repeat.
|
||||||
|
*
|
||||||
|
* Pair with {@see HasLoanLifecycle} on ProductPurchase (already mixed in via
|
||||||
|
* the package's default ProductPurchase model) to get the full borrow →
|
||||||
|
* extend → return state machine for free.
|
||||||
|
*
|
||||||
|
* Example host model:
|
||||||
|
*
|
||||||
|
* class Book extends \Blax\Shop\Models\Product
|
||||||
|
* {
|
||||||
|
* use \Blax\Shop\Traits\IsLoanableProduct;
|
||||||
|
*
|
||||||
|
* public function getTitleAttribute(): ?string { return $this->name; }
|
||||||
|
* public function setTitleAttribute(?string $v): void { $this->attributes['name'] = $v; }
|
||||||
|
* public function getIsbnAttribute(): ?string { return $this->sku; }
|
||||||
|
* public function setIsbnAttribute(?string $v): void { $this->attributes['sku'] = $v; }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
trait IsLoanableProduct
|
||||||
|
{
|
||||||
|
/** Captured by setTotalQuantityAttribute; consumed in created(). */
|
||||||
|
protected int $initialLoanableQuantity = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treat title / isbn / total_quantity as fillable virtual attributes by
|
||||||
|
* default. Hosts that don't need them can override getFillable().
|
||||||
|
*/
|
||||||
|
public function getFillable(): array
|
||||||
|
{
|
||||||
|
return array_merge(parent::getFillable(), ['title', 'isbn', 'total_quantity']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotalQuantityAttribute(): int
|
||||||
|
{
|
||||||
|
if (! $this->exists) {
|
||||||
|
return $this->initialLoanableQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $this->getMaxStocksAttribute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTotalQuantityAttribute(int $value): void
|
||||||
|
{
|
||||||
|
$this->initialLoanableQuantity = max(0, $value);
|
||||||
|
$this->attributes['manage_stock'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAvailableQuantityAttribute(): int
|
||||||
|
{
|
||||||
|
return $this->getAvailableStock();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function bootIsLoanableProduct(): void
|
||||||
|
{
|
||||||
|
static::creating(function ($product): void {
|
||||||
|
$product->type ??= ProductType::LOANABLE;
|
||||||
|
$product->status ??= ProductStatus::PUBLISHED;
|
||||||
|
$product->is_visible ??= true;
|
||||||
|
$product->manage_stock = $product->manage_stock ?? true;
|
||||||
|
});
|
||||||
|
|
||||||
|
static::created(function ($product): void {
|
||||||
|
if ($product->initialLoanableQuantity > 0) {
|
||||||
|
$product->increaseStock($product->initialLoanableQuantity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically check out one unit of this product to a borrower.
|
||||||
|
*
|
||||||
|
* Wraps three operations in a single transaction so a failure anywhere
|
||||||
|
* rolls back the lot:
|
||||||
|
* 1. decreaseStock(1) — throws NotEnoughStockException if no copy
|
||||||
|
* is available
|
||||||
|
* 2. ProductPurchase row created (purchasable=this, purchaser=$borrower)
|
||||||
|
* 3. LoanCreated event dispatched
|
||||||
|
*
|
||||||
|
* @param Model $borrower The model recording who's holding the item
|
||||||
|
* @param int|null $weeks Loan duration; defaults to shop.loan.default_duration_weeks
|
||||||
|
* @param ProductPrice|null $price Override price; defaults to product's defaultPrice
|
||||||
|
*
|
||||||
|
* @throws NotEnoughStockException When no copies are available
|
||||||
|
*/
|
||||||
|
public function checkOutTo(
|
||||||
|
Model $borrower,
|
||||||
|
?int $weeks = null,
|
||||||
|
?ProductPrice $price = null,
|
||||||
|
): ProductPurchase {
|
||||||
|
$weeks ??= (int) config('shop.loan.default_duration_weeks', 2);
|
||||||
|
$now = Carbon::now();
|
||||||
|
$price ??= $this->defaultPrice()->first();
|
||||||
|
|
||||||
|
$purchase = DB::transaction(function () use ($borrower, $weeks, $price, $now): ProductPurchase {
|
||||||
|
$this->decreaseStock(1);
|
||||||
|
|
||||||
|
return $this->purchases()->create([
|
||||||
|
'purchaser_id' => $borrower->getKey(),
|
||||||
|
'purchaser_type' => $borrower::class,
|
||||||
|
'price_id' => $price?->id,
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'status' => PurchaseStatus::PENDING,
|
||||||
|
'from' => $now,
|
||||||
|
'until' => $now->copy()->addWeeks($weeks),
|
||||||
|
'meta' => ['extensions_used' => 0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
event(new LoanCreated($purchase));
|
||||||
|
|
||||||
|
return $purchase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
|
use Blax\Shop\Contracts\Cartable;
|
||||||
|
use Blax\Shop\Contracts\Purchasable;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in trait for app models that want to be Cartable + Purchasable without
|
||||||
|
* subclassing {@see \Blax\Shop\Models\Product}.
|
||||||
|
*
|
||||||
|
* class Book extends Model implements Cartable, Purchasable
|
||||||
|
* {
|
||||||
|
* use HasUuids, IsSimplePurchasable;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Defaults shipped by this trait:
|
||||||
|
* - free price (override `getCurrentPrice()` / `getPriceAttribute()` for billing)
|
||||||
|
* - never on sale
|
||||||
|
* - `decreaseStock()` / `increaseStock()` are no-ops returning true
|
||||||
|
* - `purchases()` polymorphic relation against {@see ProductPurchase}
|
||||||
|
*
|
||||||
|
* If the host model needs to track availability, **override the
|
||||||
|
* `decreaseStock()` and `increaseStock()` methods** — that's the contract the
|
||||||
|
* package already exposes for inventory mutations. Typical override:
|
||||||
|
*
|
||||||
|
* public function decreaseStock(int $quantity = 1): bool
|
||||||
|
* {
|
||||||
|
* return (bool) static::whereKey($this->getKey())
|
||||||
|
* ->where('available_copies', '>=', $quantity)
|
||||||
|
* ->update(['available_copies' => DB::raw('available_copies - '.$quantity)]);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Host model still needs to declare `implements Cartable, Purchasable` —
|
||||||
|
* the trait satisfies the contract methods but cannot apply interfaces.
|
||||||
|
*/
|
||||||
|
trait IsSimplePurchasable
|
||||||
|
{
|
||||||
|
/** @return MorphMany<ProductPurchase, $this> */
|
||||||
|
public function purchases(): MorphMany
|
||||||
|
{
|
||||||
|
$purchaseModel = config('shop.models.product_purchase', ProductPurchase::class);
|
||||||
|
|
||||||
|
return $this->morphMany($purchaseModel, 'purchasable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrentPrice(): ?float
|
||||||
|
{
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriceAttribute(): ?float
|
||||||
|
{
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isOnSale(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decreaseStock(int $quantity = 1): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function increaseStock(int $quantity = 1): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\Cart;
|
||||||
|
|
||||||
|
use Blax\Shop\Contracts\Cartable;
|
||||||
|
use Blax\Shop\Contracts\Purchasable;
|
||||||
|
use Blax\Shop\Models\Cart;
|
||||||
|
use Blax\Shop\Models\CartItem;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Blax\Shop\Traits\IsSimplePurchasable;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cart::addToCart() used to leave $is_pool / $is_booking undefined when the
|
||||||
|
* cartable was not a Product / ProductPrice, which made non-Product Cartable
|
||||||
|
* hosts (e.g. a library Book that doesn't extend Product) crash with
|
||||||
|
* `Undefined variable $is_booking`. The fix initialises both flags to false.
|
||||||
|
*
|
||||||
|
* This integration test guards against that regression by exercising the
|
||||||
|
* full add-to-cart flow with an in-line {@see IsSimplePurchasable} host.
|
||||||
|
*/
|
||||||
|
class NonProductCartableTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Schema::create('loanable_widgets', function ($table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('name');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function add_to_cart_accepts_a_non_product_cartable_without_undefined_variables(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$widget = LoanableWidget::create(['name' => 'Hyperion']);
|
||||||
|
|
||||||
|
$cart = Cart::factory()->create([
|
||||||
|
'customer_id' => $user->id,
|
||||||
|
'customer_type' => get_class($user),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cartItem = $cart->addToCart($widget, 1);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(CartItem::class, $cartItem);
|
||||||
|
$this->assertSame($widget->id, $cartItem->purchasable_id);
|
||||||
|
$this->assertSame(LoanableWidget::class, $cartItem->purchasable_type);
|
||||||
|
$this->assertSame(1, $cartItem->quantity);
|
||||||
|
$this->assertFalse(
|
||||||
|
(bool) $cartItem->is_booking,
|
||||||
|
'A non-Product cartable should not flag the item as a booking by default'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function is_simple_purchasable_provides_polymorphic_purchases_relation(): void
|
||||||
|
{
|
||||||
|
$widget = LoanableWidget::create(['name' => 'Hyperion']);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$purchase = $widget->purchases()->create([
|
||||||
|
'purchaser_id' => $user->id,
|
||||||
|
'purchaser_type' => User::class,
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(ProductPurchase::class, $purchase);
|
||||||
|
$this->assertSame(LoanableWidget::class, $purchase->purchasable_type);
|
||||||
|
$this->assertCount(1, $widget->purchases()->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function purchasable_defaults_are_free_and_no_op_stock(): void
|
||||||
|
{
|
||||||
|
$widget = LoanableWidget::create(['name' => 'Hyperion']);
|
||||||
|
|
||||||
|
$this->assertSame(0.0, $widget->getCurrentPrice());
|
||||||
|
$this->assertSame(0.0, $widget->getPriceAttribute());
|
||||||
|
$this->assertFalse($widget->isOnSale());
|
||||||
|
$this->assertTrue($widget->increaseStock());
|
||||||
|
$this->assertTrue($widget->decreaseStock());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-line fixture: the smallest valid host for IsSimplePurchasable.
|
||||||
|
* Lives in the test file because it's not useful anywhere else.
|
||||||
|
*/
|
||||||
|
class LoanableWidget extends Model implements Cartable, Purchasable
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
use IsSimplePurchasable;
|
||||||
|
|
||||||
|
protected $table = 'loanable_widgets';
|
||||||
|
|
||||||
|
protected $fillable = ['name'];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\Loan;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Events\LoanCreated;
|
||||||
|
use Blax\Shop\Events\LoanExtended;
|
||||||
|
use Blax\Shop\Events\LoanReturned;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loan lifecycle domain events.
|
||||||
|
*
|
||||||
|
* LoanCreated — host dispatches it explicitly after creating a ProductPurchase
|
||||||
|
* for a loanable item (the package can't tell loans apart from
|
||||||
|
* carts / one-off purchases without ambiguity).
|
||||||
|
* LoanExtended — dispatched from HasLoanLifecycle::extend()
|
||||||
|
* LoanReturned — dispatched from HasLoanLifecycle::markReturned()
|
||||||
|
*/
|
||||||
|
class LoanEventsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $borrower;
|
||||||
|
private Product $book;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->borrower = User::factory()->create();
|
||||||
|
$this->book = Product::factory()->create([
|
||||||
|
'name' => 'Hyperion',
|
||||||
|
'type' => ProductType::LOANABLE,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$this->book->increaseStock(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loan(): ProductPurchase
|
||||||
|
{
|
||||||
|
return $this->book->purchases()->create([
|
||||||
|
'purchaser_id' => $this->borrower->id,
|
||||||
|
'purchaser_type' => User::class,
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'status' => PurchaseStatus::PENDING,
|
||||||
|
'from' => Carbon::now(),
|
||||||
|
'until' => Carbon::now()->addWeeks(2),
|
||||||
|
'meta' => ['extensions_used' => 0],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function extending_a_loan_fires_loan_extended(): void
|
||||||
|
{
|
||||||
|
Event::fake([LoanExtended::class]);
|
||||||
|
|
||||||
|
$loan = $this->loan();
|
||||||
|
$loan->extend(2);
|
||||||
|
|
||||||
|
Event::assertDispatched(
|
||||||
|
LoanExtended::class,
|
||||||
|
fn (LoanExtended $event) =>
|
||||||
|
$event->loan->is($loan)
|
||||||
|
&& $event->addedWeeks === 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function marking_a_loan_returned_fires_loan_returned(): void
|
||||||
|
{
|
||||||
|
Event::fake([LoanReturned::class]);
|
||||||
|
|
||||||
|
$loan = $this->loan();
|
||||||
|
$loan->markReturned();
|
||||||
|
|
||||||
|
Event::assertDispatched(
|
||||||
|
LoanReturned::class,
|
||||||
|
fn (LoanReturned $event) => $event->loan->is($loan)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function loan_created_is_a_host_dispatched_event(): void
|
||||||
|
{
|
||||||
|
// The package does NOT auto-dispatch LoanCreated — it can't reliably
|
||||||
|
// distinguish loans from other ProductPurchase rows. Test that the
|
||||||
|
// event class exists and can be dispatched by an integrating host.
|
||||||
|
Event::fake([LoanCreated::class]);
|
||||||
|
|
||||||
|
$loan = $this->loan();
|
||||||
|
event(new LoanCreated($loan));
|
||||||
|
|
||||||
|
Event::assertDispatched(
|
||||||
|
LoanCreated::class,
|
||||||
|
fn (LoanCreated $event) => $event->loan->is($loan)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function extend_emits_each_call_with_the_correct_added_weeks(): void
|
||||||
|
{
|
||||||
|
Event::fake([LoanExtended::class]);
|
||||||
|
|
||||||
|
$loan = $this->loan();
|
||||||
|
$loan->extend(1);
|
||||||
|
$loan->extend(3);
|
||||||
|
|
||||||
|
Event::assertDispatchedTimes(LoanExtended::class, 2);
|
||||||
|
|
||||||
|
$captured = [];
|
||||||
|
Event::assertDispatched(LoanExtended::class, function (LoanExtended $event) use (&$captured) {
|
||||||
|
$captured[] = $event->addedWeeks;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertSame([1, 3], $captured);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\Loan;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises the loan lifecycle that ProductPurchase picks up from
|
||||||
|
* {@see \Blax\Shop\Traits\HasLoanLifecycle}: extend(), markReturned(), the
|
||||||
|
* scopes (activeLoans / returned / overdue), and the derived domain status.
|
||||||
|
*/
|
||||||
|
class LoanLifecycleTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $borrower;
|
||||||
|
private Product $book;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->borrower = User::factory()->create();
|
||||||
|
$this->book = Product::factory()->create([
|
||||||
|
'name' => 'Hyperion',
|
||||||
|
'type' => ProductType::LOANABLE,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$this->book->increaseStock(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkout(?Carbon $from = null, ?int $weeks = 2): ProductPurchase
|
||||||
|
{
|
||||||
|
$from ??= Carbon::now();
|
||||||
|
|
||||||
|
return $this->book->purchases()->create([
|
||||||
|
'purchaser_id' => $this->borrower->id,
|
||||||
|
'purchaser_type' => User::class,
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'status' => PurchaseStatus::PENDING,
|
||||||
|
'from' => $from,
|
||||||
|
'until' => $from->copy()->addWeeks($weeks),
|
||||||
|
'meta' => ['extensions_used' => 0],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_fresh_loan_is_active_with_zero_extensions(): void
|
||||||
|
{
|
||||||
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
$this->assertFalse($loan->isReturned());
|
||||||
|
$this->assertFalse($loan->isOverdue());
|
||||||
|
$this->assertSame('active', $loan->getDomainStatus());
|
||||||
|
$this->assertSame(0, $loan->extensionsUsed());
|
||||||
|
$this->assertNull($loan->returnedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function extend_pushes_due_date_and_increments_counter(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
|
||||||
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
$loan->extend(1);
|
||||||
|
$loan->refresh();
|
||||||
|
|
||||||
|
$this->assertSame(1, $loan->extensionsUsed());
|
||||||
|
$this->assertTrue(
|
||||||
|
$loan->until->equalTo(Carbon::parse('2026-06-04 10:00:00')),
|
||||||
|
'until should advance by exactly one week'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function can_extend_respects_max_extensions(): void
|
||||||
|
{
|
||||||
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
$this->assertTrue($loan->canExtend(2));
|
||||||
|
$loan->extend(1);
|
||||||
|
$this->assertTrue($loan->canExtend(2));
|
||||||
|
$loan->extend(1);
|
||||||
|
$loan->refresh();
|
||||||
|
$this->assertFalse($loan->canExtend(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function can_extend_falls_back_to_shop_loan_max_extensions_config(): void
|
||||||
|
{
|
||||||
|
config(['shop.loan.max_extensions' => 1]);
|
||||||
|
|
||||||
|
$loan = $this->checkout();
|
||||||
|
$this->assertTrue($loan->canExtend());
|
||||||
|
|
||||||
|
$loan->extend(1);
|
||||||
|
$loan->refresh();
|
||||||
|
|
||||||
|
$this->assertFalse($loan->canExtend());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function can_extend_returns_false_when_overdue(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
|
||||||
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-06-15 10:00:00'));
|
||||||
|
|
||||||
|
$this->assertTrue($loan->isOverdue());
|
||||||
|
$this->assertFalse($loan->canExtend(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function mark_returned_records_timestamp_and_flips_status(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
|
||||||
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
$loan->markReturned();
|
||||||
|
$loan->refresh();
|
||||||
|
|
||||||
|
$this->assertTrue($loan->isReturned());
|
||||||
|
$this->assertSame(PurchaseStatus::COMPLETED, $loan->status);
|
||||||
|
$this->assertSame('returned', $loan->getDomainStatus());
|
||||||
|
$this->assertSame(
|
||||||
|
Carbon::parse('2026-05-14 10:00:00')->toIso8601String(),
|
||||||
|
$loan->returnedAt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function mark_returned_accepts_explicit_timestamp(): void
|
||||||
|
{
|
||||||
|
$loan = $this->checkout();
|
||||||
|
$when = Carbon::parse('2026-05-20 16:30:00');
|
||||||
|
|
||||||
|
$loan->markReturned($when);
|
||||||
|
|
||||||
|
$this->assertSame($when->toIso8601String(), $loan->returnedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function active_loans_scope_excludes_returned_rows(): void
|
||||||
|
{
|
||||||
|
$active = $this->checkout();
|
||||||
|
$returned = $this->checkout();
|
||||||
|
$returned->markReturned();
|
||||||
|
|
||||||
|
$ids = ProductPurchase::query()->activeLoans()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->assertContains($active->id, $ids);
|
||||||
|
$this->assertNotContains($returned->id, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function returned_scope_only_matches_handed_back_loans(): void
|
||||||
|
{
|
||||||
|
$this->checkout(); // active
|
||||||
|
$handed_back = $this->checkout();
|
||||||
|
$handed_back->markReturned();
|
||||||
|
|
||||||
|
$ids = ProductPurchase::query()->returned()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->assertSame([$handed_back->id], $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function overdue_scope_matches_past_due_unreturned_loans(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
|
||||||
|
$onTime = $this->checkout();
|
||||||
|
$late = $this->checkout(Carbon::parse('2026-04-01 10:00:00'));
|
||||||
|
$returnedLate = $this->checkout(Carbon::parse('2026-04-01 10:00:00'));
|
||||||
|
$returnedLate->markReturned();
|
||||||
|
|
||||||
|
$ids = ProductPurchase::query()->overdue()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->assertContains($late->id, $ids);
|
||||||
|
$this->assertNotContains($onTime->id, $ids);
|
||||||
|
$this->assertNotContains($returnedLate->id, $ids, 'returned loans are no longer overdue');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────────────────── edge cases ───────────────────── */
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function mark_returned_called_twice_keeps_the_first_returned_at_timestamp(): void
|
||||||
|
{
|
||||||
|
// markReturned is idempotent-ish: calling it again overwrites the
|
||||||
|
// returned_at timestamp. We document that behaviour explicitly so a
|
||||||
|
// future refactor knows whether to keep it. If you want first-write-
|
||||||
|
// wins, change markReturned() to no-op when already returned.
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
|
||||||
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
$loan->markReturned();
|
||||||
|
$firstReturnedAt = $loan->returnedAt();
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-20 10:00:00'));
|
||||||
|
$loan->markReturned();
|
||||||
|
|
||||||
|
$this->assertNotSame($firstReturnedAt, $loan->returnedAt(), 'second call overwrites');
|
||||||
|
$this->assertSame(
|
||||||
|
Carbon::parse('2026-05-20 10:00:00')->toIso8601String(),
|
||||||
|
$loan->returnedAt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function extend_increments_counter_even_when_until_is_null(): void
|
||||||
|
{
|
||||||
|
// A loan with no due date is unusual but legal. extend() must not
|
||||||
|
// crash; current behaviour is to bump the counter without shifting
|
||||||
|
// the date.
|
||||||
|
$loan = $this->book->purchases()->create([
|
||||||
|
'purchaser_id' => $this->borrower->id,
|
||||||
|
'purchaser_type' => User::class,
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'status' => PurchaseStatus::PENDING,
|
||||||
|
'from' => Carbon::parse('2026-05-14 10:00:00'),
|
||||||
|
'until' => null,
|
||||||
|
'meta' => ['extensions_used' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$loan->extend(2);
|
||||||
|
|
||||||
|
$this->assertNull($loan->until);
|
||||||
|
$this->assertSame(1, $loan->extensionsUsed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function can_extend_returns_false_for_a_returned_loan_even_under_the_cap(): void
|
||||||
|
{
|
||||||
|
config(['shop.loan.max_extensions' => 5]);
|
||||||
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
$loan->markReturned();
|
||||||
|
$loan->refresh();
|
||||||
|
|
||||||
|
$this->assertFalse($loan->canExtend(), 'returned loan can never be extended');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function returned_at_handles_array_and_object_meta_casts(): void
|
||||||
|
{
|
||||||
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
// Eloquent casts the meta column to object; the helper should still
|
||||||
|
// read the key without crashing.
|
||||||
|
$loan->meta = ['returned_at' => '2026-06-01T10:00:00+00:00', 'extensions_used' => 0];
|
||||||
|
$loan->save();
|
||||||
|
$loan->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('2026-06-01T10:00:00+00:00', $loan->returnedAt());
|
||||||
|
$this->assertTrue($loan->isReturned());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────── domain status (4 states) ─────────────────── */
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function fresh_loan_reads_as_active(): void
|
||||||
|
{
|
||||||
|
$loan = $this->checkout();
|
||||||
|
$this->assertSame('active', $loan->getDomainStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function loan_becomes_extended_after_one_or_more_extensions(): void
|
||||||
|
{
|
||||||
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
$this->assertSame('active', $loan->getDomainStatus());
|
||||||
|
$loan->extend(1);
|
||||||
|
$loan->refresh();
|
||||||
|
$this->assertSame('extended', $loan->getDomainStatus(), 'one extension flips status');
|
||||||
|
|
||||||
|
$loan->extend(1);
|
||||||
|
$loan->refresh();
|
||||||
|
$this->assertSame('extended', $loan->getDomainStatus(), 'still extended after two');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function overdue_takes_precedence_over_extended(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
|
||||||
|
$loan = $this->checkout();
|
||||||
|
$loan->extend(1);
|
||||||
|
|
||||||
|
// Past the (already-extended) due date.
|
||||||
|
Carbon::setTestNow(Carbon::parse('2027-01-01 10:00:00'));
|
||||||
|
$loan->refresh();
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $loan->extensionsUsed());
|
||||||
|
$this->assertSame('overdue', $loan->getDomainStatus(), 'overdue beats extended');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function returned_takes_precedence_over_extended(): void
|
||||||
|
{
|
||||||
|
$loan = $this->checkout();
|
||||||
|
$loan->extend(1);
|
||||||
|
|
||||||
|
$loan->markReturned();
|
||||||
|
$loan->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('returned', $loan->getDomainStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\Loan;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\BillingScheme;
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Http\Resources\PurchaseResource;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
use Blax\Shop\Models\ProductPriceTier;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiered loan pricing is a property of the ProductPrice, not of the host
|
||||||
|
* app's config. Each ProductPrice with billing_scheme=tiered owns a ladder
|
||||||
|
* of ProductPriceTier rows; ProductPrice::calculateForUsage($days) walks
|
||||||
|
* the ladder and returns total cents owed. ProductPurchase::calculateCost()
|
||||||
|
* delegates through `price_id` (or the purchasable's defaultPrice()).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - per_unit price → flat per-day billing
|
||||||
|
* - tiered price → Stripe-style `up_to` walk with multi-tier spans
|
||||||
|
* - the user-facing library scenario (free 2 weeks → €1/day → €2/day @ 2 months)
|
||||||
|
* - returned-loan cap (cost frozen at meta.returned_at)
|
||||||
|
* - per-call price override
|
||||||
|
* - fractional days
|
||||||
|
* - PurchaseResource surfacing accrued_cost
|
||||||
|
* - no-price purchase → zero cost (free loan)
|
||||||
|
*/
|
||||||
|
class LoanPricingTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $borrower;
|
||||||
|
private Product $book;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
|
||||||
|
|
||||||
|
$this->borrower = User::factory()->create();
|
||||||
|
$this->book = Product::factory()->create([
|
||||||
|
'name' => 'Hyperion',
|
||||||
|
'type' => ProductType::LOANABLE,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$this->book->increaseStock(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a tiered ProductPrice with the given ladder.
|
||||||
|
*
|
||||||
|
* @param array<int, array{up_to: ?int, unit_amount: int}> $tiers
|
||||||
|
*/
|
||||||
|
private function tieredPrice(array $tiers, bool $default = true): ProductPrice
|
||||||
|
{
|
||||||
|
$price = ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $this->book->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 0,
|
||||||
|
'billing_scheme' => BillingScheme::TIERED,
|
||||||
|
'is_default' => $default,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($tiers as $i => $tier) {
|
||||||
|
ProductPriceTier::factory()->create([
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'up_to' => $tier['up_to'] ?? null,
|
||||||
|
'unit_amount' => $tier['unit_amount'],
|
||||||
|
'sort_order' => $i,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $price->load('tiers');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loan(
|
||||||
|
Carbon $from,
|
||||||
|
?Carbon $until = null,
|
||||||
|
?ProductPrice $price = null
|
||||||
|
): ProductPurchase {
|
||||||
|
return $this->book->purchases()->create([
|
||||||
|
'purchaser_id' => $this->borrower->id,
|
||||||
|
'purchaser_type' => User::class,
|
||||||
|
'price_id' => $price?->id,
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'status' => PurchaseStatus::PENDING,
|
||||||
|
'from' => $from,
|
||||||
|
'until' => $until ?? $from->copy()->addWeeks(2),
|
||||||
|
'meta' => ['extensions_used' => 0],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_loan_with_no_associated_price_costs_nothing(): void
|
||||||
|
{
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'));
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-12-01 10:00:00'));
|
||||||
|
|
||||||
|
$this->assertSame(0, $loan->accruedCost());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function per_unit_billing_scheme_is_flat_per_day(): void
|
||||||
|
{
|
||||||
|
// billing_scheme=per_unit → unit_amount × days.
|
||||||
|
$price = ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $this->book->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 50,
|
||||||
|
'billing_scheme' => BillingScheme::PER_UNIT,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-05-01 10:00:00'), price: $price);
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-11 10:00:00'));
|
||||||
|
|
||||||
|
$this->assertSame(500, $loan->accruedCost(), '10 days × 50c');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function tiered_billing_walks_the_ladder_with_up_to_boundaries(): void
|
||||||
|
{
|
||||||
|
$price = $this->tieredPrice([
|
||||||
|
['up_to' => 14, 'unit_amount' => 0],
|
||||||
|
['up_to' => 60, 'unit_amount' => 100],
|
||||||
|
['up_to' => null, 'unit_amount' => 200],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
|
||||||
|
|
||||||
|
// Day 10: free
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-11 10:00:00'));
|
||||||
|
$this->assertSame(0, $loan->accruedCost(), 'day 10 free');
|
||||||
|
|
||||||
|
// Day 20: 14 free + 6 days at 100c
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-21 10:00:00'));
|
||||||
|
$this->assertSame(600, $loan->accruedCost(), 'day 20 = 6×100');
|
||||||
|
|
||||||
|
// Day 75: 14 free + 46×100 + 15×200
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-03-17 10:00:00'));
|
||||||
|
$this->assertSame(7600, $loan->accruedCost(), 'day 75 = 4600 + 3000');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_user_specified_library_scenario(): void
|
||||||
|
{
|
||||||
|
// Library configuration: free for 14 days, then €1/day, then €2/day
|
||||||
|
// after two months. Defined on the price model, not in config.
|
||||||
|
$price = $this->tieredPrice([
|
||||||
|
['up_to' => 14, 'unit_amount' => 0],
|
||||||
|
['up_to' => 60, 'unit_amount' => 100],
|
||||||
|
['up_to' => null, 'unit_amount' => 200],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
|
||||||
|
|
||||||
|
$scenarios = [
|
||||||
|
[0, 0, 'same-day return'],
|
||||||
|
[7, 0, '1 week (grace)'],
|
||||||
|
[14, 0, 'exactly 2 weeks (last free day)'],
|
||||||
|
[15, 100, 'day 15 → 1 day at €1'],
|
||||||
|
[30, 1600, 'day 30 → 16 days at €1'],
|
||||||
|
[60, 4600, '2 months → 46 days at €1'],
|
||||||
|
[61, 4800, 'day 61 → +1 day at €2'],
|
||||||
|
[90, 10600, 'day 90 → 46×€1 + 30×€2'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($scenarios as [$days, $expected, $label]) {
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-01 10:00:00')->addDays($days));
|
||||||
|
$this->assertSame($expected, $loan->accruedCost(), "after {$days} days: {$label}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function calculate_cost_caps_at_return_time(): void
|
||||||
|
{
|
||||||
|
$price = $this->tieredPrice([
|
||||||
|
['up_to' => 14, 'unit_amount' => 0],
|
||||||
|
['up_to' => null, 'unit_amount' => 100],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-21 10:00:00')); // day 20 → 600c
|
||||||
|
$loan->markReturned();
|
||||||
|
$loan->refresh();
|
||||||
|
|
||||||
|
// Time marches on; cost should remain frozen.
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-12-01 10:00:00'));
|
||||||
|
$this->assertSame(600, $loan->accruedCost());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function calculate_cost_accepts_an_explicit_as_of_argument(): void
|
||||||
|
{
|
||||||
|
$price = $this->tieredPrice([
|
||||||
|
['up_to' => null, 'unit_amount' => 100],
|
||||||
|
]);
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
|
||||||
|
|
||||||
|
$this->assertSame(500, $loan->calculateCost(Carbon::parse('2026-01-06 10:00:00')));
|
||||||
|
$this->assertSame(1500, $loan->calculateCost(Carbon::parse('2026-01-16 10:00:00')));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function calculate_cost_accepts_a_per_call_price_override(): void
|
||||||
|
{
|
||||||
|
// Loan has no price by default → 0.
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'));
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-11 10:00:00'));
|
||||||
|
$this->assertSame(0, $loan->accruedCost());
|
||||||
|
|
||||||
|
// Per-call override with a tiered price.
|
||||||
|
$override = $this->tieredPrice([
|
||||||
|
['up_to' => null, 'unit_amount' => 50],
|
||||||
|
], default: false);
|
||||||
|
$this->assertSame(500, $loan->calculateCost(null, $override));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function fractional_days_are_billed_proportionally(): void
|
||||||
|
{
|
||||||
|
$price = $this->tieredPrice([
|
||||||
|
['up_to' => null, 'unit_amount' => 200],
|
||||||
|
]);
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-01 22:00:00')); // 0.5 days
|
||||||
|
$this->assertSame(100, $loan->accruedCost());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function purchase_resource_surfaces_accrued_cost(): void
|
||||||
|
{
|
||||||
|
$price = $this->tieredPrice([
|
||||||
|
['up_to' => 14, 'unit_amount' => 0],
|
||||||
|
['up_to' => null, 'unit_amount' => 100],
|
||||||
|
]);
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-21 10:00:00'));
|
||||||
|
|
||||||
|
$payload = PurchaseResource::make($loan)->toArray(Request::create('/'));
|
||||||
|
|
||||||
|
$this->assertSame(600, $payload['accrued_cost']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function purchase_resource_returns_null_accrued_cost_for_non_loan_purchases(): void
|
||||||
|
{
|
||||||
|
$purchase = $this->book->purchases()->create([
|
||||||
|
'purchaser_id' => $this->borrower->id,
|
||||||
|
'purchaser_type' => User::class,
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 5000,
|
||||||
|
'amount_paid' => 5000,
|
||||||
|
'status' => PurchaseStatus::COMPLETED,
|
||||||
|
// no from/until — plain e-commerce purchase
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = PurchaseResource::make($purchase)->toArray(Request::create('/'));
|
||||||
|
$this->assertNull($payload['accrued_cost']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function product_price_calculate_for_usage_handles_zero_and_negative_usage(): void
|
||||||
|
{
|
||||||
|
$price = $this->tieredPrice([
|
||||||
|
['up_to' => null, 'unit_amount' => 100],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(0, $price->calculateForUsage(0));
|
||||||
|
$this->assertSame(0, $price->calculateForUsage(-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function product_price_flat_amount_is_added_per_entered_tier(): void
|
||||||
|
{
|
||||||
|
$price = ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $this->book->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 0,
|
||||||
|
'billing_scheme' => BillingScheme::TIERED,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
// Tier 1: 0-14 free with €5 flat-on-entry setup fee.
|
||||||
|
// Tier 2: 14+ at €1/day with no flat fee.
|
||||||
|
ProductPriceTier::factory()->create([
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'up_to' => 14,
|
||||||
|
'unit_amount' => 0,
|
||||||
|
'flat_amount' => 500,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
ProductPriceTier::factory()->create([
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'up_to' => null,
|
||||||
|
'unit_amount' => 100,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
$price->load('tiers');
|
||||||
|
|
||||||
|
// Within tier 1: only the flat 500c applies.
|
||||||
|
$this->assertSame(500, $price->calculateForUsage(5));
|
||||||
|
// Crosses both tiers: 500 (flat) + 0×14 (free days) + 100×6 (paid days)
|
||||||
|
$this->assertSame(1100, $price->calculateForUsage(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────────────────── edge cases ───────────────────── */
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function tiered_price_with_no_tiers_falls_back_to_unit_amount(): void
|
||||||
|
{
|
||||||
|
// A ProductPrice with billing_scheme=tiered but an empty tier set
|
||||||
|
// should NOT throw — it should treat unit_amount as a flat per-unit
|
||||||
|
// rate, matching per_unit behaviour.
|
||||||
|
$price = ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $this->book->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 75,
|
||||||
|
'billing_scheme' => BillingScheme::TIERED,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(0, $price->tiers()->count());
|
||||||
|
$this->assertSame(750, $price->calculateForUsage(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function purchase_price_relation_returns_the_attached_price(): void
|
||||||
|
{
|
||||||
|
$price = $this->tieredPrice([
|
||||||
|
['up_to' => null, 'unit_amount' => 100],
|
||||||
|
]);
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(ProductPrice::class, $loan->price);
|
||||||
|
$this->assertSame($price->id, $loan->price->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function calculate_cost_falls_back_to_purchasable_default_price_when_purchase_has_no_price_id(): void
|
||||||
|
{
|
||||||
|
// The purchase has no price_id set, but the Book has a default
|
||||||
|
// price — calculateCost should resolve through purchasable->defaultPrice().
|
||||||
|
$defaultPrice = $this->tieredPrice([
|
||||||
|
['up_to' => null, 'unit_amount' => 50],
|
||||||
|
], default: true);
|
||||||
|
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00')); // no price_id
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-11 10:00:00')); // 10 days
|
||||||
|
|
||||||
|
$this->assertNull($loan->price_id);
|
||||||
|
$this->assertSame(500, $loan->accruedCost(), 'fallback to defaultPrice → 10 × 50c');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function explicit_price_id_takes_precedence_over_default_price(): void
|
||||||
|
{
|
||||||
|
// Default price says €1/day, explicit price says €5/day.
|
||||||
|
$this->tieredPrice([
|
||||||
|
['up_to' => null, 'unit_amount' => 100],
|
||||||
|
], default: true);
|
||||||
|
|
||||||
|
$premiumPrice = $this->tieredPrice([
|
||||||
|
['up_to' => null, 'unit_amount' => 500],
|
||||||
|
], default: false);
|
||||||
|
|
||||||
|
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $premiumPrice);
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-11 10:00:00')); // 10 days
|
||||||
|
|
||||||
|
$this->assertSame(5000, $loan->accruedCost(), 'uses premiumPrice not default');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\Pricing;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
use Blax\Shop\Models\ProductPriceTier;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The walker in ProductPrice::calculateForUsage() relies on tiers() coming
|
||||||
|
* back in ladder order: sort_order ascending, with the unbounded tier
|
||||||
|
* (up_to = null) always pinned to the end so it acts as the catch-all.
|
||||||
|
*/
|
||||||
|
class ProductPriceTiersRelationTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function makePrice(array $overrides = []): ProductPrice
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
|
||||||
|
return ProductPrice::factory()->create(array_merge([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function tiers_relation_orders_by_sort_order(): void
|
||||||
|
{
|
||||||
|
$price = $this->makePrice();
|
||||||
|
|
||||||
|
$b = ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 30, 'sort_order' => 1]);
|
||||||
|
$a = ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 10, 'sort_order' => 0]);
|
||||||
|
$c = ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 60, 'sort_order' => 2]);
|
||||||
|
|
||||||
|
$ids = $price->tiers->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->assertSame([$a->id, $b->id, $c->id], $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function unbounded_tier_sorts_after_bounded_tiers_regardless_of_insertion_order(): void
|
||||||
|
{
|
||||||
|
$price = $this->makePrice();
|
||||||
|
|
||||||
|
// Insert the unbounded tier first (sort_order=0, the same as the
|
||||||
|
// first bounded tier) — the orderByRaw guard should still push it
|
||||||
|
// to the end.
|
||||||
|
$unbounded = ProductPriceTier::factory()->create([
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'up_to' => null,
|
||||||
|
'unit_amount' => 999,
|
||||||
|
'sort_order' => 99,
|
||||||
|
]);
|
||||||
|
$first = ProductPriceTier::factory()->create([
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'up_to' => 14,
|
||||||
|
'unit_amount' => 0,
|
||||||
|
'sort_order' => 0,
|
||||||
|
]);
|
||||||
|
$second = ProductPriceTier::factory()->create([
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'up_to' => 60,
|
||||||
|
'unit_amount' => 100,
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ids = $price->tiers()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->assertSame([$first->id, $second->id, $unbounded->id], $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function calculate_for_usage_walks_tiers_in_relation_order(): void
|
||||||
|
{
|
||||||
|
$price = $this->makePrice(['billing_scheme' => 'tiered']);
|
||||||
|
|
||||||
|
// Out-of-order inserts to prove that the relation ordering — not
|
||||||
|
// insertion order — drives the math.
|
||||||
|
ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => null, 'unit_amount' => 200, 'sort_order' => 2]);
|
||||||
|
ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 14, 'unit_amount' => 0, 'sort_order' => 0]);
|
||||||
|
ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 60, 'unit_amount' => 100, 'sort_order' => 1]);
|
||||||
|
|
||||||
|
// 75 days: 14 free + 46×100 + 15×200 = 7600
|
||||||
|
$this->assertSame(7600, $price->fresh()->calculateForUsage(75));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function tiers_table_declares_cascade_on_delete_for_price_id(): void
|
||||||
|
{
|
||||||
|
// FK enforcement on SQLite under RefreshDatabase is config-sensitive
|
||||||
|
// (transactions + PRAGMA scoping make a runtime cascade hard to
|
||||||
|
// observe reliably). The package's contract here is structural:
|
||||||
|
// the price_id FK should be declared with ON DELETE CASCADE so a
|
||||||
|
// production MySQL / Postgres deployment behaves correctly.
|
||||||
|
$migration = file_get_contents(__DIR__.'/../../../database/migrations/2025_01_01_000002_create_product_price_tiers_table.php');
|
||||||
|
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/foreignUuid\(\'price_id\'\)[^;]*cascadeOnDelete\(\)/s',
|
||||||
|
$migration,
|
||||||
|
'price_id should be declared with cascadeOnDelete()'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\Product;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductAction;
|
||||||
|
use Blax\Shop\Models\ProductAttribute;
|
||||||
|
use Blax\Shop\Models\ProductStock;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test for the FK-on-subclass package fix.
|
||||||
|
*
|
||||||
|
* Eloquent's `hasMany` infers the foreign key from the parent model class
|
||||||
|
* name. Without an explicit `'product_id'` the relation on a Product
|
||||||
|
* subclass would look for `book_id` / `widget_id` / etc., breaking
|
||||||
|
* stocks(), attributes() and actions().
|
||||||
|
*
|
||||||
|
* This test exercises a Product subclass (`SubclassedProduct`) and asserts
|
||||||
|
* that those relations still hit `product_id` and resolve cleanly.
|
||||||
|
*/
|
||||||
|
class ProductSubclassFkTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function has_stocks_works_for_a_product_subclass(): void
|
||||||
|
{
|
||||||
|
$product = SubclassedProduct::create([
|
||||||
|
'name' => 'Subclassed',
|
||||||
|
'type' => ProductType::SIMPLE,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// increaseStock writes a row in product_stocks and reads it back.
|
||||||
|
// If the trait inferred `subclassed_product_id` the insert would
|
||||||
|
// fail; the SELECT in getAvailableStock() would silently return 0.
|
||||||
|
$product->increaseStock(7);
|
||||||
|
|
||||||
|
$this->assertSame(7, $product->getAvailableStock());
|
||||||
|
$this->assertInstanceOf(ProductStock::class, $product->stocks()->first());
|
||||||
|
$this->assertSame((string) $product->id, (string) $product->stocks()->first()->product_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function attributes_relation_works_for_a_product_subclass(): void
|
||||||
|
{
|
||||||
|
$product = SubclassedProduct::create([
|
||||||
|
'name' => 'Subclassed',
|
||||||
|
'type' => ProductType::SIMPLE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProductAttribute::create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'key' => 'colour',
|
||||||
|
'value' => 'red',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertCount(1, $product->attributes()->get());
|
||||||
|
$this->assertSame('colour', $product->attributes()->first()->key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function actions_relation_works_for_a_product_subclass(): void
|
||||||
|
{
|
||||||
|
$product = SubclassedProduct::create([
|
||||||
|
'name' => 'Subclassed',
|
||||||
|
'type' => ProductType::SIMPLE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProductAction::create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'events' => ['purchased'],
|
||||||
|
'class' => 'App\\Jobs\\SendReceipt',
|
||||||
|
'method' => 'handle',
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertCount(1, $product->actions()->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function eloquent_morph_map_for_purchasable_resolves_subclass(): void
|
||||||
|
{
|
||||||
|
$product = SubclassedProduct::create([
|
||||||
|
'name' => 'Subclassed',
|
||||||
|
'type' => ProductType::SIMPLE,
|
||||||
|
'manage_stock' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The polymorphic purchases() relation should hit
|
||||||
|
// purchasable_type=SubclassedProduct, not Product.
|
||||||
|
$purchase = $product->purchases()->create([
|
||||||
|
'purchaser_type' => 'App\\Models\\User',
|
||||||
|
'purchaser_id' => '00000000-0000-0000-0000-000000000000',
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(SubclassedProduct::class, $purchase->purchasable_type);
|
||||||
|
$this->assertCount(1, $product->purchases()->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare Product subclass for the FK-invariant test. Lives in the test file
|
||||||
|
* because it's not useful elsewhere — its only job is to exist as
|
||||||
|
* "a Product subclass" so Eloquent's default conventions get challenged.
|
||||||
|
*/
|
||||||
|
class SubclassedProduct extends Product
|
||||||
|
{
|
||||||
|
// Intentionally empty.
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\ShopServiceProvider;
|
||||||
|
|
||||||
|
use Blax\Shop\ShopServiceProvider;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The package auto-loads its migrations from `vendor/.../database/migrations`
|
||||||
|
* so `composer require + php artisan migrate` works without a publish step.
|
||||||
|
* `config('shop.run_migrations')` is the kill switch for projects that
|
||||||
|
* prefer publish-and-manage workflows.
|
||||||
|
*/
|
||||||
|
class MigrationAutoLoadTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function the_package_ships_a_run_migrations_config_default_of_true(): void
|
||||||
|
{
|
||||||
|
$shipped = require __DIR__.'/../../../config/shop.php';
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('run_migrations', $shipped);
|
||||||
|
$this->assertTrue($shipped['run_migrations']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_package_publishes_all_three_migration_files(): void
|
||||||
|
{
|
||||||
|
$expected = [
|
||||||
|
'2025_01_01_000001_create_blax_shop_tables.php',
|
||||||
|
'2025_01_01_000002_create_product_price_tiers_table.php',
|
||||||
|
'2025_01_01_000003_add_stripe_to_users_table.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
$files = File::files(__DIR__.'/../../../database/migrations');
|
||||||
|
$names = collect($files)->map(fn ($f) => $f->getFilename())->all();
|
||||||
|
|
||||||
|
foreach ($expected as $migration) {
|
||||||
|
$this->assertContains($migration, $names, "Missing package migration: {$migration}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function register_migrations_is_a_protected_method_on_the_service_provider(): void
|
||||||
|
{
|
||||||
|
// Sanity check that the gate exists and is documented as part of the
|
||||||
|
// provider's boot path. Catches accidental renames.
|
||||||
|
$reflection = new ReflectionClass(ShopServiceProvider::class);
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$reflection->hasMethod('registerMigrations'),
|
||||||
|
'ShopServiceProvider::registerMigrations() is the run_migrations gate.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$reflection->getMethod('registerMigrations')->isProtected(),
|
||||||
|
'registerMigrations() should be protected — booted internally.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function register_migrations_short_circuits_when_run_migrations_is_false(): void
|
||||||
|
{
|
||||||
|
// Drive the gate directly via reflection: with run_migrations=false
|
||||||
|
// it should return early before calling loadMigrationsFrom — i.e.
|
||||||
|
// not throw and not register anything new.
|
||||||
|
config(['shop.run_migrations' => false]);
|
||||||
|
|
||||||
|
$provider = $this->app->getProvider(ShopServiceProvider::class);
|
||||||
|
|
||||||
|
$method = (new ReflectionClass($provider))->getMethod('registerMigrations');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
// Should not throw.
|
||||||
|
$method->invoke($provider);
|
||||||
|
|
||||||
|
$this->assertFalse(config('shop.run_migrations'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,9 @@ abstract class TestCase extends Orchestra
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
ini_set('memory_limit', '256M');
|
// The suite has grown past what 256M can comfortably sustain across
|
||||||
|
// ~1,200 tests sharing in-memory SQLite + Orchestra Testbench fixtures.
|
||||||
|
ini_set('memory_limit', '1024M');
|
||||||
|
|
||||||
Factory::guessFactoryNamesUsing(
|
Factory::guessFactoryNamesUsing(
|
||||||
fn(string $modelName) => match (true) {
|
fn(string $modelName) => match (true) {
|
||||||
|
|
@ -57,10 +59,13 @@ abstract class TestCase extends Orchestra
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run package migrations
|
// Run package migrations
|
||||||
$migration = include __DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub';
|
$migration = include __DIR__ . '/../database/migrations/2025_01_01_000001_create_blax_shop_tables.php';
|
||||||
$migration->up();
|
$migration->up();
|
||||||
|
|
||||||
$migration = include __DIR__ . '/../database/migrations/add_stripe_to_users_table.php.stub';
|
$migration = include __DIR__ . '/../database/migrations/2025_01_01_000003_add_stripe_to_users_table.php';
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$migration = include __DIR__ . '/../database/migrations/2025_01_01_000002_create_product_price_tiers_table.php';
|
||||||
$migration->up();
|
$migration->up();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Unit\Enums;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
|
||||||
|
class ProductTypeTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function it_exposes_loanable_as_a_distinct_product_type(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('loanable', ProductType::LOANABLE->value);
|
||||||
|
$this->assertSame('Loanable', ProductType::LOANABLE->label());
|
||||||
|
$this->assertSame(ProductType::LOANABLE, ProductType::from('loanable'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function every_product_type_has_a_human_label(): void
|
||||||
|
{
|
||||||
|
foreach (ProductType::cases() as $case) {
|
||||||
|
$this->assertNotEmpty($case->label(), "Missing label for {$case->value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Unit\Models;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
use Blax\Shop\Models\ProductPriceTier;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
|
||||||
|
class ProductPriceTierTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function makePrice(): ProductPrice
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create();
|
||||||
|
|
||||||
|
return ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_uses_the_configured_table_name(): void
|
||||||
|
{
|
||||||
|
$tier = new ProductPriceTier();
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
config('shop.tables.product_price_tiers', 'product_price_tiers'),
|
||||||
|
$tier->getTable(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function casts_keep_integers_and_object_meta(): void
|
||||||
|
{
|
||||||
|
$tier = ProductPriceTier::factory()->create([
|
||||||
|
'price_id' => $this->makePrice()->id,
|
||||||
|
'up_to' => 14,
|
||||||
|
'unit_amount' => 199,
|
||||||
|
'flat_amount' => 500,
|
||||||
|
'sort_order' => 3,
|
||||||
|
'meta' => ['note' => 'first-tier grace'],
|
||||||
|
]);
|
||||||
|
$tier->refresh();
|
||||||
|
|
||||||
|
$this->assertSame(14, $tier->up_to);
|
||||||
|
$this->assertIsInt($tier->up_to);
|
||||||
|
$this->assertSame(199, $tier->unit_amount);
|
||||||
|
$this->assertSame(500, $tier->flat_amount);
|
||||||
|
$this->assertSame(3, $tier->sort_order);
|
||||||
|
$this->assertIsObject($tier->meta);
|
||||||
|
$this->assertSame('first-tier grace', $tier->meta->note);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function up_to_can_be_null_to_represent_an_unbounded_tier(): void
|
||||||
|
{
|
||||||
|
$tier = ProductPriceTier::factory()->create([
|
||||||
|
'price_id' => $this->makePrice()->id,
|
||||||
|
'up_to' => null,
|
||||||
|
'unit_amount' => 200,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNull($tier->up_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function price_relation_resolves_back_to_the_parent_price(): void
|
||||||
|
{
|
||||||
|
$price = $this->makePrice();
|
||||||
|
$tier = ProductPriceTier::factory()->create([
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'unit_amount' => 99,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(ProductPrice::class, $tier->price);
|
||||||
|
$this->assertSame($price->id, $tier->price->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Unit\Purchase;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare unit tests for {@see \Blax\Shop\Traits\HasBookingLifecycle} — the
|
||||||
|
* trait extracted from ProductPurchase. Tests the trait's contract directly
|
||||||
|
* on a ProductPurchase row, independent of Product / Cart / pool wiring.
|
||||||
|
*/
|
||||||
|
class BookingLifecycleTraitTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function purchase(array $attrs = []): ProductPurchase
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$product = Product::factory()->create(['manage_stock' => false]);
|
||||||
|
|
||||||
|
return $product->purchases()->create(array_merge([
|
||||||
|
'purchaser_id' => $user->id,
|
||||||
|
'purchaser_type' => User::class,
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'status' => PurchaseStatus::PENDING,
|
||||||
|
], $attrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function is_booking_is_true_only_when_both_from_and_until_are_set(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->purchase()->isBooking());
|
||||||
|
|
||||||
|
$this->assertFalse(
|
||||||
|
$this->purchase(['from' => Carbon::parse('2026-01-01 10:00:00')])->isBooking(),
|
||||||
|
'only from set → not a booking'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFalse(
|
||||||
|
$this->purchase(['until' => Carbon::parse('2026-01-10 10:00:00')])->isBooking(),
|
||||||
|
'only until set → not a booking'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$this->purchase([
|
||||||
|
'from' => Carbon::parse('2026-01-01 10:00:00'),
|
||||||
|
'until' => Carbon::parse('2026-01-10 10:00:00'),
|
||||||
|
])->isBooking()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function is_booking_ended_returns_false_for_non_bookings(): void
|
||||||
|
{
|
||||||
|
$purchase = $this->purchase();
|
||||||
|
|
||||||
|
$this->assertFalse($purchase->isBookingEnded());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function is_booking_ended_returns_true_only_after_until(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-05 10:00:00'));
|
||||||
|
|
||||||
|
$future = $this->purchase([
|
||||||
|
'from' => Carbon::parse('2026-01-01 10:00:00'),
|
||||||
|
'until' => Carbon::parse('2026-01-10 10:00:00'),
|
||||||
|
]);
|
||||||
|
$this->assertFalse($future->isBookingEnded(), 'mid-booking → not ended');
|
||||||
|
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-01-11 10:00:00'));
|
||||||
|
$this->assertTrue($future->isBookingEnded(), 'past until → ended');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function bookings_scope_returns_only_rows_with_both_dates(): void
|
||||||
|
{
|
||||||
|
$bookingId = $this->purchase([
|
||||||
|
'from' => Carbon::parse('2026-01-01 10:00:00'),
|
||||||
|
'until' => Carbon::parse('2026-01-10 10:00:00'),
|
||||||
|
])->id;
|
||||||
|
|
||||||
|
$partialId = $this->purchase(['from' => Carbon::parse('2026-01-01 10:00:00')])->id;
|
||||||
|
$plainId = $this->purchase()->id;
|
||||||
|
|
||||||
|
$ids = ProductPurchase::query()->bookings()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->assertContains($bookingId, $ids);
|
||||||
|
$this->assertNotContains($partialId, $ids);
|
||||||
|
$this->assertNotContains($plainId, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function ended_bookings_scope_is_past_until_intersected_with_bookings(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-15 10:00:00'));
|
||||||
|
|
||||||
|
$past = $this->purchase([
|
||||||
|
'from' => Carbon::parse('2026-01-01 10:00:00'),
|
||||||
|
'until' => Carbon::parse('2026-01-10 10:00:00'),
|
||||||
|
]);
|
||||||
|
$future = $this->purchase([
|
||||||
|
'from' => Carbon::parse('2026-09-01 10:00:00'),
|
||||||
|
'until' => Carbon::parse('2026-09-10 10:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ids = ProductPurchase::query()->endedBookings()->pluck('id')->all();
|
||||||
|
|
||||||
|
$this->assertContains($past->id, $ids);
|
||||||
|
$this->assertNotContains($future->id, $ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Unit\Resources;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Http\Resources\PurchaseResource;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
class PurchaseResourceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function loan(array $overrides = []): ProductPurchase
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'name' => 'Hyperion',
|
||||||
|
'type' => ProductType::LOANABLE,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$product->increaseStock(1);
|
||||||
|
|
||||||
|
return $product->purchases()->create(array_merge([
|
||||||
|
'purchaser_id' => $user->id,
|
||||||
|
'purchaser_type' => User::class,
|
||||||
|
'quantity' => 1,
|
||||||
|
'amount' => 0,
|
||||||
|
'amount_paid' => 0,
|
||||||
|
'status' => PurchaseStatus::PENDING,
|
||||||
|
'from' => Carbon::parse('2026-05-14 10:00:00'),
|
||||||
|
'until' => Carbon::parse('2026-05-28 10:00:00'),
|
||||||
|
'meta' => ['extensions_used' => 0],
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_translates_e_commerce_columns_into_loan_vocabulary(): void
|
||||||
|
{
|
||||||
|
$loan = $this->loan();
|
||||||
|
|
||||||
|
$payload = PurchaseResource::make($loan)->toArray(Request::create('/'));
|
||||||
|
|
||||||
|
$this->assertSame($loan->id, $payload['id']);
|
||||||
|
$this->assertSame('2026-05-14T10:00:00+00:00', $payload['loaned_at']);
|
||||||
|
$this->assertSame('2026-05-28T10:00:00+00:00', $payload['due_at']);
|
||||||
|
$this->assertNull($payload['returned_at']);
|
||||||
|
$this->assertSame('active', $payload['status']);
|
||||||
|
$this->assertSame(0, $payload['extensions_used']);
|
||||||
|
$this->assertSame(PurchaseStatus::PENDING->value, $payload['lifecycle_status']);
|
||||||
|
$this->assertSame(1, $payload['quantity']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_surfaces_returned_status_after_mark_returned(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-05-20 16:00:00'));
|
||||||
|
|
||||||
|
$loan = $this->loan();
|
||||||
|
$loan->markReturned();
|
||||||
|
|
||||||
|
$payload = PurchaseResource::make($loan)->toArray(Request::create('/'));
|
||||||
|
|
||||||
|
$this->assertSame('returned', $payload['status']);
|
||||||
|
$this->assertSame('2026-05-20T16:00:00+00:00', $payload['returned_at']);
|
||||||
|
$this->assertSame(PurchaseStatus::COMPLETED->value, $payload['lifecycle_status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_reports_overdue_when_due_date_is_past(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse('2026-06-15 10:00:00'));
|
||||||
|
|
||||||
|
$loan = $this->loan();
|
||||||
|
|
||||||
|
$payload = PurchaseResource::make($loan)->toArray(Request::create('/'));
|
||||||
|
|
||||||
|
$this->assertSame('overdue', $payload['status']);
|
||||||
|
$this->assertNull($payload['returned_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function purchasable_resource_hook_can_serialise_the_item(): void
|
||||||
|
{
|
||||||
|
$loan = $this->loan();
|
||||||
|
$loan->load('purchasable');
|
||||||
|
|
||||||
|
$resource = new class ($loan) extends PurchaseResource {
|
||||||
|
protected function purchasableResource(): ?string
|
||||||
|
{
|
||||||
|
return BookSummaryResource::class;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$request = Request::create('/');
|
||||||
|
$payload = $resource->toArray($request);
|
||||||
|
|
||||||
|
// PurchaseResource hands back the nested resource as a JsonResource
|
||||||
|
// instance; Laravel resolves it during HTTP serialisation. Resolve it
|
||||||
|
// here to verify shape.
|
||||||
|
$this->assertInstanceOf(JsonResource::class, $payload['item']);
|
||||||
|
$this->assertSame('Hyperion', $payload['item']->toArray($request)['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BookSummaryResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue