Compare commits
1 commit
dev
...
feat/fix-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef7ed4ca77 |
1 changed files with 372 additions and 72 deletions
|
|
@ -19,6 +19,7 @@ use Illuminate\Cache\TaggableStore;
|
|||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Core service for managing feature entitlements, usage tracking, and package provisioning.
|
||||
|
|
@ -529,6 +530,193 @@ class EntitlementService
|
|||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically check entitlement and record usage in a single transaction.
|
||||
*
|
||||
* This method prevents the race condition where two concurrent requests both
|
||||
* read the same usage value, both pass the limit check, and both record usage,
|
||||
* resulting in over-consumption. It uses `lockForUpdate()` on usage records to
|
||||
* serialise concurrent access.
|
||||
*
|
||||
* ## Example Usage
|
||||
*
|
||||
* ```php
|
||||
* // Atomic check + record in one operation
|
||||
* $result = $entitlementService->checkAndRecordUsage($workspace, 'pages', 1, $user);
|
||||
* if ($result->isDenied()) {
|
||||
* return response()->json(['error' => $result->getMessage()], 403);
|
||||
* }
|
||||
* // Usage was already recorded — no need to call recordUsage() separately
|
||||
* ```
|
||||
*
|
||||
* @param Workspace $workspace The workspace to check and record for
|
||||
* @param string $featureCode The feature code to check
|
||||
* @param int $quantity The amount to consume (default: 1)
|
||||
* @param User|null $user Optional user who triggered the usage
|
||||
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
||||
* @return EntitlementResult Contains allowed/denied status. If allowed, usage has already been recorded.
|
||||
*/
|
||||
public function checkAndRecordUsage(
|
||||
Workspace $workspace,
|
||||
string $featureCode,
|
||||
int $quantity = 1,
|
||||
?User $user = null,
|
||||
?array $metadata = null
|
||||
): EntitlementResult {
|
||||
$feature = $this->getFeature($featureCode);
|
||||
|
||||
if (! $feature) {
|
||||
return EntitlementResult::denied(
|
||||
reason: "Feature '{$featureCode}' does not exist.",
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
$poolFeatureCode = $feature->getPoolFeatureCode();
|
||||
$totalLimit = $this->getTotalLimit($workspace, $poolFeatureCode);
|
||||
|
||||
if ($totalLimit === null) {
|
||||
return EntitlementResult::denied(
|
||||
reason: "Your plan does not include {$feature->name}.",
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
if ($totalLimit === -1) {
|
||||
// Unlimited — still record usage for tracking, but no need for locking
|
||||
$this->recordUsage($workspace, $featureCode, $quantity, $user, $metadata);
|
||||
|
||||
return EntitlementResult::unlimited($featureCode);
|
||||
}
|
||||
|
||||
if ($feature->isBoolean()) {
|
||||
return EntitlementResult::allowed(featureCode: $featureCode);
|
||||
}
|
||||
|
||||
// Atomic check-and-record within a transaction with row-level locking
|
||||
return DB::transaction(function () use ($workspace, $feature, $poolFeatureCode, $totalLimit, $quantity, $user, $metadata, $featureCode) {
|
||||
// Lock existing usage records to prevent concurrent reads from seeing
|
||||
// the same count. This serialises concurrent usage recording.
|
||||
$currentUsage = $this->getLockedCurrentUsage($workspace, $poolFeatureCode, $feature);
|
||||
|
||||
if ($currentUsage + $quantity > $totalLimit) {
|
||||
return EntitlementResult::denied(
|
||||
reason: "You've reached your {$feature->name} limit ({$totalLimit}).",
|
||||
limit: $totalLimit,
|
||||
used: $currentUsage,
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
UsageRecord::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'feature_code' => $poolFeatureCode,
|
||||
'quantity' => $quantity,
|
||||
'user_id' => $user?->id,
|
||||
'metadata' => $metadata,
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$this->invalidateUsageCache($workspace, $poolFeatureCode);
|
||||
|
||||
return EntitlementResult::allowed(
|
||||
limit: $totalLimit,
|
||||
used: $currentUsage + $quantity,
|
||||
featureCode: $featureCode
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically check namespace entitlement and record usage in a single transaction.
|
||||
*
|
||||
* Namespace equivalent of `checkAndRecordUsage()`. Prevents race conditions
|
||||
* for namespace-scoped usage by using row-level locking within a transaction.
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to check and record for
|
||||
* @param string $featureCode The feature code to check
|
||||
* @param int $quantity The amount to consume (default: 1)
|
||||
* @param User|null $user Optional user who triggered the usage
|
||||
* @param array<string, mixed>|null $metadata Optional metadata for audit/debugging
|
||||
* @return EntitlementResult Contains allowed/denied status. If allowed, usage has already been recorded.
|
||||
*/
|
||||
public function checkAndRecordNamespaceUsage(
|
||||
Namespace_ $namespace,
|
||||
string $featureCode,
|
||||
int $quantity = 1,
|
||||
?User $user = null,
|
||||
?array $metadata = null
|
||||
): EntitlementResult {
|
||||
$feature = $this->getFeature($featureCode);
|
||||
|
||||
if (! $feature) {
|
||||
return EntitlementResult::denied(
|
||||
reason: "Feature '{$featureCode}' does not exist.",
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
$poolFeatureCode = $feature->getPoolFeatureCode();
|
||||
|
||||
// Resolve the effective limit using the namespace cascade
|
||||
$totalLimit = $this->getNamespaceTotalLimit($namespace, $poolFeatureCode);
|
||||
|
||||
if ($totalLimit === null && $namespace->workspace_id) {
|
||||
$workspace = $namespace->workspace;
|
||||
if ($workspace) {
|
||||
$totalLimit = $this->getTotalLimit($workspace, $poolFeatureCode);
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalLimit === null) {
|
||||
return EntitlementResult::denied(
|
||||
reason: "Your plan does not include {$feature->name}.",
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
if ($totalLimit === -1) {
|
||||
$this->recordNamespaceUsage($namespace, $featureCode, $quantity, $user, $metadata);
|
||||
|
||||
return EntitlementResult::unlimited($featureCode);
|
||||
}
|
||||
|
||||
if ($feature->isBoolean()) {
|
||||
return EntitlementResult::allowed(featureCode: $featureCode);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($namespace, $feature, $poolFeatureCode, $totalLimit, $quantity, $user, $metadata, $featureCode) {
|
||||
$currentUsage = $this->getLockedNamespaceCurrentUsage($namespace, $poolFeatureCode, $feature);
|
||||
|
||||
if ($currentUsage + $quantity > $totalLimit) {
|
||||
return EntitlementResult::denied(
|
||||
reason: "You've reached your {$feature->name} limit ({$totalLimit}).",
|
||||
limit: $totalLimit,
|
||||
used: $currentUsage,
|
||||
featureCode: $featureCode
|
||||
);
|
||||
}
|
||||
|
||||
UsageRecord::create([
|
||||
'namespace_id' => $namespace->id,
|
||||
'workspace_id' => $namespace->workspace_id,
|
||||
'feature_code' => $poolFeatureCode,
|
||||
'quantity' => $quantity,
|
||||
'user_id' => $user?->id,
|
||||
'metadata' => $metadata,
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$this->invalidateNamespaceUsageCache($namespace, $poolFeatureCode);
|
||||
|
||||
return EntitlementResult::allowed(
|
||||
limit: $totalLimit,
|
||||
used: $currentUsage + $quantity,
|
||||
featureCode: $featureCode
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a package for a workspace.
|
||||
*
|
||||
|
|
@ -599,37 +787,40 @@ class EntitlementService
|
|||
): WorkspacePackage {
|
||||
$package = Package::where('code', $packageCode)->firstOrFail();
|
||||
|
||||
// Check if this is a base package and workspace already has one
|
||||
if ($package->is_base_package) {
|
||||
$existingBase = $workspace->workspacePackages()
|
||||
->whereHas('package', fn ($q) => $q->where('is_base_package', true))
|
||||
->active()
|
||||
->first();
|
||||
$workspacePackage = DB::transaction(function () use ($workspace, $package, $options) {
|
||||
// Use lockForUpdate() to prevent concurrent provisioning from creating
|
||||
// duplicate active base packages (TOCTOU race condition).
|
||||
if ($package->is_base_package) {
|
||||
$existingBase = $workspace->workspacePackages()
|
||||
->whereHas('package', fn ($q) => $q->where('is_base_package', true))
|
||||
->active()
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($existingBase) {
|
||||
// Cancel existing base package
|
||||
$existingBase->cancel(now());
|
||||
if ($existingBase) {
|
||||
$existingBase->cancel(now());
|
||||
|
||||
EntitlementLog::logPackageAction(
|
||||
$workspace,
|
||||
EntitlementLog::ACTION_PACKAGE_CANCELLED,
|
||||
$existingBase,
|
||||
source: $options['source'] ?? EntitlementLog::SOURCE_SYSTEM,
|
||||
metadata: ['reason' => 'Replaced by new base package']
|
||||
);
|
||||
EntitlementLog::logPackageAction(
|
||||
$workspace,
|
||||
EntitlementLog::ACTION_PACKAGE_CANCELLED,
|
||||
$existingBase,
|
||||
source: $options['source'] ?? EntitlementLog::SOURCE_SYSTEM,
|
||||
metadata: ['reason' => 'Replaced by new base package']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$workspacePackage = WorkspacePackage::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'package_id' => $package->id,
|
||||
'status' => WorkspacePackage::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(),
|
||||
'blesta_service_id' => $options['blesta_service_id'] ?? null,
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
return WorkspacePackage::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'package_id' => $package->id,
|
||||
'status' => WorkspacePackage::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(),
|
||||
'blesta_service_id' => $options['blesta_service_id'] ?? null,
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
});
|
||||
|
||||
EntitlementLog::logPackageAction(
|
||||
$workspace,
|
||||
|
|
@ -723,19 +914,42 @@ class EntitlementService
|
|||
string $featureCode,
|
||||
array $options = []
|
||||
): Boost {
|
||||
$boost = Boost::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'feature_code' => $featureCode,
|
||||
'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT,
|
||||
'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND,
|
||||
'limit_value' => $options['limit_value'] ?? null,
|
||||
'consumed_quantity' => 0,
|
||||
'status' => Boost::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'blesta_addon_id' => $options['blesta_addon_id'] ?? null,
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
$boost = DB::transaction(function () use ($workspace, $featureCode, $options) {
|
||||
// Lock existing active boosts for this workspace+feature to prevent
|
||||
// duplicate provisioning from concurrent requests.
|
||||
$existingBoosts = $workspace->boosts()
|
||||
->forFeature($featureCode)
|
||||
->usable()
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
// If a blesta_addon_id is provided, check for duplicates to prevent
|
||||
// the same external addon from being provisioned twice.
|
||||
$blestaAddonId = $options['blesta_addon_id'] ?? null;
|
||||
if ($blestaAddonId !== null) {
|
||||
$duplicate = $existingBoosts->first(
|
||||
fn (Boost $b) => $b->blesta_addon_id === $blestaAddonId
|
||||
);
|
||||
|
||||
if ($duplicate) {
|
||||
return $duplicate;
|
||||
}
|
||||
}
|
||||
|
||||
return Boost::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'feature_code' => $featureCode,
|
||||
'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT,
|
||||
'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND,
|
||||
'limit_value' => $options['limit_value'] ?? null,
|
||||
'consumed_quantity' => 0,
|
||||
'status' => Boost::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'blesta_addon_id' => $blestaAddonId,
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
});
|
||||
|
||||
EntitlementLog::logBoostAction(
|
||||
$workspace,
|
||||
|
|
@ -1224,6 +1438,79 @@ class EntitlementService
|
|||
return Cache::remember($cacheKey, self::USAGE_CACHE_TTL, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current usage with a pessimistic lock for atomic check-and-record.
|
||||
*
|
||||
* This bypasses the cache and queries the database directly with `lockForUpdate()`
|
||||
* to ensure that concurrent transactions see a consistent usage count. Must be
|
||||
* called within a DB::transaction().
|
||||
*
|
||||
* @param Workspace $workspace The workspace to get locked usage for
|
||||
* @param string $featureCode The feature code to get usage for
|
||||
* @param Feature $feature The feature model (for reset configuration)
|
||||
* @return int The current usage count (under lock)
|
||||
*/
|
||||
protected function getLockedCurrentUsage(Workspace $workspace, string $featureCode, Feature $feature): int
|
||||
{
|
||||
$query = UsageRecord::where('workspace_id', $workspace->id)
|
||||
->where('feature_code', $featureCode)
|
||||
->lockForUpdate();
|
||||
|
||||
if ($feature->resetsMonthly()) {
|
||||
$primaryPackage = $workspace->workspacePackages()
|
||||
->whereHas('package', fn ($q) => $q->where('is_base_package', true))
|
||||
->active()
|
||||
->first();
|
||||
|
||||
$cycleStart = $primaryPackage
|
||||
? $primaryPackage->getCurrentCycleStart()
|
||||
: now()->startOfMonth();
|
||||
|
||||
$query->where('recorded_at', '>=', $cycleStart);
|
||||
} elseif ($feature->resetsRolling()) {
|
||||
$days = $feature->rolling_window_days ?? 30;
|
||||
$query->where('recorded_at', '>=', now()->subDays($days));
|
||||
}
|
||||
|
||||
return (int) $query->sum('quantity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current namespace usage with a pessimistic lock for atomic check-and-record.
|
||||
*
|
||||
* Namespace equivalent of `getLockedCurrentUsage()`. Must be called within
|
||||
* a DB::transaction().
|
||||
*
|
||||
* @param Namespace_ $namespace The namespace to get locked usage for
|
||||
* @param string $featureCode The feature code to get usage for
|
||||
* @param Feature $feature The feature model (for reset configuration)
|
||||
* @return int The current usage count (under lock)
|
||||
*/
|
||||
protected function getLockedNamespaceCurrentUsage(Namespace_ $namespace, string $featureCode, Feature $feature): int
|
||||
{
|
||||
$query = UsageRecord::where('namespace_id', $namespace->id)
|
||||
->where('feature_code', $featureCode)
|
||||
->lockForUpdate();
|
||||
|
||||
if ($feature->resetsMonthly()) {
|
||||
$primaryPackage = $namespace->namespacePackages()
|
||||
->whereHas('package', fn ($q) => $q->where('is_base_package', true))
|
||||
->active()
|
||||
->first();
|
||||
|
||||
$cycleStart = $primaryPackage
|
||||
? $primaryPackage->getCurrentCycleStart()
|
||||
: now()->startOfMonth();
|
||||
|
||||
$query->where('recorded_at', '>=', $cycleStart);
|
||||
} elseif ($feature->resetsRolling()) {
|
||||
$days = $feature->rolling_window_days ?? 30;
|
||||
$query->where('recorded_at', '>=', now()->subDays($days));
|
||||
}
|
||||
|
||||
return (int) $query->sum('quantity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a feature by its unique code.
|
||||
*
|
||||
|
|
@ -1747,28 +2034,31 @@ class EntitlementService
|
|||
): NamespacePackage {
|
||||
$package = Package::where('code', $packageCode)->firstOrFail();
|
||||
|
||||
// Check if this is a base package and namespace already has one
|
||||
if ($package->is_base_package) {
|
||||
$existingBase = $namespace->namespacePackages()
|
||||
->whereHas('package', fn ($q) => $q->where('is_base_package', true))
|
||||
->active()
|
||||
->first();
|
||||
$namespacePackage = DB::transaction(function () use ($namespace, $package, $options) {
|
||||
// Use lockForUpdate() to prevent concurrent provisioning from creating
|
||||
// duplicate active base packages (TOCTOU race condition).
|
||||
if ($package->is_base_package) {
|
||||
$existingBase = $namespace->namespacePackages()
|
||||
->whereHas('package', fn ($q) => $q->where('is_base_package', true))
|
||||
->active()
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($existingBase) {
|
||||
// Cancel existing base package
|
||||
$existingBase->cancel(now());
|
||||
if ($existingBase) {
|
||||
$existingBase->cancel(now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$namespacePackage = NamespacePackage::create([
|
||||
'namespace_id' => $namespace->id,
|
||||
'package_id' => $package->id,
|
||||
'status' => NamespacePackage::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(),
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
return NamespacePackage::create([
|
||||
'namespace_id' => $namespace->id,
|
||||
'package_id' => $package->id,
|
||||
'status' => NamespacePackage::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(),
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->invalidateNamespaceCache($namespace);
|
||||
|
||||
|
|
@ -1846,19 +2136,29 @@ class EntitlementService
|
|||
string $featureCode,
|
||||
array $options = []
|
||||
): Boost {
|
||||
$boost = Boost::create([
|
||||
'namespace_id' => $namespace->id,
|
||||
'workspace_id' => $namespace->workspace_id,
|
||||
'feature_code' => $featureCode,
|
||||
'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT,
|
||||
'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND,
|
||||
'limit_value' => $options['limit_value'] ?? null,
|
||||
'consumed_quantity' => 0,
|
||||
'status' => Boost::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
$boost = DB::transaction(function () use ($namespace, $featureCode, $options) {
|
||||
// Lock existing active boosts for this namespace+feature to prevent
|
||||
// duplicate provisioning from concurrent requests.
|
||||
$namespace->boosts()
|
||||
->forFeature($featureCode)
|
||||
->usable()
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
return Boost::create([
|
||||
'namespace_id' => $namespace->id,
|
||||
'workspace_id' => $namespace->workspace_id,
|
||||
'feature_code' => $featureCode,
|
||||
'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT,
|
||||
'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND,
|
||||
'limit_value' => $options['limit_value'] ?? null,
|
||||
'consumed_quantity' => 0,
|
||||
'status' => Boost::STATUS_ACTIVE,
|
||||
'starts_at' => $options['starts_at'] ?? now(),
|
||||
'expires_at' => $options['expires_at'] ?? null,
|
||||
'metadata' => $options['metadata'] ?? null,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->invalidateNamespaceCache($namespace);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue