$tiers */ class ProductPrice extends Model implements Cartable { use HasFactory, HasUuids, HasMetaTranslation; protected $fillable = [ 'purchasable_type', 'purchasable_id', 'stripe_price_id', 'name', 'type', 'currency', 'unit_amount', 'sale_unit_amount', 'is_default', 'active', 'billing_scheme', 'interval', 'interval_count', 'trial_period_days', 'meta', ]; protected $casts = [ 'is_default' => 'boolean', 'active' => 'boolean', 'type' => PriceType::class, 'billing_scheme' => BillingScheme::class, 'interval' => RecurringInterval::class, 'meta' => 'object', 'unit_amount' => 'float', 'sale_unit_amount' => 'float', 'interval_count' => 'integer', 'trial_period_days' => 'integer', ]; /** * The {@see Purchasable} this price belongs to (usually a {@see Product}). * * @return MorphTo */ public function purchasable(): MorphTo { return $this->morphTo(); } /** * Filter to only currently-active prices (default scope alternative). * * @param Builder $query * @return Builder */ public function scopeIsActive(Builder $query): Builder { return $query->where('active', true); } /** * 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 { if ($sale_price) { return $this->sale_unit_amount ?? $this->unit_amount; } return $this->unit_amount; } /** * 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|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 $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 $query * @return Builder */ public function scopeConditional(Builder $query): Builder { return $query->where('meta', 'like', '%"requires"%'); } /** * Only standalone (unconditional) prices. * * @param Builder $query * @return Builder */ public function scopeStandalone(Builder $query): Builder { return $query->where(function (Builder $q) { $q->whereNull('meta')->orWhere('meta', 'not like', '%"requires"%'); }); } /** * 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 */ 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); } }