2025-11-21 10:49:41 +00:00
|
|
|
<?php
|
|
|
|
|
|
2026-05-15 18:26:24 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
namespace Blax\Shop\Models;
|
|
|
|
|
|
2025-11-23 14:07:12 +00:00
|
|
|
use Blax\Shop\Contracts\Cartable;
|
|
|
|
|
use Blax\Shop\Contracts\Purchasable;
|
2025-12-03 12:59:01 +00:00
|
|
|
use Blax\Shop\Enums\BillingScheme;
|
|
|
|
|
use Blax\Shop\Enums\PriceType;
|
|
|
|
|
use Blax\Shop\Enums\RecurringInterval;
|
2025-11-21 10:49:41 +00:00
|
|
|
use Blax\Workkit\Traits\HasMetaTranslation;
|
2026-05-15 18:26:24 +00:00
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
2025-11-21 10:49:41 +00:00
|
|
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
2025-11-23 14:07:12 +00:00
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
2025-11-21 10:49:41 +00:00
|
|
|
use Illuminate\Database\Eloquent\Model;
|
2026-05-15 08:27:59 +00:00
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
2026-05-15 18:26:24 +00:00
|
|
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2026-05-15 18:26:24 +00:00
|
|
|
/**
|
|
|
|
|
* A price record attached to a {@see Purchasable} (usually a {@see Product},
|
|
|
|
|
* but anything can carry prices via the polymorphic `purchasable_*` columns).
|
|
|
|
|
*
|
|
|
|
|
* A single purchasable can carry several prices — one default, plus
|
|
|
|
|
* alternative tiers (bulk, region, customer-segment). Pricing follows the
|
|
|
|
|
* package-wide rule: amounts are integer cents, currency lives in a
|
|
|
|
|
* separate `currency` column, never inferred from the amount.
|
|
|
|
|
*
|
|
|
|
|
* @property string $id
|
|
|
|
|
* @property string $purchasable_type
|
|
|
|
|
* @property string $purchasable_id
|
|
|
|
|
* @property string|null $stripe_price_id
|
|
|
|
|
* @property string|null $name
|
|
|
|
|
* @property \Blax\Shop\Enums\PriceType $type
|
|
|
|
|
* @property string $currency ISO 4217.
|
|
|
|
|
* @property float $unit_amount Per-unit price in the smallest currency unit (cents). Cast to float for math.
|
|
|
|
|
* @property float|null $sale_unit_amount Sale price; defaults to `unit_amount` when unset.
|
|
|
|
|
* @property bool $is_default
|
|
|
|
|
* @property bool $active
|
|
|
|
|
* @property \Blax\Shop\Enums\BillingScheme $billing_scheme
|
|
|
|
|
* @property \Blax\Shop\Enums\RecurringInterval|null $interval
|
|
|
|
|
* @property int|null $interval_count
|
|
|
|
|
* @property int|null $trial_period_days
|
|
|
|
|
* @property \stdClass $meta
|
|
|
|
|
*
|
|
|
|
|
* @property-read Model $purchasable
|
|
|
|
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, ProductPriceTier> $tiers
|
|
|
|
|
*/
|
2025-11-23 14:07:12 +00:00
|
|
|
class ProductPrice extends Model implements Cartable
|
2025-11-21 10:49:41 +00:00
|
|
|
{
|
2025-11-23 14:07:12 +00:00
|
|
|
use HasFactory, HasUuids, HasMetaTranslation;
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
protected $fillable = [
|
2025-11-23 14:07:12 +00:00
|
|
|
'purchasable_type',
|
|
|
|
|
'purchasable_id',
|
2025-11-21 10:49:41 +00:00
|
|
|
'stripe_price_id',
|
|
|
|
|
'name',
|
|
|
|
|
'type',
|
2025-11-23 14:07:12 +00:00
|
|
|
'currency',
|
|
|
|
|
'unit_amount',
|
|
|
|
|
'sale_unit_amount',
|
2025-11-21 10:49:41 +00:00
|
|
|
'is_default',
|
2025-11-23 14:07:12 +00:00
|
|
|
'active',
|
2025-11-21 10:49:41 +00:00
|
|
|
'billing_scheme',
|
|
|
|
|
'interval',
|
|
|
|
|
'interval_count',
|
|
|
|
|
'trial_period_days',
|
|
|
|
|
'meta',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
protected $casts = [
|
|
|
|
|
'is_default' => 'boolean',
|
|
|
|
|
'active' => 'boolean',
|
2025-12-03 12:59:01 +00:00
|
|
|
'type' => PriceType::class,
|
|
|
|
|
'billing_scheme' => BillingScheme::class,
|
|
|
|
|
'interval' => RecurringInterval::class,
|
2025-11-23 14:07:12 +00:00
|
|
|
'meta' => 'object',
|
|
|
|
|
'unit_amount' => 'float',
|
|
|
|
|
'sale_unit_amount' => 'float',
|
|
|
|
|
'interval_count' => 'integer',
|
|
|
|
|
'trial_period_days' => 'integer',
|
2025-11-21 10:49:41 +00:00
|
|
|
];
|
|
|
|
|
|
2026-05-15 18:26:24 +00:00
|
|
|
/**
|
|
|
|
|
* The {@see Purchasable} this price belongs to (usually a {@see Product}).
|
|
|
|
|
*
|
|
|
|
|
* @return MorphTo<Model, $this>
|
|
|
|
|
*/
|
|
|
|
|
public function purchasable(): MorphTo
|
2025-11-21 10:49:41 +00:00
|
|
|
{
|
2025-11-23 14:07:12 +00:00
|
|
|
return $this->morphTo();
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 18:26:24 +00:00
|
|
|
/**
|
|
|
|
|
* Filter to only currently-active prices (default scope alternative).
|
|
|
|
|
*
|
|
|
|
|
* @param Builder<self> $query
|
|
|
|
|
* @return Builder<self>
|
|
|
|
|
*/
|
|
|
|
|
public function scopeIsActive(Builder $query): Builder
|
2025-11-21 10:49:41 +00:00
|
|
|
{
|
|
|
|
|
return $query->where('active', true);
|
|
|
|
|
}
|
2025-11-24 13:32:11 +00:00
|
|
|
|
2026-05-15 18:26:24 +00:00
|
|
|
/**
|
|
|
|
|
* Resolve the unit price this record currently sells at.
|
|
|
|
|
*
|
|
|
|
|
* Returns the sale price when `$sale_price` is true *and* a
|
|
|
|
|
* `sale_unit_amount` is configured; otherwise the regular `unit_amount`.
|
|
|
|
|
*/
|
|
|
|
|
public function getCurrentPrice(?bool $sale_price = null): float
|
2025-11-24 13:32:11 +00:00
|
|
|
{
|
2025-11-25 16:02:39 +00:00
|
|
|
if ($sale_price) {
|
2025-11-29 07:32:58 +00:00
|
|
|
return $this->sale_unit_amount ?? $this->unit_amount;
|
2025-11-24 13:32:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->unit_amount;
|
|
|
|
|
}
|
2026-05-15 08:27:59 +00:00
|
|
|
|
2026-08-26 10:35:14 +00:00
|
|
|
/**
|
|
|
|
|
* The conditional-pricing requirement carried in `meta.requires`, or null
|
|
|
|
|
* when this is an unconditional (standalone) price.
|
|
|
|
|
*
|
|
|
|
|
* Shape: a map of one prerequisite, e.g. `['role' => 'seat']` or
|
|
|
|
|
* `['product' => 'full-seat']`, optionally with a display-only `label`
|
|
|
|
|
* ("8€ with Full Seat"). `meta` is cast to `object`, so this normalizes the
|
|
|
|
|
* `stdClass` back to an array for callers ({@see EntitlementChecker}).
|
|
|
|
|
*
|
|
|
|
|
* @return array<string,mixed>|null
|
|
|
|
|
*/
|
|
|
|
|
public function requires(): ?array
|
|
|
|
|
{
|
|
|
|
|
$requires = $this->meta->requires ?? null;
|
|
|
|
|
|
|
|
|
|
if ($requires === null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normalize stdClass|array -> array (meta cast is `object`).
|
|
|
|
|
$normalized = json_decode(json_encode($requires), true);
|
|
|
|
|
|
|
|
|
|
return is_array($normalized) && $normalized !== [] ? $normalized : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Whether this price only applies to buyers who satisfy a prerequisite
|
|
|
|
|
* (i.e. it carries `meta.requires`).
|
|
|
|
|
*/
|
|
|
|
|
public function isConditional(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->requires() !== null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Display label for the requirement ("8€ with Full Seat"), if authored.
|
|
|
|
|
*/
|
|
|
|
|
public function requiresLabel(): ?string
|
|
|
|
|
{
|
|
|
|
|
$label = $this->requires()['label'] ?? null;
|
|
|
|
|
|
|
|
|
|
return is_string($label) && $label !== '' ? $label : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Structural satisfaction check against an already-resolved set of "owned"
|
|
|
|
|
* signal strings, e.g. `['role:seat', 'product:full-seat']`.
|
|
|
|
|
*
|
|
|
|
|
* Used for the hypothetical "if you owned product A" case (no runtime
|
|
|
|
|
* buyer) — the host resolves slugs/ids into signal strings and passes them
|
|
|
|
|
* here. A non-conditional price is always satisfied. A conditional price is
|
|
|
|
|
* satisfied when ANY of its (non-label) requirement entries matches a
|
|
|
|
|
* signal — a single `requires` map with one key is the common case.
|
|
|
|
|
*
|
|
|
|
|
* @param array<int,string> $ownedSignals
|
|
|
|
|
*/
|
|
|
|
|
public function requiresSatisfiedBy(array $ownedSignals): bool
|
|
|
|
|
{
|
|
|
|
|
$requires = $this->requires();
|
|
|
|
|
|
|
|
|
|
if ($requires === null) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($requires as $type => $value) {
|
|
|
|
|
if ($type === 'label') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (is_scalar($value) && in_array($type . ':' . $value, $ownedSignals, true)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Only conditional prices (carry a `meta.requires`). Uses a JSON-string
|
|
|
|
|
* LIKE so it works whether `meta` is a JSON or TEXT column.
|
|
|
|
|
*
|
|
|
|
|
* @param Builder<self> $query
|
|
|
|
|
* @return Builder<self>
|
|
|
|
|
*/
|
|
|
|
|
public function scopeConditional(Builder $query): Builder
|
|
|
|
|
{
|
|
|
|
|
return $query->where('meta', 'like', '%"requires"%');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Only standalone (unconditional) prices.
|
|
|
|
|
*
|
|
|
|
|
* @param Builder<self> $query
|
|
|
|
|
* @return Builder<self>
|
|
|
|
|
*/
|
|
|
|
|
public function scopeStandalone(Builder $query): Builder
|
|
|
|
|
{
|
|
|
|
|
return $query->where(function (Builder $q) {
|
|
|
|
|
$q->whereNull('meta')->orWhere('meta', 'not like', '%"requires"%');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 08:27:59 +00:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|