diff --git a/.github/workflows/cross-repository-integration.yml b/.github/workflows/cross-repository-integration.yml new file mode 100644 index 0000000..f6d9d4e --- /dev/null +++ b/.github/workflows/cross-repository-integration.yml @@ -0,0 +1,253 @@ +name: Cross-repository integration + +on: + pull_request: + push: + branches: + - dynamic-slider + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dynamic-pterodactyl-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Extension quality + runs-on: ubuntu-latest + + steps: + - name: Check out Dynamic Pterodactyl + uses: actions/checkout@v7 + with: + path: dynamic-pterodactyl + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Check out companion Paymenter + uses: actions/checkout@v7 + with: + repository: ObsidianNetwork/Paymenter-Obsidian-Network + ref: 8a9e4849f131795f430869128231f7a193bb8dcc + path: paymenter + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip + tools: composer:v2 + coverage: none + + - name: Install Paymenter dependencies + working-directory: paymenter + run: composer install --no-interaction --no-ansi --prefer-dist --no-progress + + - name: Audit Composer dependencies + working-directory: paymenter + run: composer audit --locked --no-interaction + + - name: Run Pint on the complete extension branch diff + working-directory: dynamic-pterodactyl + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then + BASE_SHA="$(git rev-parse HEAD^)" + fi + mapfile -d '' files < <( + git diff --diff-filter=ACMR --name-only -z \ + "$BASE_SHA"...HEAD -- '*.php' + ) + if ((${#files[@]})); then + ../paymenter/vendor/bin/pint --test "${files[@]}" + fi + + extension-sqlite: + name: PHP ${{ matrix.php }} / SQLite + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '8.3' + - '8.4' + + steps: + - name: Check out Dynamic Pterodactyl + uses: actions/checkout@v7 + with: + path: dynamic-pterodactyl + + - name: Check out companion Paymenter + uses: actions/checkout@v7 + with: + repository: ObsidianNetwork/Paymenter-Obsidian-Network + ref: 8a9e4849f131795f430869128231f7a193bb8dcc + path: paymenter + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, pdo_sqlite, sqlite3, tokenizer, xml, zip + tools: composer:v2 + coverage: none + + - name: Install Paymenter dependencies + working-directory: paymenter + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Assemble extension checkout + run: | + mkdir -p paymenter/extensions/Others/DynamicPterodactyl + rsync -a \ + --exclude=.git \ + --exclude=.github \ + dynamic-pterodactyl/ \ + paymenter/extensions/Others/DynamicPterodactyl/ + + - name: Prepare SQLite test database + working-directory: paymenter + env: + APP_ENV: testing + CACHE_STORE: array + DB_CONNECTION: sqlite + DB_DATABASE: database/paymenter_test.sqlite + MAIL_MAILER: array + QUEUE_CONNECTION: sync + SESSION_DRIVER: array + run: | + cp .env.example .env + touch database/paymenter_test.sqlite + php artisan key:generate --force + php artisan migrate --force + php artisan migrate \ + --path=extensions/Others/DynamicPterodactyl/database/migrations \ + --force + + - name: Run Dynamic Pterodactyl tests on SQLite + working-directory: paymenter + env: + APP_ENV: testing + DB_CONNECTION: sqlite + DB_DATABASE: database/paymenter_test.sqlite + run: vendor/bin/phpunit -c extensions/Others/DynamicPterodactyl/phpunit.xml + + - name: Run Paymenter provisioning seam tests on SQLite + working-directory: paymenter + env: + APP_ENV: testing + DB_CONNECTION: sqlite + DB_DATABASE: database/paymenter_test.sqlite + run: vendor/bin/phpunit tests/Unit/PterodactylReservationIntegrationTest.php + + extension: + name: PHP ${{ matrix.php }} / MariaDB ${{ matrix.mariadb }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '8.3' + - '8.4' + mariadb: + - '11' + - '12' + + services: + mariadb: + image: mariadb:${{ matrix.mariadb }} + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: paymenter_test + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + steps: + - name: Check out Dynamic Pterodactyl + uses: actions/checkout@v7 + with: + path: dynamic-pterodactyl + + - name: Check out companion Paymenter + uses: actions/checkout@v7 + with: + repository: ObsidianNetwork/Paymenter-Obsidian-Network + ref: 8a9e4849f131795f430869128231f7a193bb8dcc + path: paymenter + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip + tools: composer:v2 + coverage: none + + - name: Install Paymenter dependencies + working-directory: paymenter + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Assemble extension checkout + run: | + mkdir -p paymenter/extensions/Others/DynamicPterodactyl + rsync -a \ + --exclude=.git \ + --exclude=.github \ + dynamic-pterodactyl/ \ + paymenter/extensions/Others/DynamicPterodactyl/ + + - name: Prepare test database + working-directory: paymenter + env: + APP_ENV: testing + CACHE_STORE: array + DB_CONNECTION: mariadb + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: paymenter_test + DB_USERNAME: root + DB_PASSWORD: password + MAIL_MAILER: array + QUEUE_CONNECTION: sync + SESSION_DRIVER: array + run: | + cp .env.example .env + php artisan key:generate --force + php artisan migrate --force + php artisan migrate \ + --path=extensions/Others/DynamicPterodactyl/database/migrations \ + --force + + - name: Run Dynamic Pterodactyl tests + working-directory: paymenter + env: + APP_ENV: testing + DB_CONNECTION: mariadb + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: paymenter_test + DB_USERNAME: root + DB_PASSWORD: password + run: vendor/bin/phpunit -c extensions/Others/DynamicPterodactyl/phpunit.xml + + - name: Run Paymenter provisioning seam tests + working-directory: paymenter + env: + APP_ENV: testing + DB_CONNECTION: mariadb + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: paymenter_test + DB_USERNAME: root + DB_PASSWORD: password + run: vendor/bin/phpunit tests/Unit/PterodactylReservationIntegrationTest.php diff --git a/01-DATABASE.md b/01-DATABASE.md index f452f36..2aad76b 100644 --- a/01-DATABASE.md +++ b/01-DATABASE.md @@ -1,579 +1,74 @@ # Database Schema -> **Related docs**: [02-SERVICES.md](02-SERVICES.md) (services that use these tables) - ---- - -## Tables Overview - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ ptero_resource_reservations │ -├─────────────────────────────────────────────────────────────────┤ -│ Temporary holds on resources during checkout (15-min TTL) │ -│ Links cart items → reserved resources → specific nodes │ -└─────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────┐ -│ ptero_pricing_configs │ -├─────────────────────────────────────────────────────────────────┤ -│ Per-product pricing configuration │ -│ Slider limits, step sizes, pricing model parameters │ -└─────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────┐ -│ ptero_audit_logs │ -├─────────────────────────────────────────────────────────────────┤ -│ Tracks configuration changes for accountability │ -│ Who changed what, when, and previous values │ -└─────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────┐ -│ ptero_alert_configs │ -├─────────────────────────────────────────────────────────────────┤ -│ Alert thresholds and notification preferences │ -│ Per-location capacity warnings │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Migration 1: Resource Reservations - -**File**: `2025_01_01_000001_create_ptero_resource_reservations_table.php` - -```php -id(); - - // Unique token for tracking reservation - $table->string('token', 64)->unique(); - - // Link to cart (nullable - cleared after checkout) - $table->unsignedBigInteger('cart_item_id')->nullable(); - - // Link to service (set after provisioning) - $table->unsignedBigInteger('service_id')->nullable(); - - // Link to user for tracking - $table->unsignedBigInteger('user_id')->nullable(); - - // Pterodactyl references - $table->unsignedInteger('node_id'); - $table->unsignedInteger('location_id'); - - // Reserved resources (all in MB except CPU) - $table->unsignedInteger('memory'); // MB - $table->unsignedBigInteger('disk'); // MB - $table->unsignedInteger('cpu'); // Percentage (100 = 1 core) - - // Pricing snapshot at reservation time - $table->decimal('calculated_price', 10, 2); - $table->json('pricing_breakdown'); - - // Status tracking - $table->enum('status', [ - 'pending', // Cart item exists, awaiting payment - 'confirmed', // Payment received, server created - 'expired', // TTL exceeded without payment - 'cancelled' // User removed from cart - ])->default('pending'); - - // Admin notes - $table->text('admin_notes')->nullable(); - - // Timestamps - $table->timestamp('expires_at'); - $table->timestamps(); - - // Indexes for efficient queries - $table->index(['node_id', 'status', 'expires_at'], 'idx_node_pending'); - $table->index(['cart_item_id']); - $table->index(['status', 'expires_at'], 'idx_cleanup'); - $table->index(['location_id', 'status']); - $table->index(['user_id', 'status']); - $table->index(['created_at']); - - // Foreign keys - $table->foreign('cart_item_id') - ->references('id') - ->on('cart_items') - ->onDelete('set null'); - - $table->foreign('service_id') - ->references('id') - ->on('services') - ->onDelete('set null'); - - $table->foreign('user_id') - ->references('id') - ->on('users') - ->onDelete('set null'); - }); - } - - public function down(): void - { - Schema::dropIfExists('ptero_resource_reservations'); - } -}; -``` - -### Reservation Status Flow - -``` -┌─────────┐ cart item ┌─────────┐ payment ┌───────────┐ -│ (none) │ ──────────────▶ │ pending │ ────────────▶ │ confirmed │ -└─────────┘ └─────────┘ └───────────┘ - │ - ┌────────────┴────────────┐ - ▼ ▼ - ┌─────────┐ ┌───────────┐ - │ expired │ │ cancelled │ - │ (TTL) │ │ (user) │ - └─────────┘ └───────────┘ -``` - -Reservation lifecycle: `pending → confirmed | expired | cancelled`. - ---- - -## Migration 2: Pricing Configs - -**File**: `2025_01_01_000002_create_ptero_pricing_configs_table.php` - -```php -id(); - - // Link to Paymenter product - $table->unsignedBigInteger('product_id')->unique(); - - // Pricing model selection - $table->enum('pricing_model', [ - 'linear', - 'tiered', - 'base_addon' - ])->default('linear'); - - // JSON configuration for pricing model - // Structure varies by model - see 07-PRICING-MODELS.md - $table->json('pricing_config'); - - // Memory slider configuration (stored in MB) - $table->unsignedInteger('min_memory')->default(1024); // 1GB - $table->unsignedInteger('max_memory')->default(65536); // 64GB - $table->unsignedInteger('memory_step')->default(1024); // 1GB steps - $table->unsignedInteger('default_memory')->default(4096); // 4GB default - - // CPU slider configuration (percentage: 100 = 1 core) - $table->unsignedInteger('min_cpu')->default(100); // 1 core - $table->unsignedInteger('max_cpu')->default(800); // 8 cores - $table->unsignedInteger('cpu_step')->default(100); // 1 core steps - $table->unsignedInteger('default_cpu')->default(200); // 2 cores default - - // Disk slider configuration (stored in MB) - $table->unsignedInteger('min_disk')->default(10240); // 10GB - $table->unsignedInteger('max_disk')->default(512000); // 500GB - $table->unsignedInteger('disk_step')->default(10240); // 10GB steps - $table->unsignedInteger('default_disk')->default(51200); // 50GB default - - // Feature toggles - $table->boolean('enable_memory_slider')->default(true); - $table->boolean('enable_cpu_slider')->default(true); - $table->boolean('enable_disk_slider')->default(true); - $table->boolean('is_active')->default(true); - - // Customer display customization (labels, tooltips) - $table->json('display_config')->nullable(); - - // Location restrictions (null = all locations allowed) - $table->json('allowed_locations')->nullable(); - - $table->timestamps(); - - // Foreign key - $table->foreign('product_id') - ->references('id') - ->on('products') - ->onDelete('cascade'); - }); - } - - public function down(): void - { - Schema::dropIfExists('ptero_pricing_configs'); - } -}; -``` - -### Display Config JSON Structure - -```json -{ - "memory_label": "RAM", - "cpu_label": "CPU Cores", - "disk_label": "Storage", - "memory_tooltip": "RAM determines how much data your server can hold...", - "cpu_tooltip": "CPU cores determine processing power...", - "disk_tooltip": "Storage space for your server files...", - "price_format": "monthly", - "show_breakdown": true, - "show_savings_badge": true -} -``` - ---- - -## Migration 3: Audit Logs - -**File**: `2025_01_01_000003_create_ptero_audit_logs_table.php` - -```php -id(); - - // Who made the change - $table->unsignedBigInteger('user_id'); - $table->string('user_name'); - $table->string('user_email'); - - // What was changed - $table->string('action'); // created, updated, deleted, cancelled - $table->string('entity_type'); // pricing_config, reservation, alert_config - $table->unsignedBigInteger('entity_id'); - $table->string('entity_name')->nullable(); // Product name, etc. - - // Change details - $table->json('old_values')->nullable(); - $table->json('new_values')->nullable(); - $table->text('description')->nullable(); - - // Request context - $table->string('ip_address', 45)->nullable(); - $table->string('user_agent')->nullable(); - - $table->timestamp('created_at'); - - // Indexes - $table->index(['entity_type', 'entity_id']); - $table->index(['user_id']); - $table->index(['created_at']); - $table->index(['action']); - - // Foreign key - $table->foreign('user_id') - ->references('id') - ->on('users') - ->onDelete('cascade'); - }); - } - - public function down(): void - { - Schema::dropIfExists('ptero_audit_logs'); - } -}; -``` - ---- - -## Migration 4: Alert Configs - -**File**: `2025_01_01_000004_create_ptero_alert_configs_table.php` - -```php -id(); - - // Scope: global (null) or per-location - $table->unsignedInteger('location_id')->nullable(); - $table->string('location_name')->nullable(); - - // Capacity thresholds (percentage) - $table->unsignedTinyInteger('memory_warning_threshold')->default(80); - $table->unsignedTinyInteger('memory_critical_threshold')->default(95); - $table->unsignedTinyInteger('disk_warning_threshold')->default(80); - $table->unsignedTinyInteger('disk_critical_threshold')->default(95); - - // Notification settings - $table->boolean('email_notifications')->default(true); - $table->json('notification_emails')->nullable(); // Array of emails - $table->boolean('webhook_notifications')->default(false); - $table->string('webhook_url')->nullable(); - - // Cooldown to prevent spam - $table->unsignedInteger('cooldown_minutes')->default(60); - $table->timestamp('last_notification_at')->nullable(); - - // Status - $table->boolean('is_active')->default(true); - - $table->timestamps(); - - // Indexes - $table->index(['location_id']); - $table->index(['is_active']); - }); - } - - public function down(): void - { - Schema::dropIfExists('ptero_alert_configs'); - } -}; -``` - ---- - -## Eloquent Models - -### PricingConfig Model - -```php - 'array', - 'display_config' => 'array', - 'allowed_locations' => 'array', - 'enable_memory_slider' => 'boolean', - 'enable_cpu_slider' => 'boolean', - 'enable_disk_slider' => 'boolean', - 'is_active' => 'boolean', - ]; - - public function product(): BelongsTo - { - return $this->belongsTo(\App\Models\Product::class); - } -} -``` - -### ResourceReservation Model - -```php - 'array', - 'expires_at' => 'datetime', - 'calculated_price' => 'decimal:2', - ]; - - public function user(): BelongsTo - { - return $this->belongsTo(\App\Models\User::class); - } - - public function service(): BelongsTo - { - return $this->belongsTo(\App\Models\Service::class); - } - - public function scopePending($query) - { - return $query->where('status', 'pending') - ->where('expires_at', '>', now()); - } - - public function scopeExpired($query) - { - return $query->where('status', 'pending') - ->where('expires_at', '<=', now()); - } -} -``` - -### AuditLog Model - -```php - 'array', - 'new_values' => 'array', - 'created_at' => 'datetime', - ]; -} +The extension owns seven live tables: + +| Table | Purpose | +|---|---| +| `ptero_resource_reservations` | Authoritative cart-to-provisioning capacity holds | +| `ptero_reservation_allocations` | Exact primary and additional Pterodactyl allocation claims | +| `ptero_node_capacity_policies` | Authoritative physical CPU and overcommit policy per panel/node | +| `ptero_capacity_scopes` | Database rows used to serialize stock and policy mutations per panel/location | +| `ptero_audit_logs` | Extension action history | +| `ptero_alert_configs` | Per-location alert thresholds | +| `ptero_alert_delivery_log` | Alert delivery outcomes | + +`ptero_pricing_configs` is retired. Paymenter `config_options.metadata` is the pricing source of truth. + +## Resource reservations + +The base table is created by `2025_01_01_000001_create_ptero_resource_reservations_table.php`. The server-owned checkout identity is added by `2026_07_25_000001_add_checkout_identity_to_ptero_resource_reservations.php`. + +### Identity + +| Column | Meaning | +|---|---| +| `cart_item_id`, `cart_id` | Original cart identity; the item FK becomes null when checkout clears the cart | +| `server_extension_id`, `panel_identity` | Paymenter provisioner identity and SHA-256 of its normalized panel URL | +| `service_id`, `user_id` | Assigned atomically at checkout/login | +| `product_id`, `plan_id`, `quantity`, `currency_code` | Purchase identity | +| `configuration_payload` | Canonical immutable checkout snapshot, including customer, cart, server extension, hashed panel identity, node, and selected options | +| `configuration_fingerprint` | SHA-256 of the canonical payload | +| `pricing_version`, `formula_version`, `calculated_price` | Pricing provenance | +| `node_id`, `location_id`, `memory`, `cpu`, `disk` | Reserved placement and authoritative limits | +| `purpose`, `service_upgrade_id`, `reserved_*` | Checkout or fixed-node upgrade identity and the positive upgrade delta | +| `guaranteed_until`, `paid_committed_at` | Seven-day invoice deadline and non-expiring post-payment commitment | +| `external_server_*`, `external_user_id`, `nest_id`, `egg_id` | Durable Pterodactyl lifecycle identity | + +The opaque `token` remains for internal/admin lookup of legacy rows. It is not a customer bearer credential and is never placed in browser, URL, cart, or service state. + +### Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> pending: cart create or edit + pending --> pending: login or seven-day checkout bind + pending --> paid_committed: invoice payment + paid_committed --> confirmed: exact Pterodactyl state verified + pending --> expired: hold deadline passes + pending --> cancelled: cart removal or replacement + confirmed --> cancelled: external absence verified ``` -### AlertConfig Model +`provisioning_started_at` and the unguessable `provisioning_lease_id` +prevent concurrent or stale workers from consuming the same commitment. +`consumed_at` records verified Pterodactyl creation or upgrade. A failed +attempt clears only its matching lease and records +`last_provisioning_error`; paid capacity remains committed for retry and +operator reconciliation. -```php - 'array', - 'email_notifications' => 'boolean', - 'webhook_notifications' => 'boolean', - 'is_active' => 'boolean', - 'last_notification_at' => 'datetime', - ]; - - public function scopeGlobal($query) - { - return $query->whereNull('location_id'); - } - - public function scopeForLocation($query, int $locationId) - { - return $query->where('location_id', $locationId); - } -} -``` - ---- - -## Index Strategy - -| Table | Index | Purpose | -|-------|-------|---------| -| reservations | `idx_node_pending` | Fast lookup of pending reservations by node | -| reservations | `idx_cleanup` | Efficient expired reservation cleanup | -| reservations | `location_id, status` | Location availability calculations | -| pricing_configs | `product_id` (unique) | One config per product | -| audit_logs | `entity_type, entity_id` | View history for specific entity | -| audit_logs | `created_at` | Time-based filtering | -| alert_configs | `location_id` | Per-location alert lookup | - ---- - -## Data Relationships - -``` -products (Paymenter) - │ - └──< ptero_pricing_configs (1:1) - -users (Paymenter) - │ - ├──< ptero_resource_reservations (1:many) - └──< ptero_audit_logs (1:many) - -cart_items (Paymenter) - │ - └──< ptero_resource_reservations (1:1 while in cart) - -services (Paymenter) - │ - └──< ptero_resource_reservations (1:1 after confirmation) -``` +- Memory and disk: MiB-compatible integer values used by Pterodactyl. +- CPU: Pterodactyl percentage (`100` = one logical core). Effective node stock + is `physical percentage × overcommit basis points / 10,000`. +- Money: decimal snapshot in the cart currency. -## Removed / deprecated +## Rollback -- `released` removed in dp-07 (2026-04-22). No service ever set this state. Use `provision_failed` if a post-confirm failure state is needed. +The key-normalization migration is intentionally irreversible: restoring uppercase `MEMORY`, `CPU`, `DISK`, or `LOCATION` keys would make the built-in provisioner ignore slider selections. diff --git a/02-SERVICES.md b/02-SERVICES.md index b541f22..700bebf 100644 --- a/02-SERVICES.md +++ b/02-SERVICES.md @@ -1,924 +1,73 @@ # Core Services -> **Related docs**: [01-DATABASE.md](01-DATABASE.md) (tables used), [07-PRICING-MODELS.md](07-PRICING-MODELS.md) (pricing details), [08-ALGORITHMS.md](08-ALGORITHMS.md) (node selection) - ---- - -## Service Overview - | Service | Responsibility | -|---------|----------------| -| `ResourceCalculationService` | Real-time Pterodactyl API queries | -| `NodeSelectionService` | Best-fit node allocation algorithm | -| `ReservationService` | Resource holds during checkout | -| `SliderConfigReaderService` | Reads dynamic-slider config payloads for API/frontend use | -| `AuditLogService` | Track admin actions | -| `AlertService` | Capacity notifications | - ---- - -## ResourceCalculationService - -Implements **real-time API approach** (no caching) proven by PteroSync. - -```php -getExtensionConfig(); - $this->apiUrl = rtrim($config['pterodactyl_url'], '/'); - $this->apiKey = $config['pterodactyl_api_key']; - } - - /** - * Get available resources for a location (real-time from Pterodactyl API) - */ - public function getLocationAvailability(int $locationId): array - { - $nodes = $this->fetchNodesInLocation($locationId); - - $locationData = [ - 'location_id' => $locationId, - 'nodes' => [], - 'max_available' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - 'total_capacity' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - 'total_allocated' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - ]; - - foreach ($nodes as $node) { - $nodeAvailability = $this->calculateNodeAvailability($node); - $locationData['nodes'][] = $nodeAvailability; - - // Track maximum available across all nodes - $locationData['max_available']['memory'] = max( - $locationData['max_available']['memory'], - $nodeAvailability['available']['memory'] - ); - $locationData['max_available']['cpu'] = max( - $locationData['max_available']['cpu'], - $nodeAvailability['available']['cpu'] - ); - $locationData['max_available']['disk'] = max( - $locationData['max_available']['disk'], - $nodeAvailability['available']['disk'] - ); - - // Aggregate totals - $locationData['total_capacity']['memory'] += $nodeAvailability['total']['memory']; - $locationData['total_capacity']['cpu'] += $nodeAvailability['total']['cpu']; - $locationData['total_capacity']['disk'] += $nodeAvailability['total']['disk']; - - $locationData['total_allocated']['memory'] += $nodeAvailability['allocated']['memory']; - $locationData['total_allocated']['cpu'] += $nodeAvailability['allocated']['cpu']; - $locationData['total_allocated']['disk'] += $nodeAvailability['allocated']['disk']; - } - - return $locationData; - } - - /** - * Calculate available resources for a specific node - */ - public function calculateNodeAvailability(array $node): array - { - $servers = $this->fetchServersOnNode($node['id']); - - // Sum allocated resources from all servers - $allocated = ['memory' => 0, 'cpu' => 0, 'disk' => 0]; - foreach ($servers as $server) { - $allocated['memory'] += $server['limits']['memory'] ?? 0; - $allocated['cpu'] += $server['limits']['cpu'] ?? 0; - $allocated['disk'] += $server['limits']['disk'] ?? 0; - } - - // Get pending reservations for this node - $pendingReservations = $this->getPendingReservations($node['id']); - - // Calculate effective totals with overallocation - // Formula: total * (1 + overallocate_percentage / 100) - $effectiveMemory = $node['memory'] * (1 + ($node['memory_overallocate'] ?? 0) / 100); - $effectiveDisk = $node['disk'] * (1 + ($node['disk_overallocate'] ?? 0) / 100); - $effectiveCpu = ($node['cpu_threads'] ?? 4) * 100; // No overallocation for CPU - - // Calculate available (total - allocated - pending) - $available = [ - 'memory' => max(0, (int)$effectiveMemory - $allocated['memory'] - $pendingReservations['memory']), - 'cpu' => max(0, (int)$effectiveCpu - $allocated['cpu'] - $pendingReservations['cpu']), - 'disk' => max(0, (int)$effectiveDisk - $allocated['disk'] - $pendingReservations['disk']), - ]; - - return [ - 'node_id' => $node['id'], - 'name' => $node['name'], - 'fqdn' => $node['fqdn'], - 'maintenance_mode' => $node['maintenance_mode'] ?? false, - 'total' => [ - 'memory' => (int)$effectiveMemory, - 'cpu' => (int)$effectiveCpu, - 'disk' => (int)$effectiveDisk, - ], - 'allocated' => $allocated, - 'reserved' => $pendingReservations, - 'available' => $available, - 'server_count' => count($servers), - 'utilization' => [ - 'memory' => $effectiveMemory > 0 - ? round(($allocated['memory'] + $pendingReservations['memory']) / $effectiveMemory * 100, 1) - : 100, - 'disk' => $effectiveDisk > 0 - ? round(($allocated['disk'] + $pendingReservations['disk']) / $effectiveDisk * 100, 1) - : 100, - ], - ]; - } - - /** - * Test API connection - */ - public function testConnection(): array - { - try { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $this->apiKey, - 'Accept' => 'application/json', - ])->timeout(10)->get("{$this->apiUrl}/api/application/nodes"); - - if ($response->successful()) { - $data = $response->json(); - return [ - 'success' => true, - 'message' => 'Connection successful', - 'node_count' => count($data['data'] ?? []), - 'panel_version' => $response->header('X-Pterodactyl-Version'), - ]; - } - - return [ - 'success' => false, - 'message' => 'API returned error: ' . $response->status(), - 'details' => $response->json('errors', []), - ]; - } catch (\Exception $e) { - return [ - 'success' => false, - 'message' => 'Connection failed: ' . $e->getMessage(), - ]; - } - } - - /** - * Verify resources are still available (called at payment time) - */ - public function verifyAvailability(int $nodeId, array $requirements): bool - { - $nodes = $this->fetchNodesInLocation($this->getNodeLocation($nodeId)); - $node = collect($nodes)->firstWhere('id', $nodeId); - - if (!$node) return false; - - $availability = $this->calculateNodeAvailability($node); - - return $availability['available']['memory'] >= $requirements['memory'] - && $availability['available']['cpu'] >= $requirements['cpu'] - && $availability['available']['disk'] >= $requirements['disk']; - } - - /** - * Get all locations from Pterodactyl - */ - public function getLocations(): array - { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $this->apiKey, - 'Accept' => 'application/json', - ])->get("{$this->apiUrl}/api/application/locations", [ - 'per_page' => 100, - ]); - - if (!$response->successful()) { - throw new \RuntimeException('Failed to fetch locations from Pterodactyl'); - } - - return collect($response->json('data', [])) - ->map(fn($loc) => [ - 'id' => $loc['attributes']['id'], - 'short' => $loc['attributes']['short'], - 'long' => $loc['attributes']['long'], - ]) - ->toArray(); - } - - // --- Private Methods --- - - private function fetchNodesInLocation(int $locationId): array - { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $this->apiKey, - 'Accept' => 'application/json', - 'Content-Type' => 'application/json', - ])->get("{$this->apiUrl}/api/application/nodes", [ - 'filter[location_id]' => $locationId, - 'per_page' => 100, - ]); - - if (!$response->successful()) { - throw new \RuntimeException('Failed to fetch nodes: ' . $response->body()); - } - - return collect($response->json('data', [])) - ->map(fn($node) => $node['attributes']) - ->toArray(); - } - - private function fetchServersOnNode(int $nodeId): array - { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $this->apiKey, - 'Accept' => 'application/json', - ])->get("{$this->apiUrl}/api/application/nodes/{$nodeId}", [ - 'include' => 'servers', - ]); - - if (!$response->successful()) { - throw new \RuntimeException('Failed to fetch node details'); - } - - return collect($response->json('attributes.relationships.servers.data', [])) - ->map(fn($server) => $server['attributes']) - ->toArray(); - } - - private function getPendingReservations(int $nodeId): array - { - $result = DB::table('ptero_resource_reservations') - ->where('node_id', $nodeId) - ->where('status', 'pending') - ->where('expires_at', '>', now()) - ->selectRaw('COALESCE(SUM(memory), 0) as memory') - ->selectRaw('COALESCE(SUM(cpu), 0) as cpu') - ->selectRaw('COALESCE(SUM(disk), 0) as disk') - ->first(); - - return [ - 'memory' => (int)$result->memory, - 'cpu' => (int)$result->cpu, - 'disk' => (int)$result->disk, - ]; - } - - private function getNodeLocation(int $nodeId): int - { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $this->apiKey, - 'Accept' => 'application/json', - ])->get("{$this->apiUrl}/api/application/nodes/{$nodeId}"); - - return $response->json('attributes.location_id'); - } - - private function getExtensionConfig(): array - { - return \App\Helpers\ExtensionHelper::getConfig('Others', 'DynamicPterodactyl'); - } -} -``` - ---- - -## NodeSelectionService - -Implements **best-fit algorithm with headroom weighting**. See [08-ALGORITHMS.md](08-ALGORITHMS.md) for detailed explanation. - -```php -resourceService = $resourceService; - } - - /** - * Select the best node for given resource requirements - * - * Algorithm: Best-fit with headroom weighting - * - Memory: 50% weight (most commonly upgraded) - * - Disk: 35% weight (harder to migrate) - * - CPU: 15% weight (often unlimited/shared) - */ - public function selectBestNode(int $locationId, array $requirements): ?array - { - $locationData = $this->resourceService->getLocationAvailability($locationId); - - $candidates = []; - - foreach ($locationData['nodes'] as $node) { - // Skip nodes in maintenance mode - if ($node['maintenance_mode'] ?? false) continue; - - // Check if node can accommodate requirements - if ($node['available']['memory'] < $requirements['memory']) continue; - if ($node['available']['cpu'] < $requirements['cpu']) continue; - if ($node['available']['disk'] < $requirements['disk']) continue; - - // Calculate remaining headroom after allocation - $remainingMemory = $node['available']['memory'] - $requirements['memory']; - $remainingCpu = $node['available']['cpu'] - $requirements['cpu']; - $remainingDisk = $node['available']['disk'] - $requirements['disk']; - - // Weighted score: prioritize memory headroom, then disk, then CPU - $memoryScore = ($remainingMemory / max(1, $node['total']['memory'])) * 0.50; - $diskScore = ($remainingDisk / max(1, $node['total']['disk'])) * 0.35; - $cpuScore = ($remainingCpu / max(1, $node['total']['cpu'])) * 0.15; - - $score = $memoryScore + $diskScore + $cpuScore; - - $candidates[] = [ - 'node' => $node, - 'score' => $score, - 'remaining' => [ - 'memory' => $remainingMemory, - 'cpu' => $remainingCpu, - 'disk' => $remainingDisk, - ], - ]; - } - - if (empty($candidates)) { - return null; - } - - // Sort by score descending, return highest - usort($candidates, fn($a, $b) => $b['score'] <=> $a['score']); - - return $candidates[0]['node']; - } - - /** - * Get maximum allocatable resources across a location - */ - public function getMaxAvailable(int $locationId): array - { - $locationData = $this->resourceService->getLocationAvailability($locationId); - return $locationData['max_available']; - } -} -``` - ---- - -## SliderConfigReaderService - -This service is now intentionally thin: it reads `dynamic_slider` ConfigOption metadata and returns the slider/config payload used by the extension API. Pricing math no longer lives in the extension. - -`POST /pricing/calculate` delegates directly to Paymenter core via `Plan::dynamicSliderBasePrice()` and `ConfigOption::calculateDynamicPriceDelta()` inside `PricingController`. - ---- +|---|---| +| `ReservationConfigurationService` | Build canonical checkout payloads, fingerprints, and service-match proofs | +| `ReservationService` | Own the hold from cart creation through provisioning | +| `PterodactylInventoryService` | Read paginated Pterodactyl 1.12.3+ node, server, location, and allocation inventory | +| `ResourceCalculationService` | Overlay live RAM/disk/CPU/port inventory with local commitments | +| `NodeSelectionService` | Select one public, non-maintenance node that fits the complete vector | +| `ConfigOptionSetupService` | Transactionally create normalized native slider options | +| `ResourceQuoteService` | Compute customer-safe, complete-vector live bounds | +| `UpgradeReservationService` | Quote, reserve, and fulfill fixed-node resource upgrades | +| `AllocationSelectionService` | Deterministically select the exact primary/additional ports used by quotes and reservations | +| `LegacyReservationReadinessService` | Fail extension migration over unresolved legacy lifecycle identity without inferring values | +| `AlertService` | Capacity and shortfall notifications | +| `AuditLogService` | Best-effort extension audit writes | + +## ReservationConfigurationService + +`forCartItem()` rejects partial configurations and quantity greater than one, resolves the selected Pterodactyl location, and records: + +- cart, product, plan, quantity, and currency; +- memory, CPU limit, disk, and location; +- all selected config-option values and metadata; +- the exact calculated cart price; +- pricing and formula versions. + +Every RAM, CPU, and disk value comes from either its validated slider selection or the product's static Pterodactyl setting; a missing value fails closed. After node selection, `withNode()` adds the node and `fingerprint()` hashes the canonical JSON. Guest login uses `withCustomer()` while holding the cart and reservation locks, then stores the replacement fingerprint in the same transaction. `assertServiceMatches()` proves the created service still has the same customer, purchase, and resource values before provisioning. ## ReservationService -Handles temporary resource holds with **pessimistic locking** to prevent overselling. - -```php -nodeService = $nodeService; - $this->auditService = $auditService; - $this->ttlMinutes = config('dynamic-pterodactyl.reservation_ttl', 15); - } - - /** - * Create a resource reservation - * - * Uses database transaction with pessimistic locking - * Retries up to 5 times on deadlock - */ - public function create( - int $productId, - int $locationId, - array $resources, - ?int $cartItemId = null, - ?int $userId = null - ): array { - return DB::transaction(function () use ($productId, $locationId, $resources, $cartItemId, $userId) { - // Lock pending reservations for this location - DB::table('ptero_resource_reservations') - ->where('location_id', $locationId) - ->where('status', 'pending') - ->lockForUpdate() - ->get(); - - // Find best node - $node = $this->nodeService->selectBestNode($locationId, $resources); - - if (!$node) { - throw new \RuntimeException('No node with sufficient resources available'); - } - - // Create reservation - // Create reservation - $token = Str::random(64); - $expiresAt = now()->addMinutes($this->ttlMinutes); - - $id = DB::table('ptero_resource_reservations')->insertGetId([ - 'token' => $token, - 'cart_item_id' => $cartItemId, - 'user_id' => $userId, - 'node_id' => $node['node_id'], - 'location_id' => $locationId, - 'memory' => $resources['memory'], - 'cpu' => $resources['cpu'], - 'disk' => $resources['disk'], - 'calculated_price' => 0, - 'pricing_breakdown' => json_encode([]), - 'status' => 'pending', - 'expires_at' => $expiresAt, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return [ - 'id' => $id, - 'token' => $token, - 'node_id' => $node['node_id'], - 'node_name' => $node['name'], - 'expires_at' => $expiresAt->toIso8601String(), - 'ttl_minutes' => $this->ttlMinutes, - 'pricing' => $pricing, - ]; - }, 5); // 5 retry attempts for deadlock - } - - /** - * Confirm a reservation (after successful payment) - */ - public function confirm(string $token, int $serviceId): bool - { - return DB::table('ptero_resource_reservations') - ->where('token', $token) - ->where('status', 'pending') - ->update([ - 'status' => 'confirmed', - 'service_id' => $serviceId, - 'updated_at' => now(), - ]) > 0; - } - - /** - * Cancel a reservation - */ - public function cancel(string $token, ?string $reason = null, bool $isAdminAction = false): bool - { - $reservation = $this->getByToken($token); - - if (!$reservation) return false; - - $result = DB::table('ptero_resource_reservations') - ->where('token', $token) - ->where('status', 'pending') - ->update([ - 'status' => 'cancelled', - 'admin_notes' => $reason, - 'updated_at' => now(), - ]) > 0; - - if ($result && $isAdminAction) { - $this->auditService->log('cancelled', 'reservation', $reservation->id, [ - 'reason' => $reason, - 'resources' => [ - 'memory' => $reservation->memory, - 'cpu' => $reservation->cpu, - 'disk' => $reservation->disk, - ], - ]); - } - - return $result; - } - - /** - * Extend reservation TTL - */ - public function extend(string $token, int $additionalMinutes = 15): bool - { - return DB::table('ptero_resource_reservations') - ->where('token', $token) - ->where('status', 'pending') - ->update([ - 'expires_at' => DB::raw("DATE_ADD(expires_at, INTERVAL {$additionalMinutes} MINUTE)"), - 'updated_at' => now(), - ]) > 0; - } - - /** - * Get reservation by token - */ - public function getByToken(string $token): ?object - { - return DB::table('ptero_resource_reservations') - ->where('token', $token) - ->first(); - } - - /** - * Get reservation by cart item - */ - public function getByCartItem(int $cartItemId): ?object - { - return DB::table('ptero_resource_reservations') - ->where('cart_item_id', $cartItemId) - ->where('status', 'pending') - ->first(); - } - - /** - * Get all reservations with filters (for admin) - */ - public function getAll(array $filters = []): \Illuminate\Support\Collection - { - $query = DB::table('ptero_resource_reservations') - ->leftJoin('users', 'ptero_resource_reservations.user_id', '=', 'users.id') - ->select([ - 'ptero_resource_reservations.*', - 'users.name as user_name', - 'users.email as user_email', - ]); - - if (!empty($filters['status'])) { - $query->where('status', $filters['status']); - } - if (!empty($filters['location_id'])) { - $query->where('location_id', $filters['location_id']); - } - if (!empty($filters['node_id'])) { - $query->where('node_id', $filters['node_id']); - } - if (!empty($filters['user_id'])) { - $query->where('ptero_resource_reservations.user_id', $filters['user_id']); - } - - return $query->orderBy('created_at', 'desc')->get(); - } - - /** - * Get reservation statistics - */ - public function getStatistics(string $period = '30d'): array - { - $startDate = match($period) { - '24h' => now()->subDay(), - '7d' => now()->subDays(7), - '30d' => now()->subDays(30), - '90d' => now()->subDays(90), - default => now()->subDays(30), - }; - - $stats = DB::table('ptero_resource_reservations') - ->where('created_at', '>=', $startDate) - ->selectRaw('status, COUNT(*) as count') - ->groupBy('status') - ->pluck('count', 'status') - ->toArray(); - - $revenue = DB::table('ptero_resource_reservations') - ->where('created_at', '>=', $startDate) - ->where('status', 'confirmed') - ->sum('calculated_price'); - - $avgResources = DB::table('ptero_resource_reservations') - ->where('created_at', '>=', $startDate) - ->where('status', 'confirmed') - ->selectRaw('AVG(memory) as avg_memory, AVG(cpu) as avg_cpu, AVG(disk) as avg_disk') - ->first(); - - $total = array_sum($stats); - $confirmed = $stats['confirmed'] ?? 0; - $expired = $stats['expired'] ?? 0; - $cancelled = $stats['cancelled'] ?? 0; - - return [ - 'period' => $period, - 'total' => $total, - 'by_status' => $stats, - 'confirmed_revenue' => $revenue, - 'conversion_rate' => ($confirmed + $expired + $cancelled) > 0 - ? round($confirmed / ($confirmed + $expired + $cancelled) * 100, 1) - : 0, - 'average_resources' => [ - 'memory' => round($avgResources->avg_memory ?? 0), - 'cpu' => round($avgResources->avg_cpu ?? 0), - 'disk' => round($avgResources->avg_disk ?? 0), - ], - ]; - } - - /** - * Cleanup expired reservations (called by scheduled job) - */ - public function cleanupExpired(): int - { - return DB::table('ptero_resource_reservations') - ->where('status', 'pending') - ->where('expires_at', '<', now()) - ->update([ - 'status' => 'expired', - 'updated_at' => now(), - ]); - } -} -``` - ---- - -## AuditLogService +The primary methods are: -```php -insertGetId([ - 'user_id' => $user?->id ?? 0, - 'user_name' => $user?->name ?? 'System', - 'user_email' => $user?->email ?? 'system@localhost', - 'action' => $action, - 'entity_type' => $entityType, - 'entity_id' => $entityId, - 'entity_name' => $entityName, - 'old_values' => $oldValues ? json_encode($oldValues) : null, - 'new_values' => $newValues ? json_encode($newValues) : null, - 'description' => $description, - 'ip_address' => Request::ip(), - 'user_agent' => Request::userAgent(), - 'created_at' => now(), - ]); - } - - /** - * Get audit logs with filters - */ - public function getLogs(array $filters = [], int $limit = 50): \Illuminate\Support\Collection - { - $query = DB::table('ptero_audit_logs'); - - if (!empty($filters['entity_type'])) { - $query->where('entity_type', $filters['entity_type']); - } - if (!empty($filters['entity_id'])) { - $query->where('entity_id', $filters['entity_id']); - } - if (!empty($filters['user_id'])) { - $query->where('user_id', $filters['user_id']); - } - if (!empty($filters['action'])) { - $query->where('action', $filters['action']); - } - if (!empty($filters['date_from'])) { - $query->where('created_at', '>=', $filters['date_from']); - } - if (!empty($filters['date_to'])) { - $query->where('created_at', '<=', $filters['date_to']); - } - - return $query->orderBy('created_at', 'desc')->limit($limit)->get(); - } - - /** - * Get logs for a specific entity - */ - public function getEntityHistory(string $entityType, int $entityId): \Illuminate\Support\Collection - { - return $this->getLogs([ - 'entity_type' => $entityType, - 'entity_id' => $entityId, - ], 100); - } -} -``` - ---- - -## AlertService - -```php -resourceService = $resourceService; - } - - /** - * Check all locations for capacity alerts - */ - public function checkCapacityAlerts(): void - { - $alertConfigs = DB::table('ptero_alert_configs') - ->where('is_active', true) - ->get(); - - foreach ($alertConfigs as $config) { - $this->checkAlertConfig($config); - } - } - - private function checkAlertConfig(object $config): void - { - // Skip if in cooldown - if ($config->last_notification_at && - now()->diffInMinutes($config->last_notification_at) < $config->cooldown_minutes) { - return; - } - - try { - if ($config->location_id) { - $locations = [$config->location_id]; - } else { - $locations = collect($this->resourceService->getLocations())->pluck('id'); - } - - foreach ($locations as $locationId) { - $availability = $this->resourceService->getLocationAvailability($locationId); - $alerts = $this->checkThresholds($availability, $config); - - if (!empty($alerts)) { - $this->sendNotifications($config, $availability, $alerts); - - DB::table('ptero_alert_configs') - ->where('id', $config->id) - ->update(['last_notification_at' => now()]); - } - } - } catch (\Exception $e) { - \Log::error('Alert check failed', [ - 'config_id' => $config->id, - 'error' => $e->getMessage(), - ]); - } - } - - private function checkThresholds(array $availability, object $config): array - { - $alerts = []; - - $memoryUtilization = $availability['total_capacity']['memory'] > 0 - ? ($availability['total_allocated']['memory'] / $availability['total_capacity']['memory']) * 100 - : 0; - - $diskUtilization = $availability['total_capacity']['disk'] > 0 - ? ($availability['total_allocated']['disk'] / $availability['total_capacity']['disk']) * 100 - : 0; - - if ($memoryUtilization >= $config->memory_critical_threshold) { - $alerts[] = ['type' => 'critical', 'resource' => 'memory', 'utilization' => $memoryUtilization]; - } elseif ($memoryUtilization >= $config->memory_warning_threshold) { - $alerts[] = ['type' => 'warning', 'resource' => 'memory', 'utilization' => $memoryUtilization]; - } - - if ($diskUtilization >= $config->disk_critical_threshold) { - $alerts[] = ['type' => 'critical', 'resource' => 'disk', 'utilization' => $diskUtilization]; - } elseif ($diskUtilization >= $config->disk_warning_threshold) { - $alerts[] = ['type' => 'warning', 'resource' => 'disk', 'utilization' => $diskUtilization]; - } - - return $alerts; - } - - private function sendNotifications(object $config, array $availability, array $alerts): void - { - $locationName = $config->location_name ?? 'All Locations'; - - if ($config->email_notifications && !empty($config->notification_emails)) { - $emails = json_decode($config->notification_emails, true); - // Send email notification - // Implementation depends on your mail setup - } - - if ($config->webhook_notifications && $config->webhook_url) { - Http::post($config->webhook_url, [ - 'location' => $locationName, - 'alerts' => $alerts, - 'availability' => $availability, - 'timestamp' => now()->toIso8601String(), - ]); - } - } - - /** - * Send test notification - */ - public function sendTestNotification(object $config): void - { - $testAlerts = [ - ['type' => 'test', 'resource' => 'memory', 'utilization' => 85], - ]; - - $testAvailability = [ - 'location_id' => $config->location_id ?? 0, - 'total_capacity' => ['memory' => 65536, 'disk' => 512000], - 'total_allocated' => ['memory' => 55705, 'disk' => 409600], - ]; - - $this->sendNotifications($config, $testAvailability, $testAlerts); - } -} -``` +## ResourceCalculationService ---- +RAM, disk, and CPU capacity are computed as: -## Scheduled Jobs +`effective total - existing Pterodactyl limits - live local commitments` -### CleanupExpiredReservations +Memory and disk honor finite Pterodactyl overallocation. CPU uses an enabled, +panel- and node-bound `NodeCapacityPolicy`; the physical value is expressed in +Pterodactyl percentage and multiplied by its configured overcommit ratio. +Nodes with missing/mismatched CPU policy, unlimited existing server limits, +private/maintenance state, unsafe client allocation headroom, or no feasible +port are excluded. -```php -cleanupExpired(); - - if ($count > 0) { - \Log::info("Cleaned up {$count} expired reservations"); - } - } -} -``` +## Configuration setup -Register in scheduler: -```php -// In App\Console\Kernel or extension boot -$schedule->job(new CleanupExpiredReservations)->everyMinute(); -$schedule->job(new CheckCapacityAlerts)->everyFiveMinutes(); -``` +Native option environment keys are lowercase: `memory`, `cpu`, `disk`, and `location`. The normalization migration updates existing options and service properties. `ExtensionHelper::getServiceProperties()` serializes `ServiceConfig.slider_value` for dynamic sliders so a config row cannot overwrite the numeric property with null. diff --git a/03-API.md b/03-API.md index 3ab3985..0ca136f 100644 --- a/03-API.md +++ b/03-API.md @@ -1,720 +1,39 @@ -# API Endpoints +# API Surface -> **Related docs**: [02-SERVICES.md](02-SERVICES.md) (services called by controllers), [06-FRONTEND.md](06-FRONTEND.md) (JavaScript that calls these APIs) +All routes are registered from `routes/api.php`. ---- +## Customer quote routes -## Route Definitions +Prefix: `/api/dynamic-pterodactyl` -### Public API Routes (Guest-Compatible Checkout) +| Method | Route | Response | +|---|---|---| +| POST | `/products/{product}/resource-quote` | Guest-safe, complete-vector checkout bounds; `web`, `throttle:30,1` | +| POST | `/services/{service}/upgrade-quote` | Customer-owned, fixed-node upgrade bounds; `web`, `auth`, `throttle:30,1` | -```php -middleware(['web', 'auth', 'throttle:30,1'])->group(function () { - // Availability - Route::get('/availability/{locationId}', [AvailabilityController::class, 'getByLocation']); +Prefix: `/api/dynamic-pterodactyl/admin` +Middleware: `web`, `auth`, `EnsureUserIsAdmin`, `throttle:30,1` - // Pricing - Route::post('/pricing/calculate', [PricingController::class, 'calculate']); - Route::get('/pricing/config/{productId}', [PricingController::class, 'getConfig']); -}); +| Method | Route | Purpose | +|---|---|---| +| GET | `/reservations` | Paginated reservation inventory | +| POST | `/reservations/{token}/cancel` | Cancel an unbound pending row with a reason | +| GET | `/capacity` | Cluster capacity summary | +| GET | `/availability/{locationId}/nodes` | Raw per-node details | -// Reservation endpoints — throttled (10 req/min) for checkout-retry burst tolerance -Route::prefix('api/dynamic-pterodactyl')->middleware(['web', 'checkout', 'throttle:10,1'])->group(function () { - Route::post('/reservation', [ReservationController::class, 'create']); - Route::get('/reservation/{token}', [ReservationController::class, 'get']); - Route::delete('/reservation/{token}', [ReservationController::class, 'cancel']); - Route::post('/reservation/{token}/extend', [ReservationController::class, 'extend']); -}); -``` +## Removed customer reservation and legacy preview APIs -> Pricing config no longer uses `ptero_pricing_configs`. It now uses native Paymenter ConfigOption rows with `type='dynamic_slider'` and `metadata.resource_type`. +There is no customer create/get/cancel/extend reservation endpoint. Capacity holds are created synchronously by server-side cart listeners. This removes browser idempotency, bearer tokens, session storage, URL state, and client-selected node ownership from the protocol. -### Admin API Routes +The retired `/availability/{locationId}`, `/pricing/calculate`, and +`/pricing/config/{productId}` routes are also absent. Checkout pricing is owned +by Paymenter core, and live stock is available only through the complete-vector +quote contracts above. -```php -middleware(['web', 'auth', EnsureUserIsAdmin::class, 'throttle:30,1']) - ->group(function () { - Route::get('/reservations', [AdminReservationController::class, 'index']); - Route::post('/reservations/{token}/cancel', [AdminReservationController::class, 'cancel']); - Route::get('/capacity', [AdminCapacityController::class, 'summary']); - Route::get('/availability/{locationId}/nodes', [AvailabilityController::class, 'getNodes']); - }); -``` - -### Removed Endpoints - -The following endpoints were previously documented but either never shipped or were retired by dp-09/dp-11: -- `POST /api/dynamic-pterodactyl/pricing/validate-config` -- `POST /api/dynamic-pterodactyl/admin/pricing/import` -- `GET /api/dynamic-pterodactyl/admin/pricing/export` -- `POST /api/dynamic-pterodactyl/admin/reservations/{token}/extend` -- `POST /api/dynamic-pterodactyl/admin/reservations/cleanup` -- `POST /api/dynamic-pterodactyl/admin/test-connection` -- `GET /api/dynamic-pterodactyl/admin/statistics` -- `GET /api/dynamic-pterodactyl/admin/dashboard` - -Future readers who find references in old commits can ignore them. - ---- - -## Endpoint Reference - -### GET /api/dynamic-pterodactyl/availability/{locationId} - -Get maximum available resources for a location. - -**Response:** -```json -{ - "success": true, - "data": { - "location_id": 1, - "max_memory": 32768, - "max_cpu": 800, - "max_disk": 204800, - "node_count": 3, - "has_capacity": true, - "resource_capacity": { - "memory": true, - "cpu": true, - "disk": true - } - } -} -``` - -`has_capacity` is conservative: it is only `true` when memory, CPU, and disk are all positive. `resource_capacity` exposes the per-resource booleans the frontend can use to explain which resource is exhausted. - -### POST /api/dynamic-pterodactyl/pricing/calculate - -Calculate price for a resource configuration by delegating to Paymenter core pricing primitives. - -**Request:** -```json -{ - "product_id": 5, - "plan_id": 12, - "memory": 8192, - "cpu": 400, - "disk": 102400 -} -``` - -`plan_id` is optional; when omitted, the first plan (sorted by `sort`) for the product is used. Only the slider resource types configured on the product are required — requests missing a configured slider return `422`. Requests with a `plan_id` belonging to a different product also return `422`. Products with no `dynamic_slider` config options return `404`. - -`PricingController::calculate()` resolves the selected plan, adds `Plan::dynamicSliderBasePrice()` once when at least one slider is in scope, and sums each slider's `ConfigOption::calculateDynamicPriceDelta(...)` result into the response payload. - -**Response (200 OK):** -```json -{ - "success": true, - "data": { - "total": 24.00, - "breakdown": [ - { "resource_type": "memory", "label": "Memory", "value": 8192, "display_value": "8 GB", "price": 4.00, "pricing_model": "linear" }, - { "resource_type": "cpu", "label": "CPU", "value": 400, "display_value": "4 cores", "price": 8.00, "pricing_model": "linear" }, - { "resource_type": "disk", "label": "Disk", "value": 102400, "display_value": "100 GB", "price": 7.00, "pricing_model": "linear" } - ], - "model": "linear" - } -} -``` - -The shared base price is included in `total` but not duplicated in each breakdown row. - -**Response (422 — foreign `plan_id`):** -```json -{ - "success": false, - "message": "Selected plan does not belong to this product" -} -``` - -### GET /api/dynamic-pterodactyl/pricing/config/{productId} - -Get pricing configuration and slider limits for a product. - -**Response:** -```json -{ - "success": true, - "data": { - "product_id": 5, - "pricing_model": "linear", - "sliders": { - "memory": { - "enabled": true, - "min": 1024, - "max": 65536, - "step": 1024, - "default": 4096 - }, - "cpu": { - "enabled": true, - "min": 100, - "max": 800, - "step": 100, - "default": 200 - }, - "disk": { - "enabled": true, - "min": 10240, - "max": 512000, - "step": 10240, - "default": 51200 - } - }, - "display": { - "memory_label": "RAM", - "cpu_label": "CPU Cores", - "disk_label": "Storage", - "show_breakdown": true - }, - "allowed_locations": null - } -} -``` - -### POST /api/dynamic-pterodactyl/reservation - -Create a resource reservation. Guests are allowed so the first capacity hold can be created on the product page before login. The throttle still protects the route: authenticated requests key by user and guest requests fall back to IP. - -**Headers:** -- `Idempotency-Key` *(optional, preferred)* — 8-64 characters, alphanumeric plus hyphen. When reused by the same user while an existing reservation is still `pending` or `confirmed`, the API returns the original reservation instead of creating a duplicate hold. - -**Request:** -```json -{ - "product_id": 5, - "location_id": 1, - "memory": 8192, - "cpu": 400, - "disk": 102400, - "cart_item_id": 123, - "idempotency_key": "checkout-req-123" -} -``` - -`cart_item_id` is optional. Product-page slider changes happen before a cart row exists, so the frontend can reserve capacity with only `product_id`, `location_id`, and the slider resources. When the item is later added to cart, Paymenter stores the returned token in `cart_items.checkout_config.dp_reservation_token` and confirms it during checkout. - -**Validation rules:** -- Product must have `dynamic_slider` config options for dynamic reservations, otherwise the request is rejected with `422` and `This product is not configured for dynamic reservations`. -- `location_id` must be one of the product's configured `location_ids` when that product setting is present. -- Each configured resource must be present in the payload. -- Extra resource fields not configured on the product are rejected. -- Each selected resource must stay within the slider's `min`/`max` bounds and match its configured `step` increment. -- `Idempotency-Key` header or `idempotency_key` body field must match `^[A-Za-z0-9-]{8,64}$` when supplied. - -### Reservation lifecycle - -The shipped flow is now: - -1. Customer changes a `dynamic_slider` value on the product page. -2. The parent Alpine `dynamicSliderGroup` component debounces 500ms, POSTs `/api/dynamic-pterodactyl/reservation`, and stores the returned token in `sessionStorage`. -3. Add-to-cart copies that token into `cart_items.checkout_config.dp_reservation_token`. -4. `App\Livewire\Cart::checkout()` creates the `Service`, then calls `ReservationService::confirm($token, $service->id, Auth::user())`. -5. If confirmation fails, checkout deletes the just-created service and surfaces a capacity-expired error instead of overselling. - -**Response:** -```json -{ - "success": true, - "data": { - "id": 456, - "token": "abc123def456...", - "node_id": 1, - "node_name": "Node-US-01", - "expires_at": "2025-11-28T12:30:00Z", - "ttl_minutes": 15, - "pricing": { - "total": 0, - "breakdown": [], - "model": "stored" - } - } -} -``` - -### GET /api/dynamic-pterodactyl/reservation/{token} - -Get reservation details. - -**Response:** -```json -{ - "success": true, - "data": { - "id": 456, - "token": "abc123def456...", - "status": "pending", - "node_id": 1, - "location_id": 1, - "memory": 8192, - "cpu": 400, - "disk": 102400, - "calculated_price": 0, - "expires_at": "2025-11-28T12:30:00Z", - "created_at": "2025-11-28T12:15:00Z" - } -} -``` - -### DELETE /api/dynamic-pterodactyl/reservation/{token} - -Cancel a reservation. - -**Response:** -```json -{ - "success": true, - "message": "Reservation cancelled" -} -``` - -### POST /api/dynamic-pterodactyl/reservation/{token}/extend - -Extend reservation TTL. - -**Request:** -```json -{ - "minutes": 15 -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "expires_at": "2025-11-28T12:45:00Z" - } -} -``` - -### GET /api/dynamic-pterodactyl/admin/availability/{locationId}/nodes - -Get detailed per-node availability for admin diagnostics. - -**Response:** -```json -{ - "success": true, - "data": { - "location_id": 1, - "nodes": [ - { - "node_id": 1, - "name": "Node-US-01", - "total": { "memory": 65536, "cpu": 1600, "disk": 512000 }, - "allocated": { "memory": 32768, "cpu": 800, "disk": 256000 }, - "reserved": { "memory": 4096, "cpu": 200, "disk": 20480 }, - "available": { "memory": 28672, "cpu": 600, "disk": 235520 }, - "utilization": { "memory": 56.2, "disk": 54.0 }, - "server_count": 12 - } - ], - "total_capacity": { "memory": 131072, "cpu": 3200, "disk": 1024000 }, - "total_allocated": { "memory": 65536, "cpu": 1600, "disk": 512000 } - } -} -``` - ---- - -## Controllers - -### AvailabilityController - -```php -resourceService = $resourceService; - $this->nodeService = $nodeService; - } - - public function getByLocation(int $locationId): JsonResponse - { - try { - $maxAvailable = $this->nodeService->getMaxAvailable($locationId); - $locationData = $this->resourceService->getLocationAvailability($locationId); - $resourceCapacity = [ - 'memory' => $maxAvailable['memory'] > 0, - 'cpu' => $maxAvailable['cpu'] > 0, - 'disk' => $maxAvailable['disk'] > 0, - ]; - - return response()->json([ - 'success' => true, - 'data' => [ - 'location_id' => $locationId, - 'max_memory' => $maxAvailable['memory'], - 'max_cpu' => $maxAvailable['cpu'], - 'max_disk' => $maxAvailable['disk'], - 'node_count' => count($locationData['nodes']), - 'has_capacity' => $resourceCapacity['memory'] && $resourceCapacity['cpu'] && $resourceCapacity['disk'], - 'resource_capacity' => $resourceCapacity, - ], - ]); - } catch (\Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to fetch availability', - 'error' => $e->getMessage(), - ], 500); - } - } - - public function getNodes(int $locationId): JsonResponse - { - try { - $locationData = $this->resourceService->getLocationAvailability($locationId); - - return response()->json([ - 'success' => true, - 'data' => $locationData, - ]); - } catch (\Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to fetch node details', - ], 500); - } - } -} -``` - -### PricingController - -```php -sliderConfigReader = $sliderConfigReader; - } - - public function calculate(Request $request): JsonResponse - { - // Phase 1: validate product_id (static) - $request->validate(['product_id' => 'required|integer|exists:products,id']); - $product = Product::query()->with(['configOptions', 'plans'])->findOrFail($request->integer('product_id')); - - $sliderOptions = $product->configOptions->where('type', 'dynamic_slider')->whereNull('parent_id'); - - if ($sliderOptions->isEmpty()) { - return response()->json(['success' => false, 'message' => 'This product is not configured for dynamic pricing'], 404); - } - - // Phase 2: build dynamic validation rules from configured sliders - $rules = ['plan_id' => 'nullable|integer|exists:plans,id']; - foreach ($sliderOptions as $option) { - $rules[$option->getMetadata('resource_type', strtolower($option->name))] = 'required|integer|min:1'; - } - $validated = array_merge(['product_id' => $product->id], $request->validate($rules)); - - try { - try { - $plan = $this->resolvePlan($product, $validated['plan_id'] ?? null); - } catch (\InvalidArgumentException $e) { - return response()->json(['success' => false, 'message' => $e->getMessage()], 422); - } - - $breakdown = []; - $total = 0.0; - $hasSlider = false; - - foreach ($sliderOptions as $option) { - $resourceType = $option->getMetadata('resource_type', strtolower($option->name)); - $value = (float) ($validated[$resourceType] ?? 0); - if ($value <= 0) continue; - - $hasSlider = true; - $price = $option->calculateDynamicPriceDelta($value, $plan->billing_period, $plan->billing_unit); - $breakdown[] = [ - 'resource_type' => $resourceType, - 'label' => $option->name, - 'value' => $value, - 'display_value' => $option->formatValueForDisplay($value), - 'price' => round($price, 2), - 'pricing_model' => $option->getMetadata('pricing.model', 'linear'), - ]; - $total += $price; - } - - if ($hasSlider) { - $total += $plan->dynamicSliderBasePrice(); - } - - return response()->json([ - 'success' => true, - 'data' => [ - 'total' => round($total, 2), - 'breakdown' => $breakdown, - 'model' => $sliderOptions->first()?->getMetadata('pricing.model', 'linear') ?? 'linear', - ], - ]); - } catch (\Exception $e) { - Log::error('DynamicPterodactyl price calculation failed', ['error' => $e->getMessage()]); - $payload = ['success' => false, 'message' => 'Price calculation failed']; - if (config('app.debug')) $payload['error'] = $e->getMessage(); - return response()->json($payload, 500); - } - } - - public function getConfig(int $productId): JsonResponse - { - $config = $this->sliderConfigReader->getConfig($productId); - - if (! $config['has_config']) { - return response()->json(['success' => false, 'message' => 'No dynamic slider config options found for this product'], 404); - } - - return response()->json(['success' => true, 'data' => ['product_id' => $productId, 'sliders' => $config['sliders']]]); - } - - public function validate(Request $request): JsonResponse - { - return response()->json(['success' => false, 'errors' => ['Pricing validation endpoint has been retired']], 410); - } - - private function resolvePlan(Product $product, ?int $planId): Plan { /* ... */ } -} -``` - -### ReservationController - -```php -reservationService = $reservationService; - } - - public function create(StoreReservationRequest $request): JsonResponse - { - $validated = $request->validated(); - $resources = [ - 'memory' => (int) ($validated['memory'] ?? 0), - 'cpu' => (int) ($validated['cpu'] ?? 0), - 'disk' => (int) ($validated['disk'] ?? 0), - ]; - - try { - $reservation = $this->reservationService->create( - productId: $validated['product_id'], - locationId: $validated['location_id'], - resources: $resources, - cartItemId: $validated['cart_item_id'] ?? null, - userId: $request->user()?->id, - idempotencyKey: $validated['idempotency_key'] ?? null, - ); - - return response()->json([ - 'success' => true, - 'data' => $reservation, - ]); - } catch (\RuntimeException $e) { - return response()->json([ - 'success' => false, - 'message' => $e->getMessage(), - ], 422); - } catch (\Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to create reservation', - ], 500); - } - } - - public function get(string $token): JsonResponse - { - $reservation = $this->reservationService->getByToken($token); - - if (!$reservation) { - return response()->json([ - 'success' => false, - 'message' => 'Reservation not found', - ], 404); - } - - // Only allow owner or admin to view - if ($reservation->user_id !== auth()->id() && !auth()->user()->is_admin) { - return response()->json([ - 'success' => false, - 'message' => 'Unauthorized', - ], 403); - } - - return response()->json([ - 'success' => true, - 'data' => $reservation, - ]); - } - - public function cancel(string $token): JsonResponse - { - $reservation = $this->reservationService->getByToken($token); - - if (!$reservation) { - return response()->json([ - 'success' => false, - 'message' => 'Reservation not found', - ], 404); - } - - if ($reservation->user_id !== auth()->id() && !auth()->user()->is_admin) { - return response()->json([ - 'success' => false, - 'message' => 'Unauthorized', - ], 403); - } - - $result = $this->reservationService->cancel($token); - - return response()->json([ - 'success' => $result, - 'message' => $result ? 'Reservation cancelled' : 'Failed to cancel reservation', - ]); - } - - public function extend(Request $request, string $token): JsonResponse - { - $validated = $request->validate([ - 'minutes' => 'integer|min:1|max:60', - ]); - - $reservation = $this->reservationService->getByToken($token); - - if (!$reservation) { - return response()->json([ - 'success' => false, - 'message' => 'Reservation not found', - ], 404); - } - - if ($reservation->user_id !== auth()->id() && !auth()->user()->is_admin) { - return response()->json([ - 'success' => false, - 'message' => 'Unauthorized', - ], 403); - } - - $result = $this->reservationService->extend($token, $validated['minutes'] ?? 15); - - if ($result) { - $updated = $this->reservationService->getByToken($token); - return response()->json([ - 'success' => true, - 'data' => [ - 'expires_at' => $updated->expires_at, - ], - ]); - } - - return response()->json([ - 'success' => false, - 'message' => 'Failed to extend reservation', - ], 500); - } -} -``` - ---- - -## Error Response Format - -All error responses follow this structure: - -```json -{ - "success": false, - "message": "Human-readable error message", - "error": "Technical details (only in non-production)", - "errors": { - "field_name": ["Validation error message"] - } -} -``` - -### HTTP Status Codes - -| Code | Meaning | -|------|---------| -| 200 | Success | -| 400 | Bad request (invalid parameters) | -| 401 | Unauthorized (not logged in) | -| 403 | Forbidden (not allowed) | -| 404 | Not found | -| 422 | Unprocessable (validation failed or business rule violation) | -| 500 | Server error | - ---- - -## Rate Limiting - -Consider implementing rate limiting for availability endpoints: - -```php -// In RouteServiceProvider or middleware -RateLimiter::for('dynamic-pterodactyl', function ($request) { - return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); -}); -``` - -Pterodactyl API limit: **240 requests/minute** — our endpoints should stay well under this. +Controllers validate and authorize, then delegate to services. Customer errors must never include raw upstream Pterodactyl bodies or node details. diff --git a/04-EVENTS.md b/04-EVENTS.md index 3ce348a..7f85b66 100644 --- a/04-EVENTS.md +++ b/04-EVENTS.md @@ -1,401 +1,68 @@ -# Event Hook Integration - -> **Related docs**: [02-SERVICES.md](02-SERVICES.md) (services called by handlers), [README.md](README.md) (system flow) - ---- - -## Overview - -The companion extension integrates with Paymenter's event system to: -1. Create reservations when items are added to cart -2. Cancel reservations when items are removed -3. Confirm reservations when payment succeeds -4. Track service creation - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Cart Item │────▶│ Checkout │────▶│ Invoice │────▶│ Service │ -│ Created │ │ (wait) │ │ Paid │ │ Created │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ - ▼ ▼ ▼ - ┌───────────┐ ┌───────────┐ ┌───────────┐ - │ Create │ │ Verify & │ │ Link │ - │Reservation│ │ Confirm │ │ Service │ - │ (15 min) │ │Reservation│ │ ID │ - └───────────┘ └───────────┘ └───────────┘ -``` - ---- - -## Event Listener Registration - -In the extension's `boot()` method: - -```php -registerEventListeners(); -} - -private function registerEventListeners(): void -{ - // Cart item created - create reservation - Event::listen(\App\Events\CartItem\Created::class, function ($event) { - $this->handleCartItemCreated($event); - }); - - // Cart item deleted - cancel reservation - Event::listen(\App\Events\CartItem\Deleted::class, function ($event) { - $this->handleCartItemDeleted($event); - }); - - // Invoice paid - confirm reservation - Event::listen(\App\Events\Invoice\Updated::class, function ($event) { - $this->handleInvoiceUpdated($event); - }); - - // Service created - link reservation - Event::listen(\App\Events\Service\Created::class, function ($event) { - $this->handleServiceCreated($event); - }); -} -``` - ---- - -## Event Handlers - -### CartItem Created - -When a user adds a dynamic Pterodactyl product to their cart: -1. Check if product has a pricing config (is a dynamic product) -2. Extract resource selections from configurable options -3. Create a reservation with 15-minute TTL -4. Store reservation token in cart item properties - -```php -cartItem; - $properties = $cartItem->properties ?? []; - - // Check if this is a dynamic Pterodactyl product - if (!$this->isDynamicPterodactylProduct($cartItem->product_id)) { - return; - } - - // Check if required properties exist (from configurable options) - if (!isset($properties['memory'], $properties['cpu'], $properties['disk'], $properties['location'])) { - \Log::debug('CartItem missing required properties for dynamic reservation', [ - 'cart_item_id' => $cartItem->id, - 'properties' => array_keys($properties), - ]); - return; - } - - try { - $reservationService = app(ReservationService::class); - - $reservation = $reservationService->create( - productId: $cartItem->product_id, - locationId: (int) $properties['location'], - resources: [ - 'memory' => (int) $properties['memory'], - 'cpu' => (int) $properties['cpu'], - 'disk' => (int) $properties['disk'], - ], - cartItemId: $cartItem->id, - userId: auth()->id() - ); - - // Store reservation token in cart item for later reference - // Using underscore prefix to indicate internal properties - $cartItem->update([ - 'properties' => array_merge($properties, [ - '_reservation_token' => $reservation['token'], - '_selected_node' => $reservation['node_id'], - '_calculated_price' => $reservation['pricing']['total'], - ]), - ]); - - \Log::info('Created resource reservation for cart item', [ - 'cart_item_id' => $cartItem->id, - 'reservation_token' => substr($reservation['token'], 0, 8) . '...', - 'node_id' => $reservation['node_id'], - 'expires_at' => $reservation['expires_at'], - ]); - - } catch (\Exception $e) { - // Log error but don't block the cart operation - // User can still checkout, but without guaranteed resources - \Log::error('Failed to create resource reservation', [ - 'cart_item_id' => $cartItem->id, - 'error' => $e->getMessage(), - ]); - } -} -``` - -### CartItem Deleted - -When a user removes an item from their cart: - -```php -cartItem; - $properties = $cartItem->properties ?? []; - - // Check if this cart item had a reservation - if (!isset($properties['_reservation_token'])) { - return; - } - - try { - $reservationService = app(ReservationService::class); - $reservationService->cancel($properties['_reservation_token']); - - \Log::info('Cancelled reservation for deleted cart item', [ - 'cart_item_id' => $cartItem->id, - 'reservation_token' => substr($properties['_reservation_token'], 0, 8) . '...', - ]); - - } catch (\Exception $e) { - \Log::error('Failed to cancel reservation', [ - 'token' => substr($properties['_reservation_token'], 0, 8) . '...', - 'error' => $e->getMessage(), - ]); - } -} -``` - -### Invoice Updated (Payment) - -When an invoice status changes to 'paid': -1. Find associated services -2. Verify resources are still available (final check) -3. Confirm the reservation -4. Link reservation to service - -```php -invoice; - - // Only process when invoice becomes paid - if ($invoice->status !== 'paid') { - return; - } - - // Check if status actually changed to paid (not already paid) - if (!$invoice->wasChanged('status')) { - return; - } - - foreach ($invoice->items as $item) { - // Skip items without a service - if (!$item->service_id) { - continue; - } - - $service = $item->service; - if (!$service) { - continue; - } - - $properties = $service->properties ?? []; - - // Check if this service has a reservation - if (!isset($properties['_reservation_token'])) { - continue; - } - - try { - $reservationService = app(ReservationService::class); - $resourceService = app(ResourceCalculationService::class); - - $reservation = $reservationService->getByToken($properties['_reservation_token']); - - if (!$reservation) { - \Log::warning('Reservation not found for paid invoice', [ - 'service_id' => $service->id, - 'invoice_id' => $invoice->id, - ]); - continue; - } - - // CRITICAL: Final availability verification - $available = $resourceService->verifyAvailability( - $reservation->node_id, - [ - 'memory' => $reservation->memory, - 'cpu' => $reservation->cpu, - 'disk' => $reservation->disk, - ] - ); - - if (!$available) { - // This should rarely happen, but we need to handle it - \Log::error('Resources no longer available for paid service', [ - 'service_id' => $service->id, - 'node_id' => $reservation->node_id, - 'memory' => $reservation->memory, - 'cpu' => $reservation->cpu, - 'disk' => $reservation->disk, - ]); - - // TODO: Notify admin for manual intervention - // The server will still be created by Pterodactyl extension, - // but may fail due to insufficient resources - continue; - } - - // Confirm the reservation - $reservationService->confirm($properties['_reservation_token'], $service->id); - - \Log::info('Confirmed reservation for paid service', [ - 'service_id' => $service->id, - 'node_id' => $reservation->node_id, - ]); - - } catch (\Exception $e) { - \Log::error('Failed to confirm reservation', [ - 'service_id' => $service->id, - 'error' => $e->getMessage(), - ]); - } - } -} -``` - -### Service Created - -Track when the Pterodactyl server is actually created: - -```php -service; - $properties = $service->properties ?? []; - - if (!isset($properties['_reservation_token'])) { - return; - } - - \Log::info('Service created with reservation', [ - 'service_id' => $service->id, - 'reservation_token' => substr($properties['_reservation_token'], 0, 8) . '...', - 'product_id' => $service->product_id, - ]); - - // The reservation should already be confirmed by handleInvoiceUpdated - // This is just for logging/tracking purposes -} -``` - ---- - -## Helper Method - -```php -where('product_id', $productId) - ->where('is_active', true) - ->exists(); -} -``` - ---- - -## Reservation TTL Extension - -When user reaches the checkout/payment page, extend their reservation to prevent expiry: - -```php -cart; - - foreach ($cart->items as $item) { - $properties = $item->properties ?? []; - - if (isset($properties['_reservation_token'])) { - $reservationService = app(ReservationService::class); - $reservationService->extend($properties['_reservation_token'], 15); - } - } -}); +# Checkout and Provisioning Lifecycle + +The reservation is server-owned from cart mutation through Pterodactyl creation. + +```mermaid +sequenceDiagram + participant Cart + participant Hold as ReservationService + participant Pay as Paymenter payment + participant Ptero as Pterodactyl provisioner + + Cart->>Hold: reserveForCartItem + Cart->>Hold: transferCartOwnership on login + Cart->>Hold: bind seven-day invoice guarantee + Pay->>Hold: commit paid capacity + Pay->>Ptero: dispatch CreateJob + Ptero->>Hold: beginProvisioning + Ptero->>Ptero: create exact node, limits, and ports + Ptero->>Hold: completeProvisioning ``` -Alternatively, the frontend JavaScript can call the extend API endpoint when the user reaches checkout. - ---- +## Cart create and update -## Graceful Degradation +`CartItem\Created` and `CartItem\Updated` use `CartItemCreatedListener`. The listener delegates to `reserveForCartItem()` and intentionally lets errors bubble. Paymenter wraps `Cart::add()` and quantity edits in a database transaction, so insufficient capacity or an unavailable extension rolls back the cart mutation. -The event handlers are designed to fail gracefully: +Changing plan, resources, quantity, location, currency, or price changes the canonical fingerprint. The old pending row is cancelled and replaced under a location lock. An exact retry refreshes the existing hold. -| Scenario | Behavior | -|----------|----------| -| Pterodactyl API down | Log error, allow cart operation | -| No nodes available | Log error, allow cart operation | -| Reservation expired | Log warning, server creation may fail | -| Database error | Log error, allow cart operation | +## Guest login -**Philosophy**: Don't block the customer's purchase flow. If reservations fail, the worst case is the server creation might fail later, which can be handled manually. +`UserAuthListener` updates the cart owner and calls `transferCartOwnership()` in one database transaction. Existing non-null ownership must match; it is never silently overwritten. ---- +## Checkout -## Event List Reference +Paymenter refreshes each dynamic hold before creating orders. After the service and its config/property rows exist, `bindCartItemToService()`: -Paymenter events used: +- proves the current cart snapshot matches the stored fingerprint; +- proves product, plan, quantity, and currency match the service; +- transfers a guest owner if needed; +- assigns `service_id`; +- extends expiry through the invoice due time. -| Event | When Fired | -|-------|------------| -| `CartItem\Created` | Item added to cart | -| `CartItem\Updated` | Item quantity/properties changed | -| `CartItem\Deleted` | Item removed from cart | -| `Invoice\Created` | Invoice generated | -| `Invoice\Updated` | Invoice status changes | -| `Service\Created` | Service provisioned | +Missing extension, missing hold, expired hold, or identity drift aborts and rolls back checkout. -Full event list: https://paymenter.org/development/event-list +## Cart removal ---- +`CartItem\Deleting` calls `cancelForCartItem()` while the relationship still exists. Bound checkout rows are not cancelled. Clearing the cart after checkout nulls the cart-item FK but preserves `cart_id` and `service_id`. -## Testing Events +## Payment and provisioning -To manually test event handlers: +Payment processing atomically turns the expiring invoice guarantee into a +non-expiring `paid_committed` reservation and moves the service to +`provisioning`. It then dispatches the retryable create job after commit. -```php -// In tinker or a test -$cartItem = \App\Models\CartItem::find(123); +The built-in Pterodactyl `createServer()` calls `beginProvisioning()` directly. +The returned context overrides the actual lowercase `node`, `location`, +`memory`, `cpu`, and `disk` settings plus the exact primary/additional +allocation IDs. The service remains `provisioning` and the row remains +`paid_committed` while the external request runs. -// Manually fire the event -event(new \App\Events\CartItem\Created($cartItem)); +After the server and complete allocation set are re-read and match, +`completeProvisioning()` marks the reservation confirmed, records durable +server/user identity and `consumed_at`, then activates the service. Failure +clears only the matching lease. If the server exists on retry, the provisioner +reconciles it instead of duplicating it. -// Check logs for handler execution -``` +Cancellation shares the same per-service queue overlap key. Capacity and +product stock are released only after the exact numeric Pterodactyl server ID +is proved absent. diff --git a/06-FRONTEND.md b/06-FRONTEND.md index 489a677..5793095 100644 --- a/06-FRONTEND.md +++ b/06-FRONTEND.md @@ -1,48 +1,34 @@ # Frontend Architecture -## Overview - -The customer-facing product-configuration UI uses Paymenter's native `dynamic_slider` config option type plus one core-owned Alpine coordinator: `resources/js/dynamic-slider-group.js`. The extension still does not ship its own JS bundle, but its reservation API is now called directly from Paymenter's checkout theme. - -## Slider component - -**File**: `themes/default/views/components/form/configoption.blade.php` (lines 79–248) - -The slider is a native HTML `` wrapped in an Alpine.js component (`x-data`). Key characteristics: - -- **Data shape**: The Alpine component holds `value` (current slider integer), `min`, `max`, `step`, and `displayValue` (formatted with a unit suffix). These are passed as Blade component props from the config option metadata. -- **Client-side price calculation**: `calculatePrice()` is a pure Alpine.js function that reads the pricing model from metadata (linear / tiered / base_addon) and computes the per-slider price delta locally. Price display updates instantaneously while a separate request manages capacity reservations. -- **Livewire entanglement**: The slider value is synced to Livewire via `$wire.entangle('{{ $name }}').live`. This allows the parent Livewire checkout component to re-render the cart total when the slider changes. -- **Reservation signalling**: after each debounced change, the child slider dispatches `slider-change` with `{ resourceType, value }` so the wrapper can submit a single reservation request for the whole product. - -## `dynamicSliderGroup` Alpine coordinator - -**File**: `resources/js/dynamic-slider-group.js` - -This wrapper lives around the product checkout form only when the product contains at least one `dynamic_slider` config option. - -- Holds shared state for all sliders on one product page: `memory`, `cpu`, `disk`, `token`, `error`, `loading`. -- Restores any prior token from `sessionStorage` using `dp_reservation_token__`. -- Listens for child `slider-change` events, debounces 500ms, and POSTs `/api/dynamic-pterodactyl/reservation` with `product_id`, `plan_id`, `location_id`, and the full resource snapshot. -- Persists the latest token back to `sessionStorage` and mirrors it into `checkoutConfig.dp_reservation_token` before Add-to-cart. -- Treats `422` as a blocking capacity error, retries `429` with backoff, and degrades gracefully on `5xx`/network failures. - -## Checkout persistence - -Once Add-to-cart runs, the reservation token moves out of browser-only state and into `cart_items.checkout_config.dp_reservation_token`. That key is copied onto the created service during checkout and confirmed immediately by `App\Livewire\Cart::checkout()`. - -## Accessibility gaps (tracked in dp-10) - -- The live price display lacks `aria-live="polite"`. Screen readers do not announce price changes when the slider moves. -- Progress indicators in the admin panel (`dashboard.blade.php`, `node-monitoring.blade.php`) lack `role="progressbar"` and `aria-value*` attributes. - -## Admin UI - -The admin panel uses Filament v4 exclusively. The extension registers pages and resources via standard Filament discovery — no custom asset injection. See `05-ADMIN-UI.md` for the admin surface inventory. - -## What this extension does NOT own on the frontend - -- The `` HTML and Alpine.js component — owned by Paymenter core themes. -- The `calculateDynamicPrice()` method — in `app/Models/ConfigOption.php` (core). -- Cart total rendering — core Livewire Cart component. -- Checkout form — core Livewire Checkout component. +Paymenter core owns the native `dynamic_slider` input, price preview, Livewire state, and checkout form. + +The slider is a native range input with Alpine state and Livewire entanglement. +It supports Page Up/Down, Home/End, an accessible value description, a live +price output, and live complete-vector stock bounds. + +The browser does not create or store reservations. There is: + +- no reservation fetch request; +- no reservation token in session storage; +- no token mirrored into Livewire checkout configuration; +- no token in URL state; +- no client-selected node. + +The companion Paymenter theme calls the guest-safe product quote endpoint on +initial load and after location or resource changes. Requests are debounced, +older responses are aborted and ignored, and checkout remains disabled while +stock is loading or unavailable. Every returned maximum is step-aligned and +conditional on the other selected resources fitting the same eligible node. +For example, a configured 32 GiB maximum becomes 23 GiB when the best feasible +node has 23 GiB left, but remains 32 GiB when at least 32 GiB is feasible. + +The browser is advisory. Cart create/edit repeats strict range, step, +complete-vector, CPU, and allocation validation under database locks. A +failure is surfaced through Paymenter's `DisplayException` handling and the +cart mutation is rolled back. + +Pricing preview remains client-friendly, but Paymenter core recalculates the authoritative price when building the cart and service. + +Capacity-aware upgrades use the same component with an authenticated, +customer-owned fixed-node quote. Admin pages use Filament 5 and extension Blade +views. diff --git a/07-PRICING-MODELS.md b/07-PRICING-MODELS.md index c6fdb40..6fe29a8 100644 --- a/07-PRICING-MODELS.md +++ b/07-PRICING-MODELS.md @@ -1,12 +1,14 @@ # Pricing Models -> **Related docs**: [02-SERVICES.md](02-SERVICES.md) (SliderConfigReaderService), [05-ADMIN-UI.md](05-ADMIN-UI.md) (configuration forms) +> **Related docs**: [02-SERVICES.md](02-SERVICES.md) (configuration and quote services), [05-ADMIN-UI.md](05-ADMIN-UI.md) (configuration forms) --- ## Overview -Paymenter core now owns `dynamic_slider` pricing math. This extension only stores slider metadata and previews pricing through the same core methods used by checkout and recalculation flows. +Paymenter core owns `dynamic_slider` pricing math. This extension stores slider +metadata and supplies live stock bounds; it does not expose a separate pricing +preview contract. Three pricing models serve different business needs: @@ -246,7 +248,10 @@ Show what's included vs. what's extra: ## Database Storage -Slider pricing is read from ConfigOption metadata built by `Services/ConfigOptionSetupService` and consumed by Paymenter core pricing methods plus `Services/SliderConfigReaderService` for config reads. It is no longer stored in `ptero_pricing_configs.pricing_config`. +Slider pricing is read from ConfigOption metadata built by +`Services/ConfigOptionSetupService` and consumed directly by Paymenter core. +It is no longer stored in `ptero_pricing_configs.pricing_config`, and the +extension exposes no separate customer pricing-preview API. Paymenter core reads the `model` key from ConfigOption metadata and performs the calculation via `ConfigOption::calculateDynamicPriceDelta()`. The extension only stores and reads configuration — no local `calculateLinear()`, `calculateTiered()`, or `calculateBasePlusAddon()` methods exist in the extension. --- diff --git a/08-ALGORITHMS.md b/08-ALGORITHMS.md index 2987eca..22941fc 100644 --- a/08-ALGORITHMS.md +++ b/08-ALGORITHMS.md @@ -1,368 +1,56 @@ -# Algorithms +# Allocation and Concurrency Algorithms -> **Related docs**: [02-SERVICES.md](02-SERVICES.md) (service implementations) +## Availability ---- +For RAM, CPU, and disk: -## Node Selection Algorithm +`available = effective node total - Pterodactyl server limits - live local commitments` -### Goal +Effective RAM/disk totals apply Pterodactyl's finite overallocation percentage. +Effective CPU is the administrator-declared physical percentage multiplied by +the per-node basis-point overcommit ratio. A missing or stale CPU policy makes +the node ineligible; CPU is never inferred from a fabricated default. -Select the best node that can accommodate requested resources while maintaining balanced utilization across the cluster. +Live commitments include unexpired `pending` checkout/upgrade holds, +non-expiring `paid_committed` rows, and confirmed expectations until live +Pterodactyl inventory proves the exact external target. Upgrade rows reserve +only each positive resource delta while retaining the full immutable target. -### Approach: Best-Fit with Headroom Weighting +Unassigned Pterodactyl allocation IDs are filtered by local allocation claims. +Required ports, allowed primary-port ranges, and dedicated-IP grouping are +evaluated by the same deterministic selector during quote and reservation. -Not just "first available" or "most available" — we use **weighted headroom scoring** to: -1. Ensure the node CAN fit the request -2. Prefer nodes with better capacity distribution -3. Weight resources by importance (memory > disk > CPU) +## Node selection -### Why These Weights? +1. Fetch live nodes for the chosen location. +2. Overlay live local resource and allocation claims. +3. Reject private, maintenance, unbounded, unmanaged-CPU, unsafe-allocation, + or port-infeasible nodes. +4. Reject nodes that cannot fit the complete RAM/CPU/disk vector. +5. Score remaining relative headroom: + - memory: 50%; + - CPU: 15%; + - disk: 35%. +6. Choose the highest score, then the lowest node ID on a tie. -| Resource | Weight | Rationale | -|----------|--------|-----------| -| Memory | 50% | Most commonly upgraded; hard to migrate | -| Disk | 35% | Harder to expand; data migration is slow | -| CPU | 15% | Often shared/burstable; easier to oversell | +When replacing a cart hold, the previous token is excluded from availability calculation and the old row is cancelled in the same transaction. -### Algorithm Steps +## Concurrency -``` -1. FILTER: Remove nodes that cannot fit the request - - Skip nodes in maintenance mode - - Skip nodes with insufficient memory - - Skip nodes with insufficient CPU - - Skip nodes with insufficient disk +`reserveForCartItem()` locks the panel/location capacity-scope row before +reading inventory, selecting allocations, and inserting. Database-specific +unique guards prevent duplicate live cart, checkout-service, and upgrade rows. -2. SCORE: Calculate weighted headroom for each candidate - For each node: - remaining_memory = available_memory - requested_memory - remaining_cpu = available_cpu - requested_cpu - remaining_disk = available_disk - requested_disk - - memory_score = (remaining_memory / total_memory) × 0.50 - disk_score = (remaining_disk / total_disk) × 0.35 - cpu_score = (remaining_cpu / total_cpu) × 0.15 - - total_score = memory_score + disk_score + cpu_score +Checkout extends the cart hold to exactly seven days. Payment changes it to +`paid_committed`, which has no capacity expiry. `beginProvisioning()` locks the +service-bound commitment; an unguessable lease rejects concurrent or stale +workers. -3. SELECT: Choose node with highest score -``` +The row stays `paid_committed` until the external create and allocation set are +verified. After confirmation, a local expectation overlay remains until the +separately-read Pterodactyl inventory catches up. -### Visual Example - -``` -Request: 8GB RAM, 2 cores, 50GB disk - -Node A: 32GB total, 16GB available → fits, remaining 8GB -Node B: 64GB total, 20GB available → fits, remaining 12GB -Node C: 32GB total, 6GB available → SKIP (can't fit 8GB) - -Scoring (memory only for simplicity): -Node A: 8/32 = 0.25 × 0.50 = 0.125 -Node B: 12/64 = 0.1875 × 0.50 = 0.094 - -Winner: Node A (better relative headroom despite smaller total) -``` - -### Pseudocode - -```php -function selectBestNode(locationId, requirements): - nodes = getNodesInLocation(locationId) - candidates = [] - - for node in nodes: - if node.maintenance_mode: - continue - - available = calculateAvailable(node) - - if available.memory < requirements.memory: - continue - if available.cpu < requirements.cpu: - continue - if available.disk < requirements.disk: - continue - - remaining = { - memory: available.memory - requirements.memory, - cpu: available.cpu - requirements.cpu, - disk: available.disk - requirements.disk, - } - - score = (remaining.memory / node.total.memory) * 0.50 - + (remaining.disk / node.total.disk) * 0.35 - + (remaining.cpu / node.total.cpu) * 0.15 - - candidates.append({ node, score, remaining }) - - if candidates.empty: - return null - - return candidates.sortByScoreDesc().first().node -``` - -### Edge Cases - -| Scenario | Handling | -|----------|----------| -| No nodes in location | Return `null`, let caller handle | -| All nodes in maintenance | Return `null` | -| Exact fit (0 remaining) | Score = 0, still valid candidate | -| Single node location | That node wins if it fits | - ---- - -## Availability Calculation - -### Components of "Available" Resources - -``` -Available = Total Capacity - - Already Allocated (existing servers) - - Pending Reservations (checkout in progress) -``` - -### Pterodactyl Overallocation - -Pterodactyl allows overallocation for memory and disk: - -``` -Effective Total = Base Capacity × (1 + Overallocation% / 100) - -Example: - Base memory: 64GB - Overallocation: 50% - Effective: 64 × 1.5 = 96GB allocatable -``` - -CPU is NOT overallocated (uses thread count × 100). - -### Calculation Flow - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Pterodactyl API │ -├─────────────────────────────────────────────────────────────────┤ -│ GET /api/application/nodes/{id}?include=servers │ -│ │ -│ Returns: │ -│ - node.memory (base capacity) │ -│ - node.memory_overallocate (percentage) │ -│ - node.disk (base capacity) │ -│ - node.disk_overallocate (percentage) │ -│ - servers[].limits.memory (allocated per server) │ -│ - servers[].limits.cpu │ -│ - servers[].limits.disk │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Local Database Query │ -├─────────────────────────────────────────────────────────────────┤ -│ SELECT SUM(memory), SUM(cpu), SUM(disk) │ -│ FROM ptero_resource_reservations │ -│ WHERE node_id = ? AND status = 'pending' │ -│ AND expires_at > NOW() │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Final Calculation │ -├─────────────────────────────────────────────────────────────────┤ -│ effective_total = base × (1 + overallocate/100) │ -│ allocated = SUM(server.limits) │ -│ reserved = SUM(pending_reservations) │ -│ available = effective_total - allocated - reserved │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Concurrency Control - -### The Problem - -Two customers selecting resources simultaneously could both be promised the same capacity, leading to overselling. - -``` -Timeline: - T0: Customer A checks availability → 8GB available - T1: Customer B checks availability → 8GB available - T2: Customer A reserves 8GB → succeeds - T3: Customer B reserves 8GB → should fail, but might succeed without locking -``` - -### Solution: Pessimistic Locking - -Lock pending reservations for the location before checking availability and creating new reservation. - -```php -DB::transaction(function () use ($locationId, $requirements) { - // Lock all pending reservations for this location - // This prevents concurrent transactions from reading stale data - DB::table('ptero_resource_reservations') - ->where('location_id', $locationId) - ->where('status', 'pending') - ->lockForUpdate() // ← Key: exclusive lock - ->get(); - - // Now calculate availability (includes locked rows) - $node = $this->nodeService->selectBestNode($locationId, $requirements); - - if (!$node) { - throw new \RuntimeException('No capacity available'); - } - - // Create reservation (within same transaction) - return $this->createReservation($node, $requirements); -}, 5); // ← Retry up to 5 times on deadlock -``` - -### Why `lockForUpdate()`? - -| Lock Type | Behavior | -|-----------|----------| -| Shared (`sharedLock()`) | Multiple readers allowed, blocks writes | -| Exclusive (`lockForUpdate()`) | Blocks all other access until commit | - -We need exclusive because we're reading AND writing based on the read. - -### Deadlock Handling - -With pessimistic locking, deadlocks can occur if two transactions lock in different orders. Laravel's transaction retry handles this: - -```php -DB::transaction(function () { - // ... locking logic ... -}, 5); // Retry 5 times with exponential backoff -``` - -### Race Condition Timeline (With Locking) - -``` -Timeline: - T0: Customer A begins transaction, locks location 1 - T1: Customer B begins transaction, waits for lock... - T2: Customer A calculates availability, creates reservation - T3: Customer A commits, releases lock - T4: Customer B acquires lock, calculates (sees A's reservation) - T5: Customer B sees reduced availability, acts accordingly -``` - ---- - -## Reservation Lifecycle - -### State Machine - -``` - ┌──────────────┐ - │ (start) │ - └──────┬───────┘ - │ add to cart - ▼ - ┌──────────────┐ - ┌───────│ pending │───────┐ - │ └──────┬───────┘ │ - │ │ │ - TTL expires payment succeeds user cancels - │ │ │ - ▼ ▼ ▼ - ┌──────────┐ ┌───────────┐ ┌───────────┐ - │ expired │ │ confirmed │ │ cancelled │ - └──────────┘ └───────────┘ └───────────┘ - │ │ │ - └──────────────┴───────────────┘ - │ - ▼ - ┌──────────────┐ - │ (released) │ ← resources back in pool - └──────────────┘ -``` - -### TTL Management - -**Default TTL**: 15 minutes (configurable) - -**Extension**: Customers can extend once when they reach checkout, adding another 15 minutes. - -**Cleanup Job**: Runs every minute to mark expired reservations. - -```php -// Cleanup query -UPDATE ptero_resource_reservations -SET status = 'expired', updated_at = NOW() -WHERE status = 'pending' AND expires_at < NOW() -``` - -### Final Verification - -Even with reservations, we do a **final check** at payment time: - -```php -// In invoice payment handler -$stillAvailable = $resourceService->verifyAvailability( - $reservation->node_id, - [ - 'memory' => $reservation->memory, - 'cpu' => $reservation->cpu, - 'disk' => $reservation->disk, - ] -); - -if (!$stillAvailable) { - // Edge case: node capacity changed (admin removed node, etc.) - // Log error, notify admin, but don't block payment - // Server creation will fail at Pterodactyl level -} -``` - -This catches edge cases like: -- Admin removing a node -- Another admin manually creating servers -- Pterodactyl capacity changes - ---- - -## Performance Considerations - -### API Call Frequency - -| Scenario | API Calls | -|----------|-----------| -| Location dropdown change | 1 (nodes in location) | -| Create reservation | 1 (verify node capacity) | -| Price calculation | 0 (local only) | -| Cleanup job | 0 (local only) | - -Pterodactyl rate limit: **240 requests/minute** — we stay well under this. - -### Database Query Optimization - -Key indexes on `ptero_resource_reservations`: - -```sql --- Fast pending reservation lookup by node -INDEX idx_node_pending (node_id, status, expires_at) - --- Fast cleanup of expired -INDEX idx_cleanup (status, expires_at) - --- Location availability calculation -INDEX (location_id, status) -``` - -### Caching (Intentionally Avoided) - -We chose **real-time API** over caching because: -1. Stale cache = overselling risk -2. Cache invalidation complexity -3. Proven by PteroSync in production -4. Pterodactyl API is fast enough - -If latency becomes an issue, consider: -- Edge caching with 30-second TTL (accept slight staleness) -- WebSocket for real-time updates -- Background refresh with optimistic UI +Pterodactyl `external_id = service.id` initiates reconciliation on retry, but +is never sufficient proof. Numeric server/user IDs, UUID, identifier, panel, +node, nest, egg, resource limits, owner external ID, and allocation IDs must +all match the immutable commitment. diff --git a/09-IMPLEMENTATION.md b/09-IMPLEMENTATION.md index 0539909..96c8106 100644 --- a/09-IMPLEMENTATION.md +++ b/09-IMPLEMENTATION.md @@ -1,303 +1,82 @@ -# Implementation Guide - -> **Related docs**: All other documents in this folder - ---- - -## File Structure - -``` -extension/Others/DynamicPterodactyl/ -├── DynamicPterodactyl.php # Main extension class -│ -├── database/ -│ └── migrations/ -│ ├── 2025_01_01_000001_create_ptero_resource_reservations_table.php -│ ├── 2025_01_01_000002_create_ptero_pricing_configs_table.php -│ ├── 2025_01_01_000003_create_ptero_audit_logs_table.php -│ └── 2025_01_01_000004_create_ptero_alert_configs_table.php -│ -├── Admin/ -│ ├── Pages/ -│ │ ├── Dashboard.php -│ │ ├── NodeMonitoring.php -│ │ ├── AuditLogPage.php -│ │ └── SetupWizard.php -│ └── Resources/ -│ ├── ReservationResource.php -│ └── AlertConfigResource.php -│ -├── Http/ -│ └── Controllers/ -│ ├── Api/ -│ │ ├── AvailabilityController.php -│ │ ├── PricingController.php -│ │ └── ReservationController.php -│ └── Admin/ -│ ├── AdminReservationController.php -│ └── AdminCapacityController.php -│ -├── Models/ -│ ├── ResourceReservation.php -│ ├── AuditLog.php -│ └── AlertConfig.php -│ -├── resources/ -│ └── views/ -│ └── admin/ -│ ├── dashboard.blade.php -│ ├── node-monitoring.blade.php -│ ├── audit-log.blade.php -│ └── setup-wizard.blade.php -│ -├── routes/ -│ └── api.php -│ -└── Services/ - ├── ResourceCalculationService.php - ├── NodeSelectionService.php - ├── ReservationService.php - ├── PricingCalculatorService.php - ├── AuditLogService.php - ├── AlertService.php - └── ConfigOptionSetupService.php -``` - ---- - -## Implementation Roadmap - -| Phase | Status | Reference | -|---|---|---| -| Database schema | ✅ Shipped | dp-08 idempotency + drop_released migrations | -| Service layer | ✅ Shipped | dp-09 cleanup; 8 services live | -| API endpoints | ✅ Shipped | dp-05 admin API; dp-08 reservation hardening | -| Filament admin UI | ✅ Shipped | dp-13 SetupWizard atomicity | -| Pricing model | ✅ Delegated to core | core `DynamicSliderPricingRule` (dp-core-01) | -| Frontend slider | ✅ Shipped | dp-10 a11y; dp-core-02 Blade partial | -| Capacity alerts | ✅ Shipped | dp-12 scheduled task + email; dp-17 delivery log | -| Authorization hardening | ✅ Shipped | dp-11 policy + cart-item ownership | - -## Current Backlog - -Improvement items from the 2026-04-26 audit pass: - -- **dp-14**: Rate-limit reservation endpoints to 10 req/min per authenticated user — ✅ Shipped (PR #17) -- **dp-16**: Documentation sync: align 03-API + 05-ADMIN-UI + 09-IMPLEMENTATION with post-dp-13 architecture — ✅ Shipped (this PR) -- **dp-17**: Alert delivery log + `AlertDeliveryFailed` escalation event — ✅ Shipped (PR #18) -- **dp-18**: Capacity-fanout performance: batch Pterodactyl reads in `ResourceCalculationService` to reduce O(locations × nodes) API calls in admin views — Pending - ---- - -## Testing Strategy - -### Unit Tests - -Test individual service methods in isolation. - -```php -class PricingCalculatorServiceTest extends TestCase -{ - public function test_linear_pricing_calculation() - { - $service = new PricingCalculatorService(); - - // Mock pricing config - $this->mockPricingConfig(1, [ - 'pricing_model' => 'linear', - 'pricing_config' => json_encode([ - 'base_price' => 5, - 'memory_per_gb' => 0.50, - 'cpu_per_core' => 2.00, - 'disk_per_gb' => 0.02, - ]), - ]); - - $result = $service->calculate(1, [ - 'memory' => 8192, // 8GB - 'cpu' => 400, // 4 cores - 'disk' => 102400, // 100GB - ]); - - $this->assertEquals(19.00, $result['total']); - $this->assertEquals('linear', $result['model']); - } - - public function test_tiered_pricing_with_multiple_tiers() - { - // ... - } - - public function test_base_addon_with_no_overage() - { - // ... - } -} -``` - -### Integration Tests - -Integration tests against a real or mocked Pterodactyl API are not yet implemented. The suite is unit-level with limited feature coverage. - -Current test coverage under `tests/Unit/` is mostly mocked service/listener validation. `tests/Feature/` currently covers admin API authorization/response behavior plus the skipped SetupWizard placeholder. - -### Feature Tests - -Test full HTTP request/response cycles. - -```php -class AdminApiTest extends TestCase -{ - public function test_admin_capacity_endpoint_returns_structure() - { - $response = $this->actingAs($this->admin) - ->getJson('/api/dynamic-pterodactyl/admin/capacity'); - - $response->assertOk() - ->assertJsonStructure([ - 'success', - 'data' => [ - 'locations', - 'generated_at', - ], - ]); - } -} -``` - -### Browser Tests - -No browser/E2E suite is implemented in this extension yet. Frontend slider verification is currently manual. - -## SetupWizard Atomicity + Audit Reliability (dp-13) - -`createDynamicSliderOptions()` is wrapped in `DB::transaction()`. Mid-batch failures (pricing rule throw, FK violation, transient DB error) roll back all writes atomically. The audit entry is written post-commit; failure is structured-logged and reported but does not affect the transaction outcome. - -`safeAudit()` lives in `Services/Concerns/AuditsExtensionActions.php` and is used by both `ReservationService` and `ConfigOptionSetupService`. Audit failures emit `Log::warning('extension audit write failed', [...])` so they appear in normal application logs even without a configured exception reporter. - ---- - -## Risk Mitigation - -### Risk 1: Pterodactyl API Changes - -**Probability**: Low -**Impact**: High (breaks availability calculation) - -**Mitigation**: -- Abstract API calls behind interface -- Version check on connection test -- Monitor Pterodactyl changelog - -### Risk 2: Database Deadlocks - -**Probability**: Medium (under high concurrency) -**Impact**: Medium (failed reservations) - -**Mitigation**: -- Transaction retry logic (5 attempts) -- Proper index ordering -- Monitor deadlock frequency in logs - -### Risk 3: Stale Availability Data - -**Probability**: Low (real-time API) -**Impact**: Medium (overselling) - -**Mitigation**: -- Final verification at payment -- No caching of availability -- Reservation system as buffer - -### Risk 4: Frontend JavaScript Conflicts - -**Probability**: Medium -**Impact**: Low (broken sliders, not data loss) - -**Mitigation**: -- Namespace all code (`window.DynamicPterodactyl`) -- No global jQuery modifications -- Graceful degradation (fallback to number inputs) - -### Risk 5: Admin Misconfiguration - -**Probability**: High -**Impact**: Medium (incorrect pricing) - -**Mitigation**: -- Form validation prevents invalid configs -- Preview calculator before saving -- Audit logging of all changes - ---- - -## Deployment Checklist - -### Pre-Deployment - -- [ ] All migrations tested on copy of production data -- [ ] Pterodactyl API credentials configured -- [ ] Connection test passes -- [ ] At least one pricing config created -- [ ] Backup database - -### Deployment Steps - -1. Enable maintenance mode -2. Run migrations: `php artisan migrate` -3. Clear caches: `php artisan cache:clear` -4. Register scheduled jobs (cleanup is wired; alerts are not yet scheduled) -5. Disable maintenance mode -6. Verify dashboard loads -7. Test one product with sliders -8. Monitor error logs for 30 minutes - -### Post-Deployment - -- [ ] Verify scheduled jobs running (check `telescope` or logs) -- [ ] Create test reservation, let it expire -- [ ] Complete full purchase flow -- [ ] Check audit log records creation - ---- - -## Rollback Plan - -If critical issues discovered: - -1. **Immediate**: Deactivate all pricing configs (disables sliders) -2. **If needed**: Roll back migration (data loss of configs) - ```bash - php artisan migrate:rollback --step=4 - ``` -3. **Emergency**: Remove extension folder entirely - -Data in `ptero_resource_reservations` is ephemeral and can be safely dropped. - -## Scheduler wiring status - -Expired-reservation cleanup and `AlertService::checkCapacityAlerts()` are both scheduled in `DynamicPterodactyl.php::boot()`. Cleanup runs every minute; capacity checks run every five minutes with `withoutOverlapping()`. - -## Capacity Alerts + Reservation Observability (dp-12) - -`AlertService::checkCapacityAlerts()` is scheduled every 5 minutes in `DynamicPterodactyl.php::boot()`. For each active `ptero_alert_configs` row it: (1) respects `cooldown_minutes` via `last_notification_at`, (2) reads live utilization via `ResourceCalculationService`, (3) dispatches to email (all admins with `role_id`) and/or webhook, (4) writes one `capacity_alert_sent` audit row summarising channels + severity + breached resources. - -`ReservationService::{confirm,cancel,cleanupExpired}` write audit rows on successful state transitions. `confirm` / `cancel` are per-reservation. `cleanupExpired` writes one batch row per run with `count`. Token values are stored as `token_prefix` only. - -Both services use the shared `AuditsExtensionActions` trait (dp-13) — audit failure is best-effort and does not abort business logic. - ---- - -## Performance Benchmarks - -Target performance (measure during testing): - -| Operation | Target | Acceptable | -|-----------|--------|------------| -| Availability fetch | < 200ms | < 500ms | -| Price calculation | < 50ms | < 100ms | -| Reservation creation | < 300ms | < 500ms | -| Dashboard load | < 1s | < 2s | -| Slider response | < 100ms | < 200ms | - -If targets not met, profile with Laravel Telescope or Debugbar. +# Implementation and Verification + +## Runtime boundaries + +- Paymenter 1.5.6, Filament 5, Livewire 4. +- Paymenter core owns pricing, cart/order/service persistence, and the built-in Pterodactyl provisioner. +- Dynamic Pterodactyl owns capacity reads, node selection, reservation identity, and lifecycle validation. +- Pterodactyl Panel and Wings 1.12.3+ own actual server state after a verified + create or build update. + +## Required cross-repository changes + +The Paymenter companion branch must include: + +- transactional cart add/edit operations; +- atomic login ownership transfer; +- fail-closed checkout refresh and service binding; +- correct `ServiceConfig.slider_value` serialization; +- Pterodactyl `createServer()` begin/complete/fail integration; +- exact reserved-node and resource overrides; +- exact allocation-ID create and lifecycle reconciliation; +- guest-safe complete-vector quote JavaScript that fails checkout closed; +- durable payment, cancellation, retry, and capacity-aware upgrade state + machines. + +The extension branch must include: + +- checkout identity and provisioning migrations; +- `ReservationConfigurationService`; +- server-owned cart listeners; +- provisioning lifecycle methods; +- lowercase option creation/backfill; +- per-node authoritative CPU policy and overcommit; +- exact allocation claims and fixed-node upgrade deltas; +- legacy readiness and forward-only migration gates. + +Deploy the Paymenter 1.5.6 baseline and extension migrations together. Migrating the extension without the companion core leaves no caller for service binding/provisioning consumption. + +## Verification matrix + +| Area | Required proof | +|---|---| +| Upgrade | Paymenter reports 1.5.6 dependency versions | +| Cart | reservation failure rolls back create and edit | +| Identity | resource, plan, location, quantity, currency, or price change changes fingerprint | +| Guest | cart owner and pending holds transfer atomically | +| Checkout | missing, expired, foreign, or mismatched hold rolls back order/service | +| Payment delay | hold expiry extends to invoice due time | +| Paid state | payment creates non-expiring capacity and service remains provisioning | +| Concurrency | duplicate cart hold and duplicate provisioning worker are rejected | +| Node binding | outbound request uses stored node, limits, and allocation IDs | +| Retry | existing external server consumes a pending row idempotently | +| Serialization | slider value reaches lowercase service properties and `getServiceProperties()` | +| CPU | configured physical capacity, overcommit, live server limits, and holds produce the exact bound | +| Bounds | 32 GiB configured / 23 GiB feasible clamps to 23; 100 GiB feasible keeps 32 | +| Upgrade | fixed-node positive delta, immutable source/target, payment, PATCH, and reconciliation | +| Cancellation | exact external absence precedes capacity and product-stock release | +| Ports | quote, hold, create, retry, and cancellation preserve exact allocation identity | + +Run the Paymenter PHPUnit suite, the extension PHPUnit suite against a dedicated test database, frontend lint/build, and migration up/down checks. The extension test bootstrap must retain its test-database guard. + +## Operational checks + +After deployment: + +1. Back up both databases and enter Paymenter maintenance mode. +2. Deploy/migrate Paymenter core, then run the extension migration/readiness + command. Do not leave maintenance if schema activation or readiness fails. +3. Restart queue workers and verify the scheduler and failed-job monitoring. +4. Confirm Panel and Wings are 1.12.3 or newer, API credentials have node, + server, user, location, nest/egg, and allocation access, and panel URLs + canonicalize to the same identity. +5. Reconcile every legacy commitment reported by the readiness gate. +6. Configure and enable a CPU policy for each dedicated dynamic-stock node. +7. Confirm all configured resource/location keys are lowercase and quantity is + disabled. +8. Run a real-panel guest checkout canary for 32/23 and 32/100 bounds, payment, + queue retry, exact resources/ports, cancellation, and a paid upgrade. +9. Verify reservation `node_id`, resources, owner, nest/egg, and allocation IDs + exactly equal the Pterodactyl server. +10. Verify confirmed rows have `consumed_at`, no active lease, and that external + panel/automation writes are disabled on managed nodes. diff --git a/AGENTS.md b/AGENTS.md index c393314..09695e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,19 +6,19 @@ ## OVERVIEW -Nested Paymenter `Other` extension that complements the built-in Pterodactyl provisioner. It owns real-time capacity reads, short-lived reservations, lifecycle hooks, and Filament administration; Paymenter core remains the slider-pricing authority and the built-in server extension remains the provisioning authority. +Nested Paymenter `Other` extension that complements the built-in Pterodactyl provisioner. It owns real-time capacity reads, server-owned reservations, lifecycle validation, and Filament administration; Paymenter core remains the slider-pricing authority and the built-in server extension remains the provisioning authority. ## STRUCTURE ```text DynamicPterodactyl/ ├── DynamicPterodactyl.php # boot, install/uninstall, policy, listeners, schedules -├── Admin/ # Filament 4 pages/resources; see Admin/AGENTS.md -├── Http/ # customer/admin API controllers, request, middleware -├── Listeners/ # cart, invoice, and service lifecycle adapters +├── Admin/ # Filament 5 pages/resources; see Admin/AGENTS.md +├── Http/ # aggregate customer and raw admin APIs +├── Listeners/ # transactional cart lifecycle adapters ├── Services/ # reservation, capacity, alert, setup, audit core ├── Models/ # 4 ptero_* models plus AlertConfig observer -├── database/migrations/ # 8 migrations, including retired pricing-table drop +├── database/migrations/ # 10 migrations, including checkout identity and key normalization ├── resources/views/admin/ # 1:1 Blade views for standalone admin pages ├── routes/api.php # required from boot(); /api/dynamic-pterodactyl/* ├── tests/ # isolated harness; see tests/AGENTS.md @@ -32,9 +32,9 @@ DynamicPterodactyl/ | Extension lifecycle / schedules | `DynamicPterodactyl.php` | cleanup every minute; alerts every five minutes | | Reservation state changes | `Services/ReservationService.php` | `Listeners/`, `Models/ResourceReservation.php` | | Capacity / node choice | `Services/ResourceCalculationService.php`, `Services/NodeSelectionService.php` | `08-ALGORITHMS.md` | -| Checkout/payment reconciliation | `Listeners/CartItemCreatedListener.php`, `Listeners/InvoicePaidListener.php` | `04-EVENTS.md` | -| Live API surface | `routes/api.php`, `Http/Controllers/Api/` | code outranks retired endpoints in `03-API.md` | -| Core slider metadata / price preview | `Services/SliderConfigReaderService.php`, `Http/Controllers/Api/PricingController.php` | core `Plan` and `ConfigOption` methods own math | +| Checkout/provisioning reconciliation | `Services/ReservationService.php`, companion Pterodactyl `createServer()` | `04-EVENTS.md` | +| Live API surface | `routes/api.php`, `Http/Controllers/Api/` | `03-API.md` | +| Customer stock quotes | `Services/ResourceQuoteService.php`, `Services/UpgradeReservationService.php`, quote controllers | complete-vector bounds; core `Plan` and `ConfigOption` methods own pricing | | Admin pages and actions | `Admin/AGENTS.md` | `resources/views/admin/` | | Tables / casts / lifecycle values | `Models/`, `database/migrations/` | `DECISIONS.md` before stale schema prose | | Test harness and coverage | `tests/AGENTS.md`, `phpunit.xml` | `tests/bootstrap.php` enforces DB isolation | @@ -48,10 +48,11 @@ Caller counts are CodeGraph static counts; Laravel events, container resolution, |---|---|---|---:|---| | `DynamicPterodactyl::boot()` | method | `DynamicPterodactyl.php` | runtime | route, policy, observer, listener, schedule root | | `ResourceCalculationService` | class | `Services/ResourceCalculationService.php` | 41 | uncached panel reads and cluster capacity snapshots | -| `ReservationService` | class | `Services/ReservationService.php` | 26 | reservation state machine, locking, idempotency, audit | +| `ReservationService` | class | `Services/ReservationService.php` | cross-layer | cart, login, checkout, and provisioning state machine | +| `ReservationConfigurationService` | class | `Services/ReservationConfigurationService.php` | cross-layer | canonical payload and fingerprint authority | | `AlertService` | class | `Services/AlertService.php` | 14 | threshold checks, delivery, shortfall notifications | | `NodeSelectionService` | class | `Services/NodeSelectionService.php` | 11 | best-fit scoring over current capacity | -| `SliderConfigReaderService` | class | `Services/SliderConfigReaderService.php` | 5 | native `dynamic_slider` metadata reader | +| `ResourceQuoteService` | class | `Services/ResourceQuoteService.php` | customer | complete-vector checkout bounds without node disclosure | | `ConfigOptionSetupService` | class | `Services/ConfigOptionSetupService.php` | admin | transactional setup-wizard writes into core options | | `ResourceReservation` | model | `Models/ResourceReservation.php` | cross-layer | shared API, policy, service, listener, admin record | | `routes/api.php` | route entry | `routes/api.php` | runtime | customer, checkout, and admin route groups | @@ -60,12 +61,13 @@ Caller counts are CodeGraph static counts; Laravel events, container resolution, - Tables use `ptero_*`; memory/disk use MB, CPU uses percent, money uses `decimal(10,2)`, JSON keys use `snake_case`. - Reservation lifecycle is `pending -> confirmed | expired | cancelled`; `released` is retired. -- Active retries use `idempotency_key`; reservation writes use transactions, `lockForUpdate()`, and deadlock retry. -- Audit payloads store `token_prefix`, never a full reservation token; audit writes are best-effort around primary state changes. -- Controllers validate/authorize then delegate. Customer responses expose aggregate capacity; raw node data is admin-only. -- Route budgets are explicit: availability/pricing/admin `30/min`, reservations `10/min`. -- Invoice-paid confirmation must re-check live availability before `ReservationService::confirm()` and notify on drift/shortfall. -- Treat current code plus `DECISIONS.md` as authoritative. `README.md`, `CLAUDE.md`, and numbered specs retain some retired names and endpoints. +- One pending hold is keyed by `cart_item_id`; reservation writes use transactions, `lockForUpdate()`, and deadlock retry. +- Tokens are internal/admin-only and never appear in browser, cart, service, URL, or audit state. +- Controllers validate/authorize then delegate. Customer responses expose only complete-vector bounds; raw node data is admin-only. +- Quote and admin route budgets are explicit at `30/min`; there is no customer reservation, independent-max availability, or extension pricing route. +- Provisioning must call `beginProvisioning()` and `completeProvisioning()` around the external create request. +- CPU is never treated as hard node capacity without an external authoritative inventory. +- Treat current code plus `DECISIONS.md` as authoritative. - Record live work in `PROGRESS.md`; record newly settled architecture in `DECISIONS.md`. ## ANTI-PATTERNS (THIS PROJECT) @@ -75,14 +77,14 @@ Caller counts are CodeGraph static counts; Laravel events, container resolution, - Do not cache Pterodactyl responses or snapshots; availability is a real-time contract. - Do not expose node-level capacity on customer routes. - Do not add a local `composer.json` or frontend bundle; the outer app supplies autoloading and native sliders. -- Do not copy Filament v3 APIs into this Filament 4 codebase. +- Do not copy legacy Filament v3/v4 APIs into this Filament 5 codebase. - Do not introduce `released`, `base_plus_addon`, full-token audit fields, or retired API endpoints. - Do not commit from the outer Paymenter worktree; this directory is its own git checkout. ## UNIQUE STYLES - Routes are loaded by `boot()` rather than extension-local framework discovery. -- Four Paymenter events adapt cart/invoice/service lifecycle into the reservation service. +- Cart created/updated/deleting events adapt cart lifecycle into the reservation service; provisioning is a direct companion-core integration. - Schedules are named inline closures in `boot()`; there are no local Job/Command classes. - Filament resources perform actions through services; standalone pages own 1:1 namespaced Blade views. - Pricing preview calls core `Plan::dynamicSliderBasePrice()` and `ConfigOption::calculateDynamicPriceDelta()` directly. diff --git a/Admin/AGENTS.md b/Admin/AGENTS.md index f1b2acb..c02b60f 100644 --- a/Admin/AGENTS.md +++ b/Admin/AGENTS.md @@ -2,7 +2,7 @@ ## OVERVIEW -Filament 4 administration surface for Dynamic Pterodactyl. Standalone `Page` classes own dashboard-style screens and bind to 1:1 Blade views; `Resource` classes own model tables/forms and delegate state-changing actions to services. +Filament 5 administration surface for Dynamic Pterodactyl. Standalone `Page` classes own dashboard-style screens and bind to 1:1 Blade views; `Resource` classes own model tables/forms and delegate state-changing actions to services. ## STRUCTURE @@ -34,7 +34,7 @@ Paired views live outside this directory at `../resources/views/admin/`. - Standalone screens extend `Filament\Pages\Page`; resources extend `Filament\Resources\Resource` and register nested page classes through `getPages()`. - Page views are explicitly namespaced: `dynamic-pterodactyl::admin.dashboard`, `setup-wizard`, `node-monitoring`, and `audit-log`; keep PHP page names and Blade filenames paired 1:1. - Resource page classes stay depth-four under `Resources//Pages/` and only point `protected static string $resource` back to the resource unless header actions are needed. -- Use Filament 4 APIs: `Schema $schema`, schema layouts from `Filament\Schemas\Components\*`, inputs from `Filament\Forms\Components\*`, table `recordActions()`, and top-level `Filament\Actions\*` imports. +- Use Filament 5 APIs: `Schema $schema`, schema layouts from `Filament\Schemas\Components\*`, inputs from `Filament\Forms\Components\*`, table `recordActions()`, and top-level `Filament\Actions\*` imports. - `SetupWizard` may be large, but persistence belongs in `ConfigOptionSetupService::createDynamicSliderOptions()`; the page gathers state, validates wizard flow, warns on existing options, and reports setup results. - `ReservationResource` is not a create surface: `canCreate()` returns `false`; extend/cancel/cleanup actions call `ReservationService` with the authenticated actor. - `AlertConfigResource` owns CRUD form/table composition; test notifications call `AlertService::sendTestNotification()`. @@ -48,4 +48,4 @@ Paired views live outside this directory at `../resources/views/admin/`. - Do not make reservations manually creatable from Filament. - Do not bypass `ConfigOptionSetupService` from the setup wizard to write core config options inline. - Do not treat `AlertConfigResource` saves as UI-only changes; observer and alert-service side effects are part of the contract. -- Do not mix Filament v3 resource APIs such as legacy table actions into these Filament 4 resources. +- Do not mix legacy Filament v3/v4 resource APIs into these Filament 5 resources. diff --git a/Admin/Pages/Dashboard.php b/Admin/Pages/Dashboard.php index b6f18f4..f709fe3 100644 --- a/Admin/Pages/Dashboard.php +++ b/Admin/Pages/Dashboard.php @@ -92,7 +92,7 @@ private function buildLocationCapacity(array $snapshot, int $locationId): array { $locationSnapshot = $snapshot['by_location'][$locationId] ?? [ 'nodes' => [], - 'totals' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], + 'totals' => ['memory' => 0, 'cpu' => null, 'disk' => 0], 'allocated' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], ]; @@ -106,7 +106,7 @@ private function buildLocationCapacity(array $snapshot, int $locationId): array 'nodes' => $nodes, 'max_available' => [ 'memory' => \collect($nodes)->max('available.memory') ?? 0, - 'cpu' => \collect($nodes)->max('available.cpu') ?? 0, + 'cpu' => null, 'disk' => \collect($nodes)->max('available.disk') ?? 0, ], 'total_capacity' => $locationSnapshot['totals'], diff --git a/Admin/Pages/NodeMonitoring.php b/Admin/Pages/NodeMonitoring.php index 91c1078..cc295cf 100644 --- a/Admin/Pages/NodeMonitoring.php +++ b/Admin/Pages/NodeMonitoring.php @@ -32,7 +32,7 @@ public function getViewData(): array foreach ($snapshot['locations'] as $location) { $locationSnapshot = $snapshot['by_location'][$location['id']] ?? [ 'nodes' => [], - 'totals' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], + 'totals' => ['memory' => 0, 'cpu' => null, 'disk' => 0], 'allocated' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], ]; diff --git a/Admin/Pages/SetupWizard.php b/Admin/Pages/SetupWizard.php index 38f1935..f8ba77d 100644 --- a/Admin/Pages/SetupWizard.php +++ b/Admin/Pages/SetupWizard.php @@ -82,327 +82,382 @@ public function form(Schema $schema): Schema return $schema ->components([ Form::make([ - Tabs::make('Wizard')->tabs([ - - // TAB 1: Product Selection - Tabs\Tab::make('Product') - ->icon('heroicon-o-cube') - ->schema([ - Select::make('product_id') - ->label('Select Product') - ->options(fn () => Product::pluck('name', 'id')) - ->required() - ->searchable() - ->live() - ->afterStateUpdated(function (Get $get, Set $set, ?int $state) { - if ($state) { - $this->checkExistingOptions($state); - } else { - $this->existingOptions = null; - $this->showOverwriteWarning = false; - } - }), - - Placeholder::make('existing_warning') - ->label('') - ->content(fn () => $this->showOverwriteWarning - ? '⚠️ This product already has ' . ($this->existingOptions['existing_count'] ?? 0) . ' dynamic slider config option(s). Running the wizard will update them.' - : '') - ->visible(fn () => $this->showOverwriteWarning), - - Section::make('Pricing Model') - ->schema([ - Select::make('pricing_model') - ->options([ - 'linear' => 'Linear (per-unit pricing)', - 'tiered' => 'Tiered (volume discounts)', - 'base_addon' => 'Base + Addon', - ]) - ->default('linear') - ->live() - ->required(), - - TextInput::make('base_price') - ->label('Base Price') - ->prefix('$') - ->numeric() - ->default(0) - ->helperText('Monthly base price before resource costs'), - ]), - ]), - - // TAB 2: Resource Sliders - Tabs\Tab::make('Sliders') - ->icon('heroicon-o-adjustments-horizontal') - ->schema([ - // Memory Slider - Section::make('Memory Slider') - ->schema([ - Toggle::make('enable_memory_slider') - ->label('Enable Memory Slider') - ->default(true) - ->live(), - Grid::make(4) - ->visible(fn (Get $get) => $get('enable_memory_slider')) - ->schema([ - TextInput::make('memory_min') - ->label('Min') - ->suffix('GB') + Tabs::make('Wizard')->tabs([ + + // TAB 1: Product Selection + Tabs\Tab::make('Product') + ->icon('heroicon-o-cube') + ->schema([ + Select::make('product_id') + ->label('Select Product') + ->options(fn () => Product::query() + ->where('hidden', false) + ->whereHas('server', fn ($query) => $query + ->where('extension', 'Pterodactyl') + ->where('enabled', true)) + ->pluck('name', 'id')) + ->required() + ->searchable() + ->live() + ->afterStateUpdated(function (Get $get, Set $set, ?int $state) { + if ($state) { + $this->checkExistingOptions($state); + } else { + $this->existingOptions = null; + $this->showOverwriteWarning = false; + } + }), + + Placeholder::make('existing_warning') + ->label('') + ->content(fn () => $this->showOverwriteWarning + ? '⚠️ This product already has '.($this->existingOptions['existing_count'] ?? 0).' dynamic slider config option(s). Running the wizard will update them.' + : '') + ->visible(fn () => $this->showOverwriteWarning), + + Section::make('Pricing Model') + ->schema([ + Select::make('pricing_model') + ->options([ + 'linear' => 'Linear (per-unit pricing)', + 'tiered' => 'Tiered (volume discounts)', + 'base_addon' => 'Base + Addon', + ]) + ->default('linear') + ->live() + ->required(), + + TextInput::make('base_price') + ->label('Base Price') + ->prefix('$') + ->numeric() + ->required() + ->minValue(0) + ->default(0) + ->helperText('Monthly base price before resource costs'), + ]), + ]), + + // TAB 2: Resource Sliders + Tabs\Tab::make('Sliders') + ->icon('heroicon-o-adjustments-horizontal') + ->schema([ + // Memory Slider + Section::make('Memory Slider') + ->schema([ + Toggle::make('enable_memory_slider') + ->label('Enable Memory Slider') + ->default(true) + ->live(), + Grid::make(4) + ->visible(fn (Get $get) => $get('enable_memory_slider')) + ->schema([ + TextInput::make('memory_min') + ->label('Min') + ->suffix('GB') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(1), + TextInput::make('memory_max') + ->label('Max') + ->suffix('GB') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(64), + TextInput::make('memory_step') + ->label('Step') + ->suffix('GB') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(1), + TextInput::make('memory_default') + ->label('Default') + ->suffix('GB') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(4), + ]), + ]), + + // CPU Slider + Section::make('CPU Slider') + ->schema([ + Toggle::make('enable_cpu_slider') + ->label('Enable CPU Slider') + ->default(true) + ->live(), + Grid::make(4) + ->visible(fn (Get $get) => $get('enable_cpu_slider')) + ->schema([ + TextInput::make('cpu_min') + ->label('Min') + ->suffix('cores') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(1), + TextInput::make('cpu_max') + ->label('Max') + ->suffix('cores') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(8), + TextInput::make('cpu_step') + ->label('Step') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(1), + TextInput::make('cpu_default') + ->label('Default') + ->suffix('cores') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(2), + ]), + ]), + + // Disk Slider + Section::make('Disk Slider') + ->schema([ + Toggle::make('enable_disk_slider') + ->label('Enable Disk Slider') + ->default(true) + ->live(), + Grid::make(4) + ->visible(fn (Get $get) => $get('enable_disk_slider')) + ->schema([ + TextInput::make('disk_min') + ->label('Min') + ->suffix('GB') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(10), + TextInput::make('disk_max') + ->label('Max') + ->suffix('GB') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(500), + TextInput::make('disk_step') + ->label('Step') + ->suffix('GB') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(10), + TextInput::make('disk_default') + ->label('Default') + ->suffix('GB') + ->numeric() + ->required() + ->minValue(0.0001) + ->default(50), + ]), + ]), + ]), + + // TAB 3: Pricing Details + Tabs\Tab::make('Pricing') + ->icon('heroicon-o-currency-dollar') + ->schema([ + // LINEAR pricing + Section::make('Linear Rates') + ->description('Set price per unit for each resource type') + ->visible(fn (Get $get) => $get('pricing_model') === 'linear') + ->schema([ + Grid::make(3)->schema([ + TextInput::make('memory_rate') + ->label('Memory Rate') + ->prefix('$') + ->suffix('/GB/mo') ->numeric() - ->default(1), - TextInput::make('memory_max') - ->label('Max') - ->suffix('GB') + ->required() + ->minValue(0) + ->default(0.50), + TextInput::make('cpu_rate') + ->label('CPU Rate') + ->prefix('$') + ->suffix('/core/mo') ->numeric() - ->default(64), - TextInput::make('memory_step') - ->label('Step') - ->suffix('GB') + ->required() + ->minValue(0) + ->default(2.00), + TextInput::make('disk_rate') + ->label('Disk Rate') + ->prefix('$') + ->suffix('/GB/mo') ->numeric() - ->default(1), - TextInput::make('memory_default') - ->label('Default') + ->required() + ->minValue(0) + ->default(0.02), + ]), + ]), + + // TIERED pricing + Section::make('Memory Tiers') + ->description('Volume discounts - price decreases as quantity increases') + ->visible(fn (Get $get) => $get('pricing_model') === 'tiered') + ->schema([ + Repeater::make('memory_tiers') + ->schema([ + TextInput::make('up_to') + ->label('Up to (GB)') + ->numeric() + ->minValue(0) + ->placeholder('∞ (leave empty for unlimited)'), + TextInput::make('rate') + ->label('Price per GB') + ->prefix('$') + ->numeric() + ->minValue(0) + ->required(), + ]) + ->columns(2) + ->addActionLabel('Add Tier') + ->defaultItems(2) + ->default([ + ['up_to' => 8, 'rate' => 0.75], + ['up_to' => null, 'rate' => 0.50], + ]), + ]), + + Section::make('CPU Tiers') + ->visible(fn (Get $get) => $get('pricing_model') === 'tiered') + ->schema([ + Repeater::make('cpu_tiers') + ->schema([ + TextInput::make('up_to') + ->label('Up to (cores)') + ->numeric() + ->minValue(0) + ->placeholder('∞'), + TextInput::make('rate') + ->label('Price per core') + ->prefix('$') + ->numeric() + ->minValue(0) + ->required(), + ]) + ->columns(2) + ->addActionLabel('Add Tier') + ->defaultItems(1) + ->default([ + ['up_to' => null, 'rate' => 2.00], + ]), + ]), + + Section::make('Disk Tiers') + ->visible(fn (Get $get) => $get('pricing_model') === 'tiered') + ->schema([ + Repeater::make('disk_tiers') + ->schema([ + TextInput::make('up_to') + ->label('Up to (GB)') + ->numeric() + ->minValue(0) + ->placeholder('∞'), + TextInput::make('rate') + ->label('Price per GB') + ->prefix('$') + ->numeric() + ->minValue(0) + ->required(), + ]) + ->columns(2) + ->addActionLabel('Add Tier') + ->defaultItems(1) + ->default([ + ['up_to' => null, 'rate' => 0.02], + ]), + ]), + + // BASE + ADDON pricing + Section::make('Included Resources') + ->description('Resources included in base price') + ->visible(fn (Get $get) => $get('pricing_model') === 'base_addon') + ->schema([ + Grid::make(3)->schema([ + TextInput::make('memory_included') + ->label('Included Memory') ->suffix('GB') ->numeric() + ->required() + ->minValue(0) ->default(4), - ]), - ]), - - // CPU Slider - Section::make('CPU Slider') - ->schema([ - Toggle::make('enable_cpu_slider') - ->label('Enable CPU Slider') - ->default(true) - ->live(), - Grid::make(4) - ->visible(fn (Get $get) => $get('enable_cpu_slider')) - ->schema([ - TextInput::make('cpu_min') - ->label('Min') - ->suffix('cores') - ->numeric() - ->default(1), - TextInput::make('cpu_max') - ->label('Max') - ->suffix('cores') - ->numeric() - ->default(8), - TextInput::make('cpu_step') - ->label('Step') - ->numeric() - ->default(1), - TextInput::make('cpu_default') - ->label('Default') + TextInput::make('cpu_included') + ->label('Included CPU') ->suffix('cores') ->numeric() + ->required() + ->minValue(0) ->default(2), - ]), - ]), - - // Disk Slider - Section::make('Disk Slider') - ->schema([ - Toggle::make('enable_disk_slider') - ->label('Enable Disk Slider') - ->default(true) - ->live(), - Grid::make(4) - ->visible(fn (Get $get) => $get('enable_disk_slider')) - ->schema([ - TextInput::make('disk_min') - ->label('Min') - ->suffix('GB') - ->numeric() - ->default(10), - TextInput::make('disk_max') - ->label('Max') - ->suffix('GB') - ->numeric() - ->default(500), - TextInput::make('disk_step') - ->label('Step') - ->suffix('GB') - ->numeric() - ->default(10), - TextInput::make('disk_default') - ->label('Default') + TextInput::make('disk_included') + ->label('Included Disk') ->suffix('GB') ->numeric() + ->required() + ->minValue(0) ->default(50), ]), - ]), - ]), - - // TAB 3: Pricing Details - Tabs\Tab::make('Pricing') - ->icon('heroicon-o-currency-dollar') - ->schema([ - // LINEAR pricing - Section::make('Linear Rates') - ->description('Set price per unit for each resource type') - ->visible(fn (Get $get) => $get('pricing_model') === 'linear') - ->schema([ - Grid::make(3)->schema([ - TextInput::make('memory_rate') - ->label('Memory Rate') - ->prefix('$') - ->suffix('/GB/mo') - ->numeric() - ->default(0.50), - TextInput::make('cpu_rate') - ->label('CPU Rate') - ->prefix('$') - ->suffix('/core/mo') - ->numeric() - ->default(2.00), - TextInput::make('disk_rate') - ->label('Disk Rate') - ->prefix('$') - ->suffix('/GB/mo') - ->numeric() - ->default(0.02), ]), - ]), - - // TIERED pricing - Section::make('Memory Tiers') - ->description('Volume discounts - price decreases as quantity increases') - ->visible(fn (Get $get) => $get('pricing_model') === 'tiered') - ->schema([ - Repeater::make('memory_tiers') - ->schema([ - TextInput::make('up_to') - ->label('Up to (GB)') - ->numeric() - ->placeholder('∞ (leave empty for unlimited)'), - TextInput::make('rate') - ->label('Price per GB') + + Section::make('Overage Rates') + ->description('Price for resources beyond included amount') + ->visible(fn (Get $get) => $get('pricing_model') === 'base_addon') + ->schema([ + Grid::make(3)->schema([ + TextInput::make('memory_overage') + ->label('Memory Overage') ->prefix('$') + ->suffix('/GB/mo') ->numeric() - ->required(), - ]) - ->columns(2) - ->addActionLabel('Add Tier') - ->defaultItems(2) - ->default([ - ['up_to' => 8, 'rate' => 0.75], - ['up_to' => null, 'rate' => 0.50], - ]), - ]), - - Section::make('CPU Tiers') - ->visible(fn (Get $get) => $get('pricing_model') === 'tiered') - ->schema([ - Repeater::make('cpu_tiers') - ->schema([ - TextInput::make('up_to') - ->label('Up to (cores)') - ->numeric() - ->placeholder('∞'), - TextInput::make('rate') - ->label('Price per core') + ->required() + ->minValue(0) + ->default(0.75), + TextInput::make('cpu_overage') + ->label('CPU Overage') ->prefix('$') + ->suffix('/core/mo') ->numeric() - ->required(), - ]) - ->columns(2) - ->addActionLabel('Add Tier') - ->defaultItems(1) - ->default([ - ['up_to' => null, 'rate' => 2.00], - ]), - ]), - - Section::make('Disk Tiers') - ->visible(fn (Get $get) => $get('pricing_model') === 'tiered') - ->schema([ - Repeater::make('disk_tiers') - ->schema([ - TextInput::make('up_to') - ->label('Up to (GB)') - ->numeric() - ->placeholder('∞'), - TextInput::make('rate') - ->label('Price per GB') + ->required() + ->minValue(0) + ->default(3.00), + TextInput::make('disk_overage') + ->label('Disk Overage') ->prefix('$') + ->suffix('/GB/mo') ->numeric() - ->required(), - ]) - ->columns(2) - ->addActionLabel('Add Tier') - ->defaultItems(1) - ->default([ - ['up_to' => null, 'rate' => 0.02], + ->required() + ->minValue(0) + ->default(0.05), ]), - ]), - - // BASE + ADDON pricing - Section::make('Included Resources') - ->description('Resources included in base price') - ->visible(fn (Get $get) => $get('pricing_model') === 'base_addon') - ->schema([ - Grid::make(3)->schema([ - TextInput::make('memory_included') - ->label('Included Memory') - ->suffix('GB') - ->numeric() - ->default(4), - TextInput::make('cpu_included') - ->label('Included CPU') - ->suffix('cores') - ->numeric() - ->default(2), - TextInput::make('disk_included') - ->label('Included Disk') - ->suffix('GB') - ->numeric() - ->default(50), ]), - ]), - - Section::make('Overage Rates') - ->description('Price for resources beyond included amount') - ->visible(fn (Get $get) => $get('pricing_model') === 'base_addon') - ->schema([ - Grid::make(3)->schema([ - TextInput::make('memory_overage') - ->label('Memory Overage') - ->prefix('$') - ->suffix('/GB/mo') - ->numeric() - ->default(0.75), - TextInput::make('cpu_overage') - ->label('CPU Overage') - ->prefix('$') - ->suffix('/core/mo') - ->numeric() - ->default(3.00), - TextInput::make('disk_overage') - ->label('Disk Overage') - ->prefix('$') - ->suffix('/GB/mo') - ->numeric() - ->default(0.05), + ]), + + // TAB 4: Locations + Tabs\Tab::make('Locations') + ->icon('heroicon-o-map-pin') + ->schema([ + Section::make('Location Selection') + ->description('Select which Pterodactyl locations to enable for this product') + ->schema([ + Select::make('locations') + ->label('Allowed Locations') + ->multiple() + ->options(fn () => $this->getLocationOptions()) + ->helperText('Select one or more customer choices, or leave empty only when the product has exactly one valid static Pterodactyl location.'), ]), - ]), - ]), - - // TAB 4: Locations - Tabs\Tab::make('Locations') - ->icon('heroicon-o-map-pin') - ->schema([ - Section::make('Location Selection') - ->description('Select which Pterodactyl locations to enable for this product') - ->schema([ - Select::make('locations') - ->label('Allowed Locations') - ->multiple() - ->options(fn () => $this->getLocationOptions()) - ->helperText('Select locations to create a location selector. Leave empty to skip location config option.'), - ]), - ]), - ])->columnSpanFull(), + ]), + ])->columnSpanFull(), ]), ]) ->statePath('data'); @@ -447,6 +502,22 @@ public function setupConfigOptions(): void if (! empty($selectedLocationIds)) { $allLocations = app(ResourceCalculationService::class)->getLocations(); $locations = array_filter($allLocations, fn ($loc) => in_array($loc['id'], $selectedLocationIds)); + $resolvedLocationIds = collect($locations) + ->pluck('id') + ->map(fn ($id) => (string) $id) + ->sort() + ->values() + ->all(); + $submittedLocationIds = collect($selectedLocationIds) + ->map(fn ($id) => (string) $id) + ->unique() + ->sort() + ->values() + ->all(); + + if ($resolvedLocationIds !== $submittedLocationIds) { + throw new \InvalidArgumentException('One or more selected locations no longer exist on the configured Pterodactyl panel.'); + } } // Call the service to create config options @@ -500,7 +571,7 @@ protected function getLocationOptions(): array $locations = app(ResourceCalculationService::class)->getLocations(); return collect($locations)->mapWithKeys(fn ($loc) => [ - $loc['id'] => ($loc['long'] ?: $loc['short']) . ' (ID: ' . $loc['id'] . ')', + $loc['id'] => ($loc['long'] ?: $loc['short']).' (ID: '.$loc['id'].')', ])->toArray(); } catch (\Exception $e) { return []; diff --git a/Admin/Resources/AlertConfigResource.php b/Admin/Resources/AlertConfigResource.php index f1732fe..6335b7a 100644 --- a/Admin/Resources/AlertConfigResource.php +++ b/Admin/Resources/AlertConfigResource.php @@ -2,18 +2,18 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Admin\Resources; +use Filament\Actions\Action as TableAction; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; -use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Section; use Filament\Forms\Components\Select; use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; -use Filament\Actions\Action as TableAction; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; @@ -43,10 +43,7 @@ public static function form(Schema $schema): Schema return $schema->components([ Select::make('location_id') ->label('Location') - ->options(fn () => array_merge( - ['' => 'Global (All Locations)'], - self::getLocationOptions() - )) + ->options(fn () => self::getScopedLocationOptions()) ->placeholder('Global (All Locations)'), Toggle::make('is_active') @@ -70,6 +67,20 @@ public static function form(Schema $schema): Schema ->default(95) ->minValue(1) ->maxValue(100), + TextInput::make('cpu_warning_threshold') + ->label('CPU Warning') + ->suffix('%') + ->numeric() + ->default(80) + ->minValue(1) + ->maxValue(100), + TextInput::make('cpu_critical_threshold') + ->label('CPU Critical') + ->suffix('%') + ->numeric() + ->default(95) + ->minValue(1) + ->maxValue(100), TextInput::make('disk_warning_threshold') ->label('Disk Warning') ->suffix('%') @@ -133,9 +144,11 @@ public static function table(Table $table): Table TextColumn::make('thresholds') ->label('Thresholds') ->getStateUsing(fn ($record) => sprintf( - 'Mem: %d%%/%d%% | Disk: %d%%/%d%%', + 'Mem: %d%%/%d%% | CPU: %d%%/%d%% | Disk: %d%%/%d%%', $record->memory_warning_threshold, $record->memory_critical_threshold, + $record->cpu_warning_threshold, + $record->cpu_critical_threshold, $record->disk_warning_threshold, $record->disk_critical_threshold )), @@ -187,6 +200,16 @@ private static function getLocationOptions(): array } } + /** + * Array union preserves numeric Pterodactyl location IDs. array_merge() + * would silently reindex them from zero and save the wrong location. + */ + private static function getScopedLocationOptions(): array + { + return ['' => 'Global (All Locations)'] + + self::getLocationOptions(); + } + private static function getLocationName(int $locationId): string { $locations = self::getLocationOptions(); diff --git a/Admin/Resources/NodeCapacityPolicyResource.php b/Admin/Resources/NodeCapacityPolicyResource.php new file mode 100644 index 0000000..e9cf216 --- /dev/null +++ b/Admin/Resources/NodeCapacityPolicyResource.php @@ -0,0 +1,165 @@ +components([ + Section::make('Authoritative CPU Stock') + ->description( + 'CPU is measured in Pterodactyl percent: 100% equals one logical core. ' + .'Enabling a policy dedicates that node to reservation-backed products; ' + .'Paymenter static provisioning and non-capacity upgrades are blocked there. ' + .'Nodes without an enabled policy are excluded from dynamic stock.' + ) + ->schema([ + Select::make('node_uuid') + ->label('Pterodactyl Node') + ->options(fn (): array => self::nodeOptions()) + ->searchable() + ->required(), + TextInput::make('cpu_capacity_percent') + ->label('Physical CPU Capacity') + ->suffix('%') + ->helperText('For example, an 8-thread node has 800% physical capacity.') + ->numeric() + ->integer() + ->minValue(1) + ->maxValue(NodeCapacityPolicy::MAX_CPU_CAPACITY_PERCENT) + ->required(), + TextInput::make('cpu_overcommit_bps') + ->label('CPU Overcommit Ratio') + ->suffix('basis points') + ->helperText('10,000 = 1.0×, 15,000 = 1.5×, 20,000 = 2.0×.') + ->numeric() + ->integer() + ->default(10000) + ->minValue(1) + ->maxValue(NodeCapacityPolicy::MAX_CPU_OVERCOMMIT_BPS) + ->required(), + Toggle::make('enabled') + ->label('Dedicate this node to dynamic stock') + ->helperText( + 'Enabled nodes must not receive servers or resource changes outside the reservation-backed flow.' + ) + ->default(true), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('node_id')->label('Node ID')->sortable(), + TextColumn::make('node_uuid')->label('Node UUID')->copyable(), + TextColumn::make('cpu_capacity_percent') + ->label('Physical CPU') + ->suffix('%') + ->numeric(), + TextColumn::make('cpu_overcommit_bps') + ->label('Overcommit') + ->formatStateUsing( + fn (int $state): string => number_format($state / 10000, 2).'×' + ), + TextColumn::make('effective_cpu') + ->label('Effective CPU') + ->getStateUsing( + fn (NodeCapacityPolicy $record): string => number_format($record->effectiveCpuCapacity()).'%' + ), + IconColumn::make('enabled')->boolean(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->toolbarActions([ + DeleteBulkAction::make(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => ListNodeCapacityPolicies::route('/'), + 'create' => CreateNodeCapacityPolicy::route('/create'), + 'edit' => EditNodeCapacityPolicy::route('/{record}/edit'), + ]; + } + + /** + * Resolve panel and numeric node identity from the live Pterodactyl + * inventory instead of accepting either value from an administrator form. + * + * @param array $data + * @return array + */ + public static function withInventoryIdentity(array $data): array + { + $inventory = app(PterodactylInventoryService::class); + $node = collect($inventory->nodes())->firstWhere( + 'uuid', + (string) ($data['node_uuid'] ?? '') + ); + + if (! is_array($node)) { + throw ValidationException::withMessages([ + 'node_uuid' => 'The selected node is no longer present in Pterodactyl.', + ]); + } + + $data['node_id'] = (int) $node['id']; + $data['location_id'] = (int) $node['location_id']; + $data['panel_identity'] = $inventory->panelIdentity(); + + return $data; + } + + private static function nodeOptions(): array + { + try { + return collect(app(PterodactylInventoryService::class)->nodes()) + ->mapWithKeys(fn (array $node): array => [ + $node['uuid'] => sprintf('%s (#%d)', $node['name'], $node['id']), + ]) + ->all(); + } catch (\Throwable $exception) { + report($exception); + + return []; + } + } +} diff --git a/Admin/Resources/NodeCapacityPolicyResource/Pages/CreateNodeCapacityPolicy.php b/Admin/Resources/NodeCapacityPolicyResource/Pages/CreateNodeCapacityPolicy.php new file mode 100644 index 0000000..b24429e --- /dev/null +++ b/Admin/Resources/NodeCapacityPolicyResource/Pages/CreateNodeCapacityPolicy.php @@ -0,0 +1,16 @@ +label('Price') - ->money('usd'), + ->formatStateUsing( + fn ( + mixed $state, + ResourceReservation $record + ): string => self::formatPrice( + $state, + $record->currency_code + ) + ), TextColumn::make('expires_at') ->label('Expires') ->dateTime() @@ -92,7 +100,10 @@ public static function table(Table $table): Table ->form([ TextInput::make('minutes') ->label('Minutes to add') - ->numeric() + ->integer() + ->minValue(1) + ->maxValue(60) + ->step(1) ->default(15) ->required(), ]) @@ -126,6 +137,33 @@ public static function table(Table $table): Table ->poll('30s'); } + private static function formatPrice( + mixed $state, + mixed $currencyCode + ): string { + $amount = is_int($state) + ? (string) $state + : (is_string($state) ? $state : ''); + if ( + preg_match( + '/^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/D', + $amount, + $matches + ) !== 1 + ) { + return 'Price unavailable'; + } + $amount = $matches[1] + .'.' + .str_pad($matches[2] ?? '', 2, '0'); + $currency = strtoupper(trim((string) $currencyCode)); + if (preg_match('/^[A-Z]{3}$/D', $currency) !== 1) { + return "{$amount} (currency unavailable)"; + } + + return "{$currency} {$amount}"; + } + public static function getPages(): array { return [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 812e654..8aba229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,28 +7,52 @@ Milestone and release notes. For day-to-day progress, see PROGRESS.md. ## [Unreleased] ### Added -- Scheduled `AlertService::checkCapacityAlerts()` every 5 minutes via `DynamicPterodactyl.php::boot()` (dp-12). -- `Notifications/CapacityAlertNotification` mail notification for capacity threshold breaches (dp-12). -- Audit trail for capacity alert dispatch and reservation state transitions (`confirm`, `cancel`, `cleanupExpired`) using the shared `AuditsExtensionActions` trait (dp-12). -- Reservation create endpoint now supports `Idempotency-Key` / `idempotency_key` dedupe with active-reservation reuse semantics. +- Guest-safe complete-vector checkout quotes and authenticated fixed-node + upgrade quotes with step-aligned live bounds. +- Explicit per-node CPU capacity and basis-point overcommit policies. +- Exact primary/additional allocation claims, fixed-port mappings, and + dedicated-IP selection. +- Seven-day invoice guarantees, non-expiring paid commitments, + provisioning/upgrade leases, queue retries, reconciliation, and operator + attention states. +- Capacity-aware RAM/CPU/disk upgrades with immutable source/target snapshots + and positive-delta reservations. +- Durable Pterodactyl server, user, panel, node, nest, egg, resource, and + allocation identity for lifecycle actions. +- Managed-node isolation for Paymenter static provisioning and raw upgrades. +- Explicit extension migration/readiness command and forward-only upload gate. +- PHP 8.3/8.4 cross-repository CI on SQLite, MariaDB 11, and MariaDB 12. ### Changed -- (dp-11) Authorization hardened for reservations: `StoreReservationRequest::authorize()` now verifies cart-item ownership; `ReservationController::get|cancel|extend` now use `ResourceReservationPolicy` (Filament panel access via `User::canAccessPanel()`) instead of the broken `is_admin` check; `ReservationService::cancel|extend|confirm` accept an optional `?User $actor` and authorize via the policy when supplied (defence-in-depth). -- (dp-11) Surface reduced: deleted retired `PricingController::validate` (410 stub from dp-09) and `ReservationService::getAll()` (no callers — `queryAll()` is the live path); narrowed five internal helpers (`presentReservation`, `getActiveByIdempotencyKey`, `calculateNodeAvailability`, `createResourceOption`, `createLocationOption`) to `private`. -- (dp-10) The extension-consumed `dynamic_slider` component now exposes the full WAI-ARIA APG attribute set, keyboard PageUp/Down/Home/End support, loading/error UI states, and a 44px touch target. No extension code changes required — these are fork-side core improvements. -- `released` reservation status removed from schema and PHP enum. Lifecycle: `pending → confirmed | expired | cancelled`. -- `base_plus_addon` pricing model alias removed from `PricingConfigValidator`. Use `base_addon`. -- `/availability/{locationId}/nodes` API route moved to admin-only middleware group. -- Pricing preview now delegates to Paymenter core (`Plan::dynamicSliderBasePrice()` + `ConfigOption::calculateDynamicPriceDelta()`), `PricingCalculatorService` was renamed to `SliderConfigReaderService`, and `PricingConfigValidator` was retired in favor of core `DynamicSliderPricingRule`. +- Rebased the companion implementation onto Paymenter 1.5.6, Filament 5, + and Livewire 4. +- Replaced customer reservation tokens and independent availability maxima + with one server-owned cart hold and complete-vector quote contract. +- Provisioning now overrides the exact node, location, RAM, CPU, disk, and + allocation IDs, then activates the service only after reconciliation. +- Dynamic products force quantity one; wizard reruns retire disabled sliders + and deselected locations safely. +- Pricing base is stored once on the plan and all range, step, decimal, and + final-tier coverage rules are validated server-side. +- Dynamic service status, identity, configuration, product stock, and paid + upgrade mutations are owned by explicit fulfillment coordinators. ### Fixed -- Capacity-alert email delivery: replaced `Log::info('email would be sent')` stub in `AlertService::sendNotifications()` with real admin-user fan-out (dp-12). -- Payment-time reservation confirmation now excludes the reservation itself from pending-capacity math, so exact-fit purchases can confirm successfully. -- Reservation create requests now enforce product slider bounds and reject unconfigured products instead of persisting arbitrary resource selections. -- Availability `has_capacity` now requires memory, CPU, and disk to all be positive, with per-resource booleans exposed in the API response. -- Wrapped `ConfigOptionSetupService::createDynamicSliderOptions()` in `DB::transaction()` to prevent orphaned config_option rows on mid-batch failure (dp-13). -- Extracted `safeAudit()` into shared `AuditsExtensionActions` trait; audit failures now emit `Log::warning` in addition to `report()` (dp-13). -- Extension `phpunit.xml` now mirrors root phpunit.xml test-isolation env overrides; `tests/bootstrap.php` guards against running against a non-test DB (dp-13). +- Unsupported Pterodactyl `filter[location_id]` requests; all node pages are + read and location-filtered locally. +- RAM/disk maxima coming from different nodes, missing CPU inventory, + maintenance/private nodes, unsafe unlimited limits, and optimistic API + permission fallbacks. +- Duplicate holds, stale slider idempotency, guest ownership drift, + URL/session token exposure, and fail-open cart/checkout paths. +- Paid-active-without-server state, one-attempt provisioning, stale worker + completion, cancellation/create races, and external-ID-only lifecycle + targeting. +- Unencrypted extension API credentials and silent extension migration + success. +- Wizard base-price duplication, stale options/locations, off-step values, + uncovered pricing tiers, unsupported quantity, and raw dynamic upgrades. +- Cross-panel node-ID collisions and mutable legacy lifecycle identity. --- diff --git a/CLAUDE.md b/CLAUDE.md index 518ebe1..64896e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,251 +1,43 @@ -# CLAUDE.md - -Quick reference for implementing this extension. Read this first. - -## Project Context - -Paymenter extension enabling dynamic resource sliders (RAM, CPU, Disk) for Pterodactyl game server products with real-time pricing and availability. - -**Pattern**: Companion Extension — enhances the built-in Pterodactyl extension without reimplementing server provisioning. - -## Key Decisions (Already Settled) - -These were debated and decided. Don't re-litigate without checking DECISIONS.md: - -- ✅ Real-time Pterodactyl API — NO caching -- ✅ 15-minute reservation TTL with pessimistic locking -- ✅ Three pricing models only: linear, tiered, base+addon (now in Paymenter core) -- ✅ Filament for admin UI (matches Paymenter) -- ✅ Native `dynamic_slider` config option type for frontend (replaced noUiSlider) -- ✅ Best-fit node selection with weighted scoring (50% mem, 35% disk, 15% cpu) -- ✅ Extension focuses on reservations/availability only (pricing moved to core) - -## Conventions - -### Database -- Table prefix: `ptero_*` -- Money: `decimal(10,2)` -- Memory/Disk: stored in **MB** (display as GB in UI) -- CPU: stored as **percentage** (100 = 1 core, 400 = 4 cores) -- JSON columns use `snake_case` keys - -### Code -- Services: one class per concern, in `Services/` -- Models: in `Models/`, match table name without prefix -- Controllers: thin, delegate to services -- Filament: Pages for dashboards, Resources for CRUD - -### Naming -``` -PricingConfig (model) -ptero_pricing_configs (table) -PricingConfigResource (Filament resource) -``` - -## Documentation Map - -| Need to... | Read | -|------------|------| -| Understand tables/models | 01-DATABASE.md | -| Implement business logic | 02-SERVICES.md | -| Build API endpoints | 03-API.md | -| Hook into Paymenter events | 04-EVENTS.md | -| Build admin interface | 05-ADMIN-UI.md | -| Build customer sliders | 06-FRONTEND.md | -| Understand pricing math | 07-PRICING-MODELS.md | -| Understand node selection | 08-ALGORITHMS.md | -| See roadmap/phases | 09-IMPLEMENTATION.md | - -## Extension Location - -``` -extensions/Others/DynamicPterodactyl/ -``` - -## Useful Commands - -```bash -# Database -php artisan migrate -php artisan migrate:rollback --step=4 - -# Cache (clear after config changes) -php artisan cache:clear -php artisan config:clear -php artisan view:clear - -# Scheduler (runs cleanup + alerts) -php artisan schedule:work - -# Manual testing -php artisan tinker ->>> app(ResourceCalculationService::class)->testConnection() -``` - -## Paymenter Patterns to Follow - -Before implementing, check how the built-in Pterodactyl extension does things: -``` -app/Extensions/Servers/Pterodactyl/ -``` - -Key files to reference: -- `Pterodactyl.php` — main extension class structure -- How it registers routes -- How it uses `ExtensionHelper::getConfig()` - -## When Implementing - -1. Check PROGRESS.md for current state -2. Read relevant doc section -3. Follow existing Paymenter patterns -4. Update PROGRESS.md when done -5. Note any decisions/blockers encountered - -## Common Gotchas - -- Paymenter uses Filament v4, not v3 -- CSRF token required for API POST requests from frontend -- Cart item `properties` is JSON — merge, don't replace -- Pterodactyl API rate limit: 240/min (we use ~10) - ---- - -## Context Compaction Protocol - -**When nearing context limits, BEFORE compacting:** - -1. **Update PROGRESS.md immediately** with: - - Current task and exact stopping point - - Any uncommitted decisions - - What's working, what's broken - - Next specific action to take - -2. **If debugging**, add to PROGRESS.md: - - The error/problem - - What you've tried - - Current hypothesis - -3. **If mid-implementation**, note in PROGRESS.md: - - Files created/modified this session - - What's complete vs. partial - - Any temporary code or TODOs added - -4. **Flush any new decisions** to DECISIONS.md - -5. **Tell the user** you're about to compact so they can save context if needed - -### Quick Checkpoint Template - -Copy this to PROGRESS.md "Current Session State" when checkpointing: - -```markdown -### Checkpoint [timestamp] -**Working on**: [specific task] -**Status**: [working|stuck|almost done] -**Current file**: [path] -**Last completed**: [what] -**Next action**: [specific next step] -**Blockers**: [any issues] -**Notes**: [anything else important] -``` - -## Test Isolation Mandate (dp-13) -Extension phpunit MUST run with `DB_DATABASE=paymenter_test`. The phpunit.xml `` block enforces this; tests/bootstrap.php aborts if violated. See DECISIONS.md for rationale. - - -## CodeRabbit Review Mandate (dp-process-audit, 2026-04-24; v2 2026-04-24) - -Every PR against `dynamic-slider` (and any parent-repo branch) MUST follow `.sisyphus/templates/ralph-loop-contract.md`. This is non-negotiable after the 2026-04-24 incident where two consecutive PRs (#10, #11) merged within minutes of opening, zero CodeRabbit reviews submitted. - -**`.coderabbit.yaml` deployed (v2, 2026-04-24)** - -Both repos now carry a `.coderabbit.yaml` on their default branch: -- `Jordanmuss99/dynamic-pterodactyl` — auto-review on `dp-.*` and `dynamic-slider.*` branches; auto-pause after 10 commits; `request_changes_workflow: true`; `fail_commit_status: true` -- `ObsidianNetwork/Paymenter-Obsidian-Network` — auto-review on `dynamic-slider.*`; same settings - -CodeRabbit reviews automatically on push. No manual `@coderabbitai review` mention needed unless review is absent after ~2 minutes. - -**Required pre-PR local gate**: before `gh pr create`, run: - -```bash -cr review --plain --type committed --base dynamic-slider -``` - -If the working branch is a `dp-*` branch that targets `dynamic-slider`, this local CLI pass is mandatory. - -**Pre-merge gate** (mechanical, run immediately before `gh pr merge`): - -```bash -bash .sisyphus/templates/ralph-loop-verify.sh \ - \ - --repo \ - --expected-base -``` - -Example for extension PR: - -```bash -bash .sisyphus/templates/ralph-loop-verify.sh \ - \ - --repo Jordanmuss99/dynamic-pterodactyl \ - --expected-base '^dynamic-slider$' -``` - -Exit 0 = safe to merge. Non-zero = DO NOT merge. Do not bypass the script. -Use `--allow-actionable --reason "..."`, `--allow-direct-default --reason "..."`, or `--skip-quiet-period --reason "..."` only with explicit driver approval; reasons are audit-logged to `.sisyphus/notepads/ralph-loop-waivers.jsonl`. - -**After APPROVED**: do not rush. Wait the Rule 8 quiet period before merge. Preferred command: - -```bash -bash .sisyphus/templates/ralph-loop-verify.sh \ - \ - --repo Jordanmuss99/dynamic-pterodactyl \ - --expected-base '^dynamic-slider$' \ - --wait -``` - -**Template drift check**: before editing local contract/verifier files, or after syncing from outer Paymenter, run: - -```bash -bash .sisyphus/templates/ralph-loop-verify.sh --check-sync -``` - -**PR author identity** (what CodeRabbit actually checks for entitlement): - -CodeRabbit evaluates the GitHub login on the PR itself (whoever ran `gh pr create` / clicked "Open pull request"). The fork is Pro-entitled under `Jordanmuss99`. The 2026-04-24 incident root cause: PRs #10 and #11 were opened while `gh` was active as `ImStillBlue` (a different account logged in on this host), so CodeRabbit saw them as Free-tier and skipped review. PR #9 opened as `Jordanmuss99` received 3 reviews with the exact same commit authors. - -Before `gh pr create` you MUST: - -```bash -gh auth switch -u Jordanmuss99 -login=$(gh api /user --jq .login) -[ "$login" = "Jordanmuss99" ] || { echo "ABORT: active gh user is $login"; exit 1; } -``` - -PR author on GitHub is immutable. If you open as the wrong user, close the PR and reopen — there is no way to reassign. - -**Commit author email** (secondary — belt-and-braces): - -```bash -git config user.name "Jordanmuss99" -git config user.email "164892154+Jordanmuss99@users.noreply.github.com" -``` - -`164892154+Jordanmuss99@users.noreply.github.com` is the default safe form — it avoids the GH007 push-rejection that blocks `jordanmuss@hotmail.com` (hotmail is marked private on the GitHub account). Use the hotmail address only if the Jordanmuss99 GitHub account's email-privacy setting is changed to allow public email pushes. - -**Orchestrator rule**: when a subagent claims a PR is opened or merged, the driver MUST independently run: - -```bash -gh pr view --repo --json author,createdAt,mergedAt,statusCheckRollup \ - --jq '{author: .author.login, createdAt, mergedAt, cr_status: ([.statusCheckRollup[] | select(.name=="CodeRabbit")] | first | .state)}' -``` - -If `author != "Jordanmuss99"`, OR `mergedAt - createdAt < 3 minutes`, OR `cr_status != "SUCCESS"`, treat as a contract violation. See `.sisyphus/notepads/dp-process-audit/incident-2026-04-24.md` for remediation. - -## Enforceable rules (CodeRabbit reads these) - -- FAIL when: Pterodactyl API responses are cached. Rationale: real-time queries are a settled decision (DECISIONS.md). Rate budget is ~10/min against the 240/min panel limit. -- FAIL when: pricing logic is added to this extension's admin interface or services. Rationale: pricing moved to Paymenter core per DECISIONS.md. The `ptero_pricing_configs` table was dropped in migration `2025_01_01_000005`. -- FAIL when: server provisioning is reimplemented here (createServer, suspendServer, terminateServer). Rationale: companion extension — delegate to `extensions/Servers/Pterodactyl/`. -- FAIL when: changes to this extension are committed from the outer Paymenter repo working tree. Rationale: this directory has its own `.git/`. Use `cd extensions/Others/DynamicPterodactyl && git commit`. +# Dynamic Pterodactyl contributor guide + +Read `README.md`, `DECISIONS.md`, and the nearest `AGENTS.md` before changing +this extension. The July 2026 decisions supersede older browser-reservation, +Filament 4, static-slider, and non-authoritative CPU designs. + +## Current contract + +- Paymenter 1.5.6, Filament 5, Livewire 4, PHP 8.3/8.4. +- Pterodactyl Panel and Wings 1.12.3 or newer. +- Paymenter core owns slider rendering and pricing. +- This extension owns live complete-vector stock, reservations, CPU policy, + allocation claims, and capacity-aware upgrade commitments. +- The built-in Paymenter Pterodactyl extension owns server API mutations. +- Dynamic products fail closed when authoritative stock is unavailable. +- Customer quote responses never expose raw node identity. +- Cart holds last 15 minutes; checkout guarantees capacity for exactly seven + days; successful payment creates a non-expiring `paid_committed` commitment. +- Enabled node-capacity policies dedicate those nodes to the reservation-backed + lifecycle. External panel administrators and automation must not mutate them. +- Quantity is one. Upgrades reserve positive deltas on the existing node. + +## Safety rules + +- Never cache Pterodactyl stock responses. +- Never restore browser reservation tokens or customer reservation endpoints. +- Never calculate independent RAM/CPU/disk maxima; quote the complete resource + vector against one eligible node and its exact free allocations. +- Never infer immutable panel, server, user, node, egg, or allocation identity + for legacy commitments. +- Never mark a capacity-backed invoice paid outside the atomic payment + coordinator. +- Never mark a service active until the external server and allocation set + match its signed commitment. +- Keep Paymenter and this extension's remediation branches deployable together. + +## Required checks + +Run Paymenter's PHP 8.3/8.4 SQLite and MariaDB 11/12 matrix, the extension's +cross-repository matrix, JavaScript stock tests, migration readiness, and a real +Pterodactyl staging canary. Deploy in maintenance mode: core migrations first, +then extension migrations/readiness, restart queue workers, and only then leave +maintenance mode. diff --git a/DECISIONS.md b/DECISIONS.md index ebf655d..a7216dd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -18,8 +18,10 @@ Architectural decisions with rationale. Check here before re-debating a settled **Rationale**: - Built-in extension has ~300 lines of server provisioning code - Reimplementing = bugs + missing upstream fixes -- Companion pattern: we handle pricing/sliders, built-in handles server creation -- If our extension fails, graceful degradation (product still works, just no sliders) +- Companion pattern: Paymenter core handles pricing/sliders, this extension + handles stock commitments, and the built-in extension creates the server. +- Dynamic products fail closed if the stock extension is unavailable; a static + fallback would permit overselling. **Trade-off**: Dependent on built-in extension structure. If Paymenter changes it significantly, we need to adapt. @@ -36,7 +38,8 @@ Architectural decisions with rationale. Check here before re-debating a settled - PteroSync (WHMCS module) uses real-time API successfully in production - Caching introduces staleness → overselling risk - Cache invalidation is complex (server created outside our system, admin changes, etc.) -- Pterodactyl allows 240 requests/minute; we use ~5-10 per checkout +- Quote traffic must remain within the deployment's configured Pterodactyl API + rate limit; frontend requests are debounced and superseded reads are aborted - 200ms API call is acceptable for checkout flow **Trade-off**: Slightly slower than cached reads. Acceptable. @@ -104,8 +107,8 @@ Architectural decisions with rationale. Check here before re-debating a settled --- ### Reservation TTL -**Decision**: 15 minutes, extendable to 30 -**Date**: November 2025 +**Decision**: 15-minute cart hold followed by an exact seven-day invoice guarantee +**Date**: November 2025 (revised July 2026) **Status**: Final **Context**: How long to hold resources during checkout? @@ -114,18 +117,20 @@ Architectural decisions with rationale. Check here before re-debating a settled - Too short (5 min): Customer can't complete checkout, frustrating - Too long (1 hour): Resources hoarded, artificial scarcity - 15 minutes: Enough for checkout, not enough to hurt others -- Extension on checkout page: prevents expiry mid-payment +- Checkout atomically converts the cart hold into a seven-day invoice guarantee +- Successful payment converts it into a non-expiring `paid_committed` commitment - Cleanup job runs every minute: resources released promptly -**Trade-off**: Edge case where slow customer loses reservation. Acceptable — they can re-add to cart. +**Trade-off**: Unpaid invoices reserve stock for seven days. This is an explicit +business choice; expired invoices are cancelled and their stock is released. --- ## User Interface ### Filament for Admin -**Decision**: Filament v4 -**Date**: November 2025 +**Decision**: Filament v5 +**Date**: November 2025 (revised July 2026) **Status**: Final **Context**: What framework for admin UI? @@ -197,6 +202,43 @@ Architectural decisions with rationale. Check here before re-debating a settled --- +## Decisions locked 2026-07-25 + +These decisions supersede the earlier Filament 4, browser-reservation, invoice-confirmation, and CPU-weighted node-selection decisions. + +### 12. Paymenter baseline + +The companion fork targets Paymenter 1.5.6, Filament 5, and Livewire 4. The previous 1.4.7 baseline is release-blocking because it predates applicable security fixes. + +### 13. Reservation ownership + +Exactly one server-owned reservation exists per dynamic cart item. Customer reservation endpoints, bearer tokens, session storage, Livewire token state, and browser idempotency are retired. + +### 14. Immutable checkout identity + +Every new hold stores a canonical payload and SHA-256 fingerprint covering customer, cart, Paymenter server extension, hashed panel identity, product, plan, location, node, resources, quantity, currency, calculated price, pricing version, formula version, and option metadata. The capacity reader and provisioner must resolve to the same normalized panel URL. Guest login atomically updates both row ownership and the customer identity embedded in that payload. + +### 15. Provisioning is the consume boundary + +Checkout binds and extends a pending hold; it does not confirm it. The built-in Pterodactyl provisioner leases the row, overrides the actual node/resource settings, and marks it confirmed only after the external server create succeeds. + +### 16. CPU uses explicit per-node inventory (revised 2026-07-26) + +Pterodactyl does not publish physical node CPU capacity, so the extension owns +an explicit `NodeCapacityPolicy` keyed by normalized panel identity and node +UUID/ID/location. Administrators configure physical capacity in Pterodactyl +percentage (`100` = one logical core) and an overcommit ratio in basis points. +Enabled policies dedicate their nodes to the reservation-backed flow. Live +server CPU limits and local commitments are subtracted from effective policy +capacity; a missing, disabled, or identity-mismatched policy makes the node +ineligible. + +### 17. Normalized provisioner keys + +Dynamic option environment variables are lowercase `memory`, `cpu`, `disk`, and `location`. This is the casing consumed by the built-in Pterodactyl extension. The normalization migration is intentionally irreversible. + +--- + ## How to Propose a Change If you believe a decision should be reconsidered: @@ -240,7 +282,9 @@ Admin-only. Customers never see raw node-level data (node names, FQDNs, maintena ### 5. SetupWizard feature-test shipped status -Unit coverage accepted for dp-06. The full Filament-action lifecycle end-to-end test is deferred to **dp-13** (SetupWizard atomicity + audit-log reliability), since that plan touches `ConfigOptionSetupService` and it's the natural place to wire the E2E test. The skipped placeholder in `tests/Feature/SetupWizardValidationTest.php` carries a `// TODO dp-13:` marker tracking this. +The dp-13 SetupWizard atomicity and audit-reliability work shipped. Current +coverage exercises validation and transactional configuration behavior; the +cross-repository CI gate runs that suite on every remediation PR. ### 6. Test Isolation Mandate (dp-13, Apr 2026) @@ -283,3 +327,71 @@ PR author identity rule (PRIMARY): before `gh pr create`, the orchestrator and a Commit author email (secondary): git config MUST be `user.name = Jordanmuss99` and `user.email = 164892154+Jordanmuss99@users.noreply.github.com` (noreply form — default, avoids the GH007 push rejection that blocks `jordanmuss@hotmail.com`). Use the hotmail address only if the Jordanmuss99 account email-privacy setting is changed to allow public email pushes. Historical dp-11/dp-13/dp-12 commits using the noreply form are grandfathered — those PRs are merged and PR #9 proved the noreply form doesn't break auto-review. Orchestrator verification: when a subagent claims a PR is opened or merged, the orchestrator MUST independently run `gh pr view --json author,createdAt,mergedAt,reviews` and treat `author.login != "Jordanmuss99"` OR `mergedAt - createdAt < 3 minutes` OR `cr_review_count < 1` as a contract violation regardless of the subagent's text. See `.sisyphus/notepads/dp-process-audit/incident-2026-04-24.md` for the remediation protocol. + +## Decisions locked 2026-07-26 + +### 12. One customer stock contract + +The complete-vector resource quote endpoints are the only customer stock +contract: + +- `POST /products/{product}/resource-quote` for checkout; +- `POST /services/{service}/upgrade-quote` for an owned service. + +The older `/availability/{locationId}` endpoint is removed because independent +RAM/CPU/disk maxima can come from different nodes and therefore do not describe +a purchasable configuration. The extension-owned `/pricing/calculate` and +`/pricing/config/{productId}` endpoints are also removed because Paymenter core +owns pricing and slider metadata. Raw node availability remains admin-only. + +This decision supersedes the dp-09 config-reader route and the customer +aggregate-availability portion of decision 4 above. + +### 13. Supported database portability + +The extension follows Paymenter's tested MariaDB and SQLite database support. +MariaDB uses STORED generated columns for partial-unique reservation guards. +SQLite uses equivalent partial unique indexes because SQLite cannot add a +STORED generated column to an existing table. The SQLite migration also +rebuilds the enum-like status CHECK constraint to admit `paid_committed`. +Cross-repository CI must run the extension migrations and suite on PHP 8.3 and +8.4 against both database families. + +### 14. Enabled capacity-policy nodes are dedicated + +An enabled `NodeCapacityPolicy` makes that panel/node exclusive to the +reservation-backed lifecycle. Paymenter's built-in Pterodactyl provisioner +rejects static creates pinned to that node, automatic deployment through any +location containing it, and non-capacity upgrades of servers already on it. +Explicit static paths on unmanaged nodes and locations remain supported. +External panel administrators and automation remain subject to the separate +exclusive-control operational contract. + +### 15. Fixed ports are deterministic across IPs + +Product allocation mappings identify a port, not an IP. If several free IPs on +one node expose the same required port, the lowest allocation ID is selected. +Dedicated-IP requests first constrain candidates to one unused IP group and +then apply the same rule. Quote and reservation placement use the same +`AllocationSelectionService`, so both accept and select the same inventory. +Each non-`NONE` egg environment key maps to exactly one port because +Pterodactyl variables are scalar. Multiple `NONE` entries remain valid +unbound additional allocations. + +### 16. Legacy identity is never inferred + +Extension install, upgrade, and `app:extension:migrate` run a diagnostic +readiness gate after migrations. Confirmed checkout commitments and active +upgrade commitments must contain their complete immutable panel/server/user +identity and signed configuration. The gate reports reservation/service IDs +and missing fields, but never backfills from mutable product settings or a +Pterodactyl external-ID lookup. Web uploads execute the anonymous +`migration-readiness.php` contract directly from the newly activated +destination tree, so a main extension class already loaded from the old version +cannot bypass the new gate. + +Version updates are distinct from deactivation: live confirmed services still +block disable/delete/uninstall, but a same-identity update is allowed in +deployment maintenance after strict migrations/readiness and a queue-worker +restart. Failed updates restore files only; extension migrations are +forward-only and are explicitly never presented as rolled back. diff --git a/DynamicPterodactyl.php b/DynamicPterodactyl.php index 229021d..e469799 100644 --- a/DynamicPterodactyl.php +++ b/DynamicPterodactyl.php @@ -5,9 +5,8 @@ use App\Attributes\ExtensionMeta; use App\Classes\Extension\Extension; use App\Events\CartItem\Created as CartItemCreated; -use App\Events\CartItem\Deleted as CartItemDeleted; -use App\Events\Invoice\Paid as InvoicePaid; -use App\Events\Service\Created as ServiceCreated; +use App\Events\CartItem\Deleting as CartItemDeleting; +use App\Events\CartItem\Updated as CartItemUpdated; use App\Helpers\ExtensionHelper; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; @@ -15,17 +14,21 @@ use Illuminate\Support\Facades\View; use Paymenter\Extensions\Others\DynamicPterodactyl\Listeners\CartItemCreatedListener; use Paymenter\Extensions\Others\DynamicPterodactyl\Listeners\CartItemDeletedListener; -use Paymenter\Extensions\Others\DynamicPterodactyl\Listeners\InvoicePaidListener; -use Paymenter\Extensions\Others\DynamicPterodactyl\Listeners\ServiceCreatedListener; +use Paymenter\Extensions\Others\DynamicPterodactyl\Models\AlertConfig; +use Paymenter\Extensions\Others\DynamicPterodactyl\Models\Observers\AlertConfigObserver; use Paymenter\Extensions\Others\DynamicPterodactyl\Models\ResourceReservation; use Paymenter\Extensions\Others\DynamicPterodactyl\Policies\ResourceReservationPolicy; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\AlertService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\LegacyReservationReadinessService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\QuoteRateLimiterService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ReservationService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\SchedulerHealthService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\UpgradeReservationService; #[ExtensionMeta( name: 'Dynamic Pterodactyl', - description: 'Dynamic resource sliders (RAM/CPU/Disk), real-time availability, and 15-min reservations for Pterodactyl products.', - version: '3.1.0', + description: 'Dynamic RAM/CPU/disk stock with live quotes, short cart holds, and seven-day invoice guarantees for Pterodactyl products.', + version: '4.0.0', author: 'Paymenter', url: '', icon: 'heroicon-o-server', @@ -62,6 +65,7 @@ public function getConfig($values = []): array 'type' => 'password', 'description' => 'Application API key from Pterodactyl admin panel', 'required' => true, + 'encrypted' => true, ], [ 'name' => 'reservation_ttl', @@ -71,6 +75,30 @@ public function getConfig($values = []): array 'default' => 15, 'validation' => 'integer|min:5|max:60', ], + [ + 'name' => 'quote_rate_limit_per_ip', + 'label' => 'Quote rate limit per customer IP', + 'type' => 'number', + 'description' => 'Maximum checkout and upgrade stock quotes accepted from one IP address per minute.', + 'default' => 10, + 'validation' => 'integer|min:1|max:60', + ], + [ + 'name' => 'quote_rate_limit_global', + 'label' => 'Global quote rate limit', + 'type' => 'number', + 'description' => 'Maximum checkout and upgrade stock quotes accepted for this Pterodactyl panel per minute across all customers.', + 'default' => 60, + 'validation' => 'integer|min:1|max:240', + ], + [ + 'name' => 'exclusive_provisioning_control', + 'label' => 'Paymenter exclusively controls eligible nodes', + 'type' => 'checkbox', + 'description' => 'Required for strict stock guarantees. Every node with an enabled CPU policy is dedicated to reservation-backed products: Paymenter blocks its own static creates and non-capacity upgrades, and no administrator, automation, or other billing system may create, move, resize, or assign allocations there.', + 'required' => true, + 'validation' => 'accepted', + ], ]; } @@ -79,15 +107,37 @@ public function getConfig($values = []): array */ public function installed(): void { - ExtensionHelper::runMigrations('extensions/Others/DynamicPterodactyl/database/migrations'); + ExtensionHelper::runMigrationsOrFail('extensions/Others/DynamicPterodactyl/database/migrations'); + $this->assertMigrationReady(); + } + + /** + * Apply every newly shipped extension migration before upgraded code is used. + */ + public function upgraded($oldVersion = null): void + { + ExtensionHelper::runMigrationsOrFail('extensions/Others/DynamicPterodactyl/database/migrations'); + $this->assertMigrationReady(); + } + + /** + * Shared by install/upgrade and Paymenter's explicit extension migration + * command so migrations can never report success over unsafe legacy rows. + */ + public function assertMigrationReady(): void + { + app(LegacyReservationReadinessService::class)->assertReady(); } /** - * Called when extension is uninstalled. + * Durable reservation, payment-attention, allocation, and CPU-policy + * history is deliberately retained on uninstall. Core lifecycle guards + * already require all active work to be drained first; preserving the + * schema keeps completed fulfillment auditable and makes reinstall safe. */ public function uninstalled(): void { - ExtensionHelper::rollbackMigrations('extensions/Others/DynamicPterodactyl/database/migrations'); + // Intentionally no destructive migration rollback. } /** @@ -96,34 +146,87 @@ public function uninstalled(): void public function boot(): void { Gate::policy(ResourceReservation::class, ResourceReservationPolicy::class); + app(QuoteRateLimiterService::class)->register(); // Register routes - require __DIR__ . '/routes/api.php'; + require __DIR__.'/routes/api.php'; // Register views (for admin pages if any) - View::addNamespace('dynamic-pterodactyl', __DIR__ . '/resources/views'); + View::addNamespace('dynamic-pterodactyl', __DIR__.'/resources/views'); // Register event listeners for cart and checkout flow (reservations) $this->registerEventListeners(); - \Paymenter\Extensions\Others\DynamicPterodactyl\Models\AlertConfig::observe( - \Paymenter\Extensions\Others\DynamicPterodactyl\Models\Observers\AlertConfigObserver::class + AlertConfig::observe( + AlertConfigObserver::class ); // Note: Frontend sliders now handled by native Paymenter dynamic_slider config option type // The extension now only manages resource reservations and availability checks - // Scheduled cleanup: transition expired pending reservations. - // Keeps admin dashboards accurate and preserves the TTL guarantee on confirm(). - Schedule::call(fn () => app(ReservationService::class)->cleanupExpired()) + // Each lifecycle type owns an independent scheduler event and overlap + // lock. A failure in one task cannot suppress another task type. + Schedule::call( + fn () => app(SchedulerHealthService::class)->run( + SchedulerHealthService::TASK_EXPIRE_CHECKOUT, + fn () => app(ReservationService::class)->cleanupExpired() + ) + ) ->everyMinute() - ->name('dynamic-pterodactyl:cleanup-expired-reservations') - ->withoutOverlapping(); - - Schedule::call(fn () => app(AlertService::class)->checkCapacityAlerts()) + ->name('dynamic-pterodactyl:expire-checkout-reservations') + ->withoutOverlapping(5); + + Schedule::call( + fn () => app(SchedulerHealthService::class)->run( + SchedulerHealthService::TASK_EXPIRE_UPGRADES, + fn () => app(UpgradeReservationService::class) + ->expireUnpaidUpgrades() + ) + ) + ->everyMinute() + ->name('dynamic-pterodactyl:expire-upgrade-reservations') + ->withoutOverlapping(5); + + Schedule::call( + fn () => app(SchedulerHealthService::class)->run( + SchedulerHealthService::TASK_RECONCILE_CHECKOUT, + fn () => app(ReservationService::class) + ->reconcileStalledPaidCommitments() + ) + ) + ->everyTenMinutes() + ->name( + 'dynamic-pterodactyl:reconcile-paid-checkout-commitments' + ) + ->withoutOverlapping(15); + + Schedule::call( + fn () => app(SchedulerHealthService::class)->run( + SchedulerHealthService::TASK_RECONCILE_UPGRADES, + fn () => app(UpgradeReservationService::class) + ->reconcileStalledUpgrades() + ) + ) + ->everyTenMinutes() + ->name('dynamic-pterodactyl:reconcile-paid-upgrades') + ->withoutOverlapping(15); + + Schedule::call( + fn () => app(SchedulerHealthService::class)->run( + SchedulerHealthService::TASK_CAPACITY_ALERTS, + fn () => app(AlertService::class)->checkCapacityAlerts() + ) + ) ->everyFiveMinutes() ->name('dynamic-pterodactyl:check-capacity-alerts') - ->withoutOverlapping(); + ->withoutOverlapping(10); + + Schedule::call( + fn () => app(SchedulerHealthService::class)->checkForLag() + ) + ->everyFiveMinutes() + ->name('dynamic-pterodactyl:monitor-scheduler-health') + ->withoutOverlapping(10); } /** @@ -134,13 +237,10 @@ private function registerEventListeners(): void // Cart item created - create resource reservation Event::listen(CartItemCreated::class, CartItemCreatedListener::class); - // Cart item deleted - cancel reservation - Event::listen(CartItemDeleted::class, CartItemDeletedListener::class); - - // Invoice paid - confirm reservation - Event::listen(InvoicePaid::class, InvoicePaidListener::class); + // Cart item edited - replace or refresh its resource reservation + Event::listen(CartItemUpdated::class, CartItemCreatedListener::class); - // Service created - log linkage (tracking only) - Event::listen(ServiceCreated::class, ServiceCreatedListener::class); + // Cancel before deletion while the cart-item relationship still exists + Event::listen(CartItemDeleting::class, CartItemDeletedListener::class); } } diff --git a/Exceptions/InvalidResourceSelectionException.php b/Exceptions/InvalidResourceSelectionException.php new file mode 100644 index 0000000..9005394 --- /dev/null +++ b/Exceptions/InvalidResourceSelectionException.php @@ -0,0 +1,5 @@ + [], - 'totals' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], + 'totals' => ['memory' => 0, 'cpu' => null, 'disk' => 0], 'allocated' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], ]; diff --git a/Http/Controllers/Api/Admin/AdminReservationController.php b/Http/Controllers/Api/Admin/AdminReservationController.php index 0ce7ef5..4d9a46b 100644 --- a/Http/Controllers/Api/Admin/AdminReservationController.php +++ b/Http/Controllers/Api/Admin/AdminReservationController.php @@ -18,18 +18,18 @@ public function __construct(ReservationService $reservationService) public function index(Request $request): JsonResponse { $validated = $request->validate([ - 'status' => 'nullable|string|in:pending,confirmed,cancelled,expired', + 'status' => 'nullable|string|in:pending,paid_committed,confirmed,cancelled,expired', 'location_id' => 'nullable|integer', - 'node_id' => 'nullable|integer', - 'user_id' => 'nullable|integer', - 'per_page' => 'nullable|integer|min:1|max:100', + 'node_id' => 'nullable|integer', + 'user_id' => 'nullable|integer', + 'per_page' => 'nullable|integer|min:1|max:100', ]); $query = $this->reservationService->queryAll($validated); return response()->json([ 'success' => true, - 'data' => $query->paginate($validated['per_page'] ?? 25), + 'data' => $query->paginate($validated['per_page'] ?? 25), ]); } diff --git a/Http/Controllers/Api/AvailabilityController.php b/Http/Controllers/Api/AvailabilityController.php index 8b66e38..ae78e84 100644 --- a/Http/Controllers/Api/AvailabilityController.php +++ b/Http/Controllers/Api/AvailabilityController.php @@ -3,52 +3,16 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Http\Controllers\Api; use Illuminate\Http\JsonResponse; -use Paymenter\Extensions\Others\DynamicPterodactyl\Services\NodeSelectionService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ResourceCalculationService; class AvailabilityController { private ResourceCalculationService $resourceService; - private NodeSelectionService $nodeService; public function __construct( - ResourceCalculationService $resourceService, - NodeSelectionService $nodeService + ResourceCalculationService $resourceService ) { $this->resourceService = $resourceService; - $this->nodeService = $nodeService; - } - - public function getByLocation(int $locationId): JsonResponse - { - try { - $maxAvailable = $this->nodeService->getMaxAvailable($locationId); - $locationData = $this->resourceService->getLocationAvailability($locationId); - $resourceCapacity = [ - 'memory' => $maxAvailable['memory'] > 0, - 'cpu' => $maxAvailable['cpu'] > 0, - 'disk' => $maxAvailable['disk'] > 0, - ]; - - return response()->json([ - 'success' => true, - 'data' => [ - 'location_id' => $locationId, - 'max_memory' => $maxAvailable['memory'], - 'max_cpu' => $maxAvailable['cpu'], - 'max_disk' => $maxAvailable['disk'], - 'node_count' => count($locationData['nodes']), - 'has_capacity' => $resourceCapacity['memory'] && $resourceCapacity['cpu'] && $resourceCapacity['disk'], - 'resource_capacity' => $resourceCapacity, - ], - ]); - } catch (\Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to fetch availability', - 'error' => $e->getMessage(), - ], 500); - } } public function getNodes(int $locationId): JsonResponse diff --git a/Http/Controllers/Api/PricingController.php b/Http/Controllers/Api/PricingController.php deleted file mode 100644 index 374c457..0000000 --- a/Http/Controllers/Api/PricingController.php +++ /dev/null @@ -1,157 +0,0 @@ -sliderConfigReader = $sliderConfigReader; - } - - /** - * Calculate price for given resource configuration - */ - public function calculate(Request $request): JsonResponse - { - // Phase 1: validate product_id (static) - $request->validate(['product_id' => 'required|integer|exists:products,id']); - - $product = Product::query()->with(['configOptions', 'plans'])->findOrFail($request->integer('product_id')); - - // Determine which sliders are configured for this product - $sliderOptions = $product->configOptions - ->where('type', 'dynamic_slider') - ->whereNull('parent_id'); - - if ($sliderOptions->isEmpty()) { - return response()->json([ - 'success' => false, - 'message' => 'This product is not configured for dynamic pricing', - ], 404); - } - - // Phase 2: validate plan + only the configured slider fields - $rules = ['plan_id' => 'nullable|integer|exists:plans,id']; - foreach ($sliderOptions as $option) { - $resourceType = $option->getMetadata('resource_type', strtolower($option->name)); - $rules[$resourceType] = 'required|integer|min:1'; - } - - $validated = array_merge( - ['product_id' => $product->id], - $request->validate($rules) - ); - - try { - try { - $plan = $this->resolvePlan($product, $validated['plan_id'] ?? null); - } catch (\InvalidArgumentException $e) { - return response()->json([ - 'success' => false, - 'message' => $e->getMessage(), - ], 422); - } - - $breakdown = []; - $total = 0.0; - $hasSliderInScope = false; - - foreach ($sliderOptions as $option) { - $resourceType = $option->getMetadata('resource_type', strtolower($option->name)); - $value = (float) ($validated[$resourceType] ?? 0); - - if ($value <= 0) { - continue; - } - - $hasSliderInScope = true; - $price = $option->calculateDynamicPriceDelta($value, $plan->billing_period, $plan->billing_unit); - - $breakdown[] = [ - 'resource_type' => $resourceType, - 'label' => $option->name, - 'value' => $value, - 'display_value' => $option->formatValueForDisplay($value), - 'price' => round($price, 2), - 'pricing_model' => $option->getMetadata('pricing.model', 'linear'), - ]; - - $total += $price; - } - - if ($hasSliderInScope) { - $total += $plan->dynamicSliderBasePrice(); - } - - return response()->json([ - 'success' => true, - 'data' => [ - 'total' => round($total, 2), - 'breakdown' => $breakdown, - 'model' => $sliderOptions->first()?->getMetadata('pricing.model', 'linear') ?? 'linear', - ], - ]); - } catch (\Exception $e) { - Log::error('DynamicPterodactyl price calculation failed', [ - 'product_id' => $validated['product_id'], - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - $payload = [ - 'success' => false, - 'message' => 'Price calculation failed', - ]; - - if (config('app.debug')) { - $payload['error'] = $e->getMessage(); - } - - return response()->json($payload, 500); - } - } - - /** - * Get slider configuration for a product (reads from native ConfigOptions) - */ - public function getConfig(int $productId): JsonResponse - { - $config = $this->sliderConfigReader->getConfig($productId); - - if (! $config['has_config']) { - return response()->json([ - 'success' => false, - 'message' => 'No dynamic slider config options found for this product', - ], 404); - } - - return response()->json([ - 'success' => true, - 'data' => [ - 'product_id' => $productId, - 'sliders' => $config['sliders'], - ], - ]); - } - - private function resolvePlan(Product $product, ?int $planId): Plan - { - if ($planId !== null) { - return $product->plans->firstWhere('id', $planId) - ?? throw new \InvalidArgumentException('Selected plan does not belong to this product'); - } - - return $product->plans->sortBy('sort')->first() - ?? throw new \InvalidArgumentException('No plans found for this product'); - } -} diff --git a/Http/Controllers/Api/ReservationController.php b/Http/Controllers/Api/ReservationController.php deleted file mode 100644 index af76ae9..0000000 --- a/Http/Controllers/Api/ReservationController.php +++ /dev/null @@ -1,137 +0,0 @@ -reservationService = $reservationService; - } - - public function create(StoreReservationRequest $request): JsonResponse - { - $user = $request->user(); - $validated = $request->validated(); - // Guests reach this endpoint before login, so user_id intentionally stays null. - $resources = [ - 'memory' => (int) ($validated['memory'] ?? 0), - 'cpu' => (int) ($validated['cpu'] ?? 0), - 'disk' => (int) ($validated['disk'] ?? 0), - ]; - - try { - $reservation = $this->reservationService->create( - productId: $validated['product_id'], - locationId: $validated['location_id'], - resources: $resources, - cartItemId: $validated['cart_item_id'] ?? null, - userId: $user?->id, - idempotencyKey: $validated['idempotency_key'] ?? null, - ); - - return response()->json([ - 'success' => true, - 'data' => $reservation, - ]); - } catch (\RuntimeException $e) { - return response()->json([ - 'success' => false, - 'message' => $e->getMessage(), - ], 422); - } catch (\Exception $e) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to create reservation', - ], 500); - } - } - - public function get(string $token): JsonResponse - { - $reservation = ResourceReservation::query()->where('token', $token)->first(); - - if (! $reservation) { - return response()->json([ - 'success' => false, - 'message' => 'Reservation not found', - ], 404); - } - - $this->authorize('view', $reservation); - - return response()->json([ - 'success' => true, - 'data' => $reservation, - ]); - } - - public function cancel(Request $request, string $token): JsonResponse - { - $reservation = ResourceReservation::query()->where('token', $token)->first(); - - if (! $reservation) { - return response()->json([ - 'success' => false, - 'message' => 'Reservation not found', - ], 404); - } - - $this->authorize('cancel', $reservation); - - $result = $this->reservationService->cancel($token, null, 'customer', $request->user()); - - return response()->json([ - 'success' => $result, - 'message' => $result ? 'Reservation cancelled' : 'Failed to cancel reservation', - ]); - } - - public function extend(Request $request, string $token): JsonResponse - { - $validated = $request->validate([ - 'minutes' => 'integer|min:1|max:60', - ]); - - $reservation = ResourceReservation::query()->where('token', $token)->first(); - - if (! $reservation) { - return response()->json([ - 'success' => false, - 'message' => 'Reservation not found', - ], 404); - } - - $this->authorize('extend', $reservation); - - $result = $this->reservationService->extend($token, $validated['minutes'] ?? 15, $request->user()); - - if ($result) { - $updated = $this->reservationService->getByToken($token); - - return response()->json([ - 'success' => true, - 'data' => [ - 'expires_at' => $updated->expires_at, - ], - ]); - } - - return response()->json([ - 'success' => false, - 'message' => 'Failed to extend reservation', - ], 500); - } -} diff --git a/Http/Controllers/Api/ResourceQuoteController.php b/Http/Controllers/Api/ResourceQuoteController.php new file mode 100644 index 0000000..98ed533 --- /dev/null +++ b/Http/Controllers/Api/ResourceQuoteController.php @@ -0,0 +1,114 @@ +whereKey($product) + ->where('hidden', false) + ->first(); + + if ($productModel === null) { + return response()->json([ + 'message' => 'The requested product is not available.', + ], 404); + } + $productModel->loadMissing([ + 'plans.prices', + 'server', + 'configOptions', + ]); + + if ( + $productModel->stock === 0 + || ! $productModel->price()->available + || $productModel->server?->extension !== 'Pterodactyl' + || ! $productModel->usesDynamicResources() + ) { + return response()->json([ + 'message' => 'The requested product is not available.', + ], 404); + } + + try { + return response()->json([ + 'data' => $this->quotes->quote( + $productModel, + $request->validated('config_options'), + $this->excludedReservationToken( + $productModel, + $request->validated('cart_item_id') + ) + ), + ]); + } catch (InvalidResourceSelectionException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 422); + } catch (StockUnavailableException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 409); + } catch (InvalidStockConfigurationException $exception) { + report($exception); + + return response()->json([ + 'message' => 'Dynamic stock is not configured for this product.', + ], 503); + } catch (\Throwable $exception) { + report($exception); + + return response()->json([ + 'message' => 'Dynamic stock is temporarily unavailable.', + ], 503); + } + } + + private function excludedReservationToken( + Product $product, + mixed $cartItemId + ): ?string { + if ($cartItemId === null) { + return null; + } + + $cart = Cart::getOnce(); + $item = $cart->exists + ? $cart->items() + ->whereKey((int) $cartItemId) + ->where('product_id', $product->id) + ->first() + : null; + + if ($item === null) { + throw new InvalidResourceSelectionException( + 'The cart item is not available for this resource quote.' + ); + } + + return ResourceReservation::query() + // Stock accounting keeps an expired pending row authoritative + // until the cart mutation atomically retires it and releases its + // allocation claims. Let the owning cart exclude that same row + // while re-quoting so it can reach the cleanup/replacement + // transaction instead of deadlocking behind its own stale hold. + ->where('status', ResourceReservation::STATUS_PENDING) + ->where('cart_item_id', $item->id) + ->value('token'); + } +} diff --git a/Http/Controllers/Api/UpgradeQuoteController.php b/Http/Controllers/Api/UpgradeQuoteController.php new file mode 100644 index 0000000..7a2007e --- /dev/null +++ b/Http/Controllers/Api/UpgradeQuoteController.php @@ -0,0 +1,57 @@ +validate([ + 'config_options' => ['required', 'array'], + ]); + + try { + return response()->json([ + 'data' => $this->upgrades->quoteForService( + $service, + $validated['config_options'] + ), + ]); + } catch (\InvalidArgumentException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 422); + } catch (StockUnavailableException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 409); + } catch (InvalidStockConfigurationException $exception) { + report($exception); + + return response()->json([ + 'message' => 'Dynamic upgrade stock is not configured safely.', + ], 503); + } catch (\Throwable $exception) { + report($exception); + + return response()->json([ + 'message' => 'Dynamic upgrade stock is temporarily unavailable.', + ], 503); + } + } +} diff --git a/Http/Requests/ResourceQuoteRequest.php b/Http/Requests/ResourceQuoteRequest.php new file mode 100644 index 0000000..3b3e436 --- /dev/null +++ b/Http/Requests/ResourceQuoteRequest.php @@ -0,0 +1,36 @@ + ['present', 'array', 'max:50'], + 'cart_item_id' => [ + 'nullable', + 'integer', + 'min:1', + 'max:'.PHP_INT_MAX, + ], + ]; + } + + protected function failedValidation(Validator $validator): void + { + throw new HttpResponseException(response()->json([ + 'message' => 'The resource quote request is invalid.', + 'errors' => $validator->errors(), + ], 422)); + } +} diff --git a/Http/Requests/StoreReservationRequest.php b/Http/Requests/StoreReservationRequest.php deleted file mode 100644 index 3e56b87..0000000 --- a/Http/Requests/StoreReservationRequest.php +++ /dev/null @@ -1,162 +0,0 @@ -integer('cart_item_id'); - - if (! $cartItemId) { - return true; - } - - $cartItem = CartItem::query() - ->with('cart') - ->find($cartItemId); - - if (! $cartItem || ! $cartItem->cart) { - return false; - } - - return $cartItem->cart->user_id === $this->user()?->id; - } - - protected function prepareForValidation(): void - { - $this->merge([ - 'idempotency_key' => $this->header('Idempotency-Key', $this->input('idempotency_key')), - ]); - } - - public function rules(): array - { - return [ - 'product_id' => 'required|integer|exists:products,id', - 'location_id' => 'required|integer', - 'memory' => 'sometimes|integer|min:0', - 'cpu' => 'sometimes|integer|min:0', - 'disk' => 'sometimes|integer|min:0', - 'cart_item_id' => 'nullable|integer|exists:cart_items,id', - 'idempotency_key' => ['nullable', 'regex:/^[A-Za-z0-9-]{8,64}$/'], - ]; - } - - public function withValidator($validator): void - { - $validator->after(function ($validator) { - $productId = (int) $this->input('product_id'); - - if ($productId <= 0 || ! Product::query()->whereKey($productId)->exists()) { - return; - } - - $allowedLocationIds = $this->getAllowedLocationIds($productId); - $locationId = (int) $this->input('location_id'); - if ($allowedLocationIds !== [] && ! in_array($locationId, $allowedLocationIds, true)) { - $validator->errors()->add('location_id', 'The selected location is not configured for this product'); - } - - $sliders = $this->getDynamicSliderConfig($productId); - - if ($sliders === []) { - $validator->errors()->add('product_id', 'This product is not configured for dynamic reservations'); - - return; - } - - $providedResources = collect(['memory', 'cpu', 'disk']) - ->filter(fn (string $resource) => $this->exists($resource)) - ->values() - ->all(); - - foreach (array_diff(array_keys($sliders), $providedResources) as $resource) { - $validator->errors()->add($resource, ucfirst($resource) . ' is required for this product'); - } - - foreach (array_diff($providedResources, array_keys($sliders)) as $resource) { - $validator->errors()->add($resource, ucfirst($resource) . ' is not configured for this product'); - } - - foreach ($sliders as $resource => $slider) { - if (! $this->exists($resource) || $validator->errors()->has($resource)) { - continue; - } - - $value = $this->input($resource); - if (! is_numeric($value)) { - continue; - } - - $value = (int) $value; - $min = (int) ($slider['min'] ?? 0); - $max = (int) ($slider['max'] ?? 0); - $step = max(1, (int) ($slider['step'] ?? 1)); - - if ($value < $min || $value > $max) { - $validator->errors()->add($resource, ucfirst($resource) . " must be between {$min} and {$max}"); - - continue; - } - - if (($value - $min) % $step !== 0) { - $validator->errors()->add($resource, ucfirst($resource) . " must increase in steps of {$step}"); - } - } - }); - } - - private function getDynamicSliderConfig(int $productId): array - { - return DB::table('config_options') - ->join('config_option_products', 'config_options.id', '=', 'config_option_products.config_option_id') - ->where('config_option_products.product_id', $productId) - ->where('config_options.type', 'dynamic_slider') - ->whereNull('config_options.parent_id') - ->select(['config_options.name', 'config_options.metadata']) - ->get() - ->mapWithKeys(function ($option) { - $metadata = is_array($option->metadata) - ? $option->metadata - : json_decode($option->metadata ?? '[]', true) ?? []; - - $resourceType = $metadata['resource_type'] ?? strtolower($option->name); - - if (! in_array($resourceType, ['memory', 'cpu', 'disk'], true)) { - return []; - } - - return [$resourceType => $metadata]; - }) - ->all(); - } - - private function getAllowedLocationIds(int $productId): array - { - $product = Product::query()->find($productId); - $setting = $product?->settings() - ->where('key', 'location_ids') - ->value('value'); - - if ($setting === null || $setting === '') { - return []; - } - - $locationIds = is_array($setting) ? $setting : json_decode($setting, true); - if (! is_array($locationIds)) { - return []; - } - - return collect($locationIds) - ->filter(fn ($locationId) => is_numeric($locationId)) - ->map(fn ($locationId) => (int) $locationId) - ->values() - ->all(); - } -} diff --git a/Listeners/CartItemCreatedListener.php b/Listeners/CartItemCreatedListener.php index 94e191b..48a64b6 100644 --- a/Listeners/CartItemCreatedListener.php +++ b/Listeners/CartItemCreatedListener.php @@ -3,171 +3,35 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Listeners; use App\Events\CartItem\Created; -use App\Models\ConfigOption; -use Illuminate\Support\Facades\DB; +use App\Events\CartItem\Updated; use Illuminate\Support\Facades\Log; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ReservationConfigurationService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ReservationService; class CartItemCreatedListener { - public function handle(Created $event): void + public function __construct( + private readonly ReservationService $reservationService, + private readonly ReservationConfigurationService $configurationService + ) {} + + public function handle(Created|Updated $event): void { $cartItem = $event->cartItem; - // Check if this product has dynamic_slider config options (Pterodactyl resources) - if (! $this->hasDynamicSliderOptions($cartItem->product_id)) { - return; - } - - // Extract resource values from Paymenter's native config_options array - // config_options format: [{option_id: X, value: Y}, ...] - $resources = $this->extractResourcesFromConfigOptions( - $cartItem->config_options ?? [] - ); - - // Check if required resources exist (at minimum, need memory to proceed) - if (empty($resources)) { - Log::debug('CartItem has no dynamic_slider resources for reservation', [ - 'cart_item_id' => $cartItem->id, - ]); - - return; - } - - // Location is required for reservation - check checkout_config or config_options - $locationId = $this->extractLocationId($cartItem); - if (! $locationId) { - Log::debug('CartItem missing location for reservation', [ - 'cart_item_id' => $cartItem->id, - ]); - + if (! $this->configurationService->requiresReservation($cartItem->product_id)) { return; } - try { - $reservationService = app(ReservationService::class); - - $reservation = $reservationService->create( - productId: $cartItem->product_id, - locationId: (int) $locationId, - resources: [ - 'memory' => (int) ($resources['memory'] ?? 0), - 'cpu' => (int) ($resources['cpu'] ?? 0), - 'disk' => (int) ($resources['disk'] ?? 0), - ], - cartItemId: $cartItem->id, - userId: auth()->id() - ); - - // Store reservation token in checkout_config for later reference - $checkoutConfig = $cartItem->checkout_config ?? []; - $checkoutConfig['_reservation_token'] = $reservation['token']; - $checkoutConfig['_selected_node'] = $reservation['node_id']; - $checkoutConfig['_calculated_price'] = $reservation['pricing']['total']; - - $cartItem->update([ - 'checkout_config' => $checkoutConfig, - ]); - - Log::info('Created resource reservation for cart item', [ - 'cart_item_id' => $cartItem->id, - 'reservation_token' => substr($reservation['token'], 0, 8) . '...', - 'node_id' => $reservation['node_id'], - 'expires_at' => $reservation['expires_at'], - ]); - - } catch (\Exception $e) { - // Log error but don't block the cart operation - // User can still checkout, but without guaranteed resources - Log::error('Failed to create resource reservation', [ - 'cart_item_id' => $cartItem->id, - 'error' => $e->getMessage(), - ]); - } - } - - /** - * Extract resource values from native dynamic_slider config options - * Uses metadata.resource_type to identify memory/cpu/disk sliders - */ - private function extractResourcesFromConfigOptions(array $configOptions): array - { - $resources = []; - - foreach ($configOptions as $opt) { - $optionId = $opt['option_id'] ?? null; - $value = $opt['value'] ?? null; - - if (! $optionId || $value === null) { - continue; - } - - // Get the config option to check its type and metadata - $configOption = ConfigOption::find($optionId); - - if (! $configOption || $configOption->type !== 'dynamic_slider') { - continue; - } - - // Get resource type from metadata (memory, cpu, disk, custom) - $resourceType = $configOption->getMetadata('resource_type'); - - if ($resourceType && in_array($resourceType, ['memory', 'cpu', 'disk'])) { - $resources[$resourceType] = $value; - } - } - - return $resources; - } - - /** - * Extract location ID from cart item (checkout_config or config_options) - */ - private function extractLocationId($cartItem): ?int - { - // First, check checkout_config (extension-provided location selector) - $checkoutConfig = $cartItem->checkout_config ?? []; - if (isset($checkoutConfig['location'])) { - return (int) $checkoutConfig['location']; - } - - // Fall back to config_options (if location is a select/radio type) - foreach ($cartItem->config_options ?? [] as $opt) { - $optionId = $opt['option_id'] ?? null; - $value = $opt['value'] ?? null; - - if (! $optionId) { - continue; - } - - $configOption = ConfigOption::find($optionId); - if ($configOption && strtolower($configOption->name) === 'location') { - // Value might be a child config option ID - get its env_variable (Ptero location ID) - if (is_numeric($value)) { - $pteroLocationId = DB::table('config_options') - ->where('id', $value) - ->value('env_variable'); - - return $pteroLocationId ? (int) $pteroLocationId : (int) $value; - } - - return (int) $value; - } - } - - return null; - } + // Exceptions intentionally bubble into the cart transaction. A dynamic + // product must never be added or updated without an authoritative hold. + $reservation = $this->reservationService->reserveForCartItem($cartItem); - /** - * Check if a product has any dynamic_slider config options - */ - private function hasDynamicSliderOptions(int $productId): bool - { - return DB::table('config_options') - ->join('config_option_products', 'config_options.id', '=', 'config_option_products.config_option_id') - ->where('config_option_products.product_id', $productId) - ->where('config_options.type', 'dynamic_slider') - ->whereNull('config_options.parent_id') - ->exists(); + Log::info('Capacity reserved for cart item', [ + 'cart_item_id' => $cartItem->id, + 'reservation_id' => $reservation['id'], + 'node_id' => $reservation['node_id'], + 'expires_at' => $reservation['expires_at'], + ]); } } diff --git a/Listeners/CartItemDeletedListener.php b/Listeners/CartItemDeletedListener.php index 53b90b0..65ad441 100644 --- a/Listeners/CartItemDeletedListener.php +++ b/Listeners/CartItemDeletedListener.php @@ -2,56 +2,19 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Listeners; -use App\Events\CartItem\Deleted; -use App\Models\Service; +use App\Events\CartItem\Deleting; use Illuminate\Support\Facades\Log; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ReservationService; class CartItemDeletedListener { - public function handle(Deleted $event): void - { - $cartItem = $event->cartItem; - - // Reservation token is stored in checkout_config by CartItemCreatedListener - $checkoutConfig = $cartItem->checkout_config ?? []; - $token = $checkoutConfig['dp_reservation_token'] ?? $checkoutConfig['_reservation_token'] ?? null; - - if (!$token) { - return; - } - - // If a Service already carries this reservation token, the cart item was - // deleted as part of a successful checkout. Paymenter core (Cart::checkout) - // copies checkout_config into Service::properties, commits, then clears the - // cart — all BEFORE Invoice\Paid fires. Cancelling here would race-cancel a - // reservation that InvoicePaidListener is about to confirm. Leave it pending. - $serviceExists = Service::whereHas('properties', function ($q) use ($token) { - $q->where('key', '_reservation_token')->where('value', $token); - })->exists(); - - if ($serviceExists) { - Log::debug('Skipping reservation cancel: cart item consumed by checkout', [ - 'cart_item_id' => $cartItem->id, - 'reservation_token' => substr($token, 0, 8) . '...', - ]); - - return; - } + public function __construct(private readonly ReservationService $reservationService) {} - try { - $reservationService = app(ReservationService::class); - // System event: cart cleanup may happen outside an authenticated HTTP request. - $reservationService->cancel($token, null, 'cart_deleted', null); - - Log::info('Cancelled reservation for deleted cart item', [ - 'cart_item_id' => $cartItem->id, - 'reservation_token' => substr($token, 0, 8) . '...', - ]); - } catch (\Exception $e) { - Log::error('Failed to cancel reservation', [ - 'token' => substr($token, 0, 8) . '...', - 'error' => $e->getMessage(), + public function handle(Deleting $event): void + { + if ($this->reservationService->cancelForCartItem($event->cartItem->id)) { + Log::info('Cancelled capacity reservation for removed cart item', [ + 'cart_item_id' => $event->cartItem->id, ]); } } diff --git a/Listeners/InvoicePaidListener.php b/Listeners/InvoicePaidListener.php deleted file mode 100644 index 415dca2..0000000 --- a/Listeners/InvoicePaidListener.php +++ /dev/null @@ -1,135 +0,0 @@ -invoice; - - foreach ($invoice->items as $item) { - if ($item->reference_type !== Service::class) { - continue; - } - - $service = $item->reference; - if (!$service) { - continue; - } - - $reservationToken = $service->properties() - ->where('key', '_reservation_token') - ->value('value'); - - if (!$reservationToken) { - continue; - } - - try { - $reservationService = app(ReservationService::class); - $resourceService = app(ResourceCalculationService::class); - $alertService = app(AlertService::class); - - $reservation = $reservationService->getByToken($reservationToken); - - if (!$reservation) { - Log::warning('Reservation not found for paid invoice', [ - 'service_id' => $service->id, - 'invoice_id' => $invoice->id, - ]); - - continue; - } - - $snapshot = [ - 'memory' => $reservation->memory, - 'cpu' => $reservation->cpu, - 'disk' => $reservation->disk, - ]; - - // CRITICAL: Final availability verification - $available = $resourceService->verifyAvailability( - $reservation->node_id, - $snapshot, - $reservationToken, - ); - - if (!$available) { - Log::error('Resources no longer available for paid service', [ - 'service_id' => $service->id, - 'node_id' => $reservation->node_id, - 'memory' => $reservation->memory, - 'cpu' => $reservation->cpu, - 'disk' => $reservation->disk, - ]); - - try { - $alertService->notifyShortfall( - serviceId: $service->id, - invoiceId: $invoice->id, - snapshot: $snapshot, - reason: 'insufficient_resources', - ); - } catch (\Throwable $e) { - Log::error('Shortfall notification delivery failed', [ - 'service_id' => $service->id, - 'reason' => 'insufficient_resources', - 'error' => $e->getMessage(), - ]); - } - - continue; - } - - // Confirm the reservation. Returns false if no pending row matched — - // meaning the reservation was already cancelled or expired between - // verifyAvailability() and this call (state drift). - // System event: invoice settlement runs without an authenticated actor. - $confirmed = $reservationService->confirm($reservationToken, $service->id, null); - - if ($confirmed) { - Log::info('Confirmed reservation for paid service', [ - 'service_id' => $service->id, - 'node_id' => $reservation->node_id, - ]); - } else { - $current = $reservationService->getByToken($reservationToken); - Log::warning('Reservation could not be confirmed (state drift)', [ - 'service_id' => $service->id, - 'reservation_id' => $reservation->id, - 'current_status' => $current?->status, - ]); - - try { - $alertService->notifyShortfall( - serviceId: $service->id, - invoiceId: $invoice->id, - snapshot: $snapshot, - reason: 'state_drift:' . ($current?->status ?? 'unknown'), - ); - } catch (\Throwable $e) { - Log::error('Shortfall notification delivery failed', [ - 'service_id' => $service->id, - 'reason' => 'state_drift', - 'error' => $e->getMessage(), - ]); - } - } - - } catch (\Exception $e) { - Log::error('Invoice reservation processing failed', [ - 'service_id' => $service->id, - 'error' => $e->getMessage(), - ]); - } - } - } -} diff --git a/Listeners/ServiceCreatedListener.php b/Listeners/ServiceCreatedListener.php deleted file mode 100644 index 05b0311..0000000 --- a/Listeners/ServiceCreatedListener.php +++ /dev/null @@ -1,32 +0,0 @@ -service; - - // Get reservation token from service properties (morphMany relationship) - $reservationToken = $service->properties() - ->where('key', '_reservation_token') - ->value('value'); - - if (! $reservationToken) { - return; - } - - Log::info('Service created with reservation', [ - 'service_id' => $service->id, - 'reservation_token' => substr($reservationToken, 0, 8) . '...', - 'product_id' => $service->product_id, - ]); - - // The reservation should already be confirmed by InvoicePaidListener - // This is just for logging/tracking purposes - } -} diff --git a/Models/AlertConfig.php b/Models/AlertConfig.php index ab290e2..59aea44 100644 --- a/Models/AlertConfig.php +++ b/Models/AlertConfig.php @@ -14,6 +14,8 @@ class AlertConfig extends Model 'location_name', 'memory_warning_threshold', 'memory_critical_threshold', + 'cpu_warning_threshold', + 'cpu_critical_threshold', 'disk_warning_threshold', 'disk_critical_threshold', 'email_notifications', diff --git a/Models/CapacityScope.php b/Models/CapacityScope.php new file mode 100644 index 0000000..00e83d5 --- /dev/null +++ b/Models/CapacityScope.php @@ -0,0 +1,30 @@ + 'integer', + ]; + + public function scopeForPanel(Builder $query, string $panelIdentity): Builder + { + return $query->where('panel_identity', $panelIdentity); + } + + public function scopeForLocation(Builder $query, int $locationId): Builder + { + return $query->where('location_id', $locationId); + } +} diff --git a/Models/NodeCapacityPolicy.php b/Models/NodeCapacityPolicy.php new file mode 100644 index 0000000..ae67590 --- /dev/null +++ b/Models/NodeCapacityPolicy.php @@ -0,0 +1,238 @@ + 'integer', + 'location_id' => 'integer', + 'cpu_capacity_percent' => 'integer', + 'cpu_overcommit_bps' => 'integer', + 'enabled' => 'boolean', + ]; + + public function scopeForPanel(Builder $query, string $panelIdentity): Builder + { + return $query->where('panel_identity', $panelIdentity); + } + + public function scopeForNode(Builder $query, string $nodeUuid): Builder + { + return $query->where('node_uuid', $nodeUuid); + } + + public function effectiveCpuCapacity(): int + { + if (! $this->enabled) { + return 0; + } + + $capacity = (int) $this->cpu_capacity_percent; + $overcommit = (int) $this->cpu_overcommit_bps; + $this->assertPolicyRange($capacity, $overcommit); + + return intdiv( + $capacity * $overcommit, + 10000 + ); + } + + protected static function booted(): void + { + static::saving(function (self $policy): void { + $policy->assertPolicyRange( + (int) $policy->cpu_capacity_percent, + (int) $policy->cpu_overcommit_bps + ); + }); + } + + public function save(array $options = []) + { + if (! $this->capacityPolicyIsChanging()) { + return parent::save($options); + } + if (DB::transactionLevel() === 0) { + return DB::transaction( + fn () => $this->save($options), + 5 + ); + } + + $this->lockCapacityScopes(); + $this->assertNoLiveCommitment(); + + return parent::save($options); + } + + public function delete() + { + if (! $this->exists) { + return parent::delete(); + } + if (DB::transactionLevel() === 0) { + return DB::transaction( + fn () => $this->delete(), + 5 + ); + } + + $this->lockCapacityScopes(); + $this->assertNoLiveCommitment(); + + return parent::delete(); + } + + private function assertPolicyRange(int $capacity, int $overcommit): void + { + if ($capacity < 1 || $capacity > self::MAX_CPU_CAPACITY_PERCENT) { + throw new \InvalidArgumentException(sprintf( + 'CPU capacity must be between 1 and %d percent.', + self::MAX_CPU_CAPACITY_PERCENT + )); + } + + if ($overcommit < 1 || $overcommit > self::MAX_CPU_OVERCOMMIT_BPS) { + throw new \InvalidArgumentException(sprintf( + 'CPU overcommit must be between 1 and %d basis points.', + self::MAX_CPU_OVERCOMMIT_BPS + )); + } + + if ($capacity > intdiv(PHP_INT_MAX, $overcommit)) { + throw new \InvalidArgumentException( + 'The configured CPU policy exceeds the supported integer range.' + ); + } + } + + private function assertNoLiveCommitment(): bool + { + if (! Schema::hasTable('ptero_resource_reservations')) { + return true; + } + + $scopes = [[ + (string) $this->panel_identity, + (int) $this->node_id, + ]]; + if ($this->exists) { + $scopes[] = [ + (string) $this->getOriginal('panel_identity'), + (int) $this->getOriginal('node_id'), + ]; + } + + foreach (array_unique($scopes, SORT_REGULAR) as [$panel, $node]) { + if (! ResourceReservation::query() + ->holdingCapacity() + ->where('panel_identity', $panel) + ->where('node_id', $node) + ->exists()) { + continue; + } + + throw new \RuntimeException( + 'CPU capacity policy cannot change while this node has a live capacity commitment.' + ); + } + + return true; + } + + private function capacityPolicyIsChanging(): bool + { + return ! $this->exists || $this->isDirty([ + 'panel_identity', + 'node_uuid', + 'node_id', + 'location_id', + 'cpu_capacity_percent', + 'cpu_overcommit_bps', + 'enabled', + ]); + } + + /** + * Serialize the policy write with reservation creation for both the old + * and new location. The lock remains held until the surrounding save or + * delete transaction commits. + */ + private function lockCapacityScopes(): void + { + if (! Schema::hasTable('ptero_resource_reservations')) { + return; + } + if (! Schema::hasTable('ptero_capacity_scopes')) { + throw new \RuntimeException( + 'CPU capacity policy changes require the capacity-scope lock table.' + ); + } + + $scopes = [[ + 'panel_identity' => (string) $this->panel_identity, + 'location_id' => (int) $this->location_id, + ]]; + if ($this->exists) { + $scopes[] = [ + 'panel_identity' => (string) $this->getOriginal( + 'panel_identity' + ), + 'location_id' => (int) $this->getOriginal('location_id'), + ]; + } + $scopes = collect($scopes) + ->unique(fn (array $scope): string => "{$scope['panel_identity']}:{$scope['location_id']}") + ->sortBy(fn (array $scope): string => "{$scope['panel_identity']}:" + .str_pad((string) $scope['location_id'], 10, '0', STR_PAD_LEFT)) + ->values(); + + foreach ($scopes as $scope) { + if ( + preg_match( + '/^[a-f0-9]{64}$/D', + $scope['panel_identity'] + ) !== 1 + || $scope['location_id'] <= 0 + ) { + throw new \InvalidArgumentException( + 'A CPU capacity policy requires a valid panel and location identity.' + ); + } + DB::table('ptero_capacity_scopes')->insertOrIgnore([ + ...$scope, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + foreach ($scopes as $scope) { + DB::table('ptero_capacity_scopes') + ->where('panel_identity', $scope['panel_identity']) + ->where('location_id', $scope['location_id']) + ->lockForUpdate() + ->firstOrFail(); + } + } +} diff --git a/Models/ReservationAllocation.php b/Models/ReservationAllocation.php new file mode 100644 index 0000000..20fe927 --- /dev/null +++ b/Models/ReservationAllocation.php @@ -0,0 +1,33 @@ + 'boolean', + 'released_at' => 'datetime', + ]; + + public function reservation(): BelongsTo + { + return $this->belongsTo(ResourceReservation::class, 'reservation_id'); + } +} diff --git a/Models/ResourceReservation.php b/Models/ResourceReservation.php index f0fa1ee..df14248 100644 --- a/Models/ResourceReservation.php +++ b/Models/ResourceReservation.php @@ -2,21 +2,48 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Models; +use App\Models\Invoice; use App\Models\Service; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class ResourceReservation extends Model { + public const STATUS_PENDING = 'pending'; + + public const STATUS_PAID_COMMITTED = 'paid_committed'; + + public const STATUS_CONFIRMED = 'confirmed'; + + public const STATUS_EXPIRED = 'expired'; + + public const STATUS_CANCELLED = 'cancelled'; + protected $table = 'ptero_resource_reservations'; protected $fillable = [ 'token', + 'purpose', 'idempotency_key', 'cart_item_id', + 'cart_item_guard_id', + 'cart_id', + 'server_extension_id', + 'panel_identity', 'service_id', + 'service_guard_id', + 'invoice_id', 'user_id', + 'product_id', + 'plan_id', + 'quantity', + 'currency_code', + 'configuration_fingerprint', + 'configuration_payload', + 'pricing_version', + 'formula_version', 'node_id', 'location_id', 'memory', @@ -27,11 +54,44 @@ class ResourceReservation extends Model 'status', 'admin_notes', 'expires_at', + 'guaranteed_until', + 'paid_committed_at', + 'provisioning_started_at', + 'provisioning_attempts', + 'last_provisioning_attempt_at', + 'next_provisioning_attempt_at', + 'provisioning_lease_id', + 'consumed_at', + 'last_provisioning_error', + 'failure_alerted_at', + 'cancellation_requested_at', + 'last_cancellation_error', + 'cancellation_failure_alerted_at', + 'external_server_id', + 'external_user_id', + 'external_server_uuid', + 'external_server_identifier', + 'last_reconciled_at', + 'customer_notified_at', + 'product_stock_released_at', ]; protected $casts = [ 'pricing_breakdown' => 'array', + 'configuration_payload' => 'array', 'expires_at' => 'datetime', + 'guaranteed_until' => 'datetime', + 'paid_committed_at' => 'datetime', + 'provisioning_started_at' => 'datetime', + 'last_provisioning_attempt_at' => 'datetime', + 'next_provisioning_attempt_at' => 'datetime', + 'consumed_at' => 'datetime', + 'failure_alerted_at' => 'datetime', + 'cancellation_requested_at' => 'datetime', + 'cancellation_failure_alerted_at' => 'datetime', + 'last_reconciled_at' => 'datetime', + 'customer_notified_at' => 'datetime', + 'product_stock_released_at' => 'datetime', 'calculated_price' => 'decimal:2', ]; @@ -45,13 +105,33 @@ public function service(): BelongsTo return $this->belongsTo(Service::class); } + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function allocations(): HasMany + { + return $this->hasMany(ReservationAllocation::class, 'reservation_id'); + } + /** * Scope for pending reservations that haven't expired. */ public function scopePending($query) { return $query->where('status', 'pending') - ->where('expires_at', '>', now()); + ->where('expires_at', '>', now()); + } + + public function scopeHoldingCapacity($query) + { + return $query->where(function ($query) { + $query->where( + 'status', + self::STATUS_PENDING + )->orWhere('status', self::STATUS_PAID_COMMITTED); + }); } /** @@ -60,6 +140,6 @@ public function scopePending($query) public function scopeExpired($query) { return $query->where('status', 'pending') - ->where('expires_at', '<=', now()); + ->where('expires_at', '<=', now()); } } diff --git a/Models/SchedulerHeartbeat.php b/Models/SchedulerHeartbeat.php new file mode 100644 index 0000000..cec666d --- /dev/null +++ b/Models/SchedulerHeartbeat.php @@ -0,0 +1,46 @@ + 'integer', + 'lag_threshold_seconds' => 'integer', + 'last_started_at' => 'datetime', + 'last_completed_at' => 'datetime', + 'last_succeeded_at' => 'datetime', + 'last_failed_at' => 'datetime', + 'last_lag_checked_at' => 'datetime', + 'lag_detected_at' => 'datetime', + 'last_alerted_at' => 'datetime', + 'last_scanned_entity_id' => 'integer', + 'last_processed_count' => 'integer', + 'last_failure_count' => 'integer', + 'consecutive_failures' => 'integer', + 'last_failure_context' => 'array', + ]; +} diff --git a/Models/UpgradeReservation.php b/Models/UpgradeReservation.php new file mode 100644 index 0000000..c44dca0 --- /dev/null +++ b/Models/UpgradeReservation.php @@ -0,0 +1,30 @@ + 'array', + 'pricing_breakdown' => 'array', + 'expires_at' => 'datetime', + 'guaranteed_until' => 'datetime', + 'paid_committed_at' => 'datetime', + 'provisioning_started_at' => 'datetime', + 'consumed_at' => 'datetime', + 'calculated_price' => 'decimal:2', + ]; + + public function serviceUpgrade(): BelongsTo + { + return $this->belongsTo(ServiceUpgrade::class); + } +} diff --git a/Notifications/PaymentAttentionNotification.php b/Notifications/PaymentAttentionNotification.php new file mode 100644 index 0000000..9d40e35 --- /dev/null +++ b/Notifications/PaymentAttentionNotification.php @@ -0,0 +1,35 @@ +afterCommit = true; + } + + public function via($notifiable): array + { + return ['mail']; + } + + public function toMail($notifiable): MailMessage + { + return (new MailMessage) + ->subject('[Paymenter] Capacity invoice payment needs refund review') + ->line('Payment activity exists after a dynamic-capacity guarantee expired.') + ->line('Invoice ID: '.($this->snapshot['invoice_id'] ?? 'unknown')) + ->line('Service ID: '.($this->snapshot['service_id'] ?? 'unknown')) + ->line('Reason: '.($this->snapshot['error'] ?? 'unknown')) + ->line('Capacity was not consumed. Review the gateway transaction and issue a refund or account credit.') + ->action('View invoice', url('/admin/invoices/'.($this->snapshot['invoice_id'] ?? 0).'/edit')); + } +} diff --git a/Notifications/ProvisioningFailedNotification.php b/Notifications/ProvisioningFailedNotification.php new file mode 100644 index 0000000..c68a387 --- /dev/null +++ b/Notifications/ProvisioningFailedNotification.php @@ -0,0 +1,64 @@ + $snapshot + */ + public function __construct(public array $snapshot) + { + $this->afterCommit = true; + } + + public function via($notifiable): array + { + return ['mail']; + } + + public function toMail($notifiable): MailMessage + { + $serviceId = (int) ($this->snapshot['service_id'] ?? 0); + $operation = $this->snapshot['operation'] ?? 'provisioning'; + $cancellation = $operation === 'cancellation'; + $upgrade = $operation === 'upgrade'; + + return (new MailMessage) + ->subject(match (true) { + $cancellation => '[Paymenter] Dynamic server cancellation requires attention', + $upgrade => '[Paymenter] Dynamic resource upgrade requires attention', + default => '[Paymenter] Dynamic server provisioning requires attention', + }) + ->line(match (true) { + $cancellation => 'A dynamic server cancellation exhausted its immediate termination retries.', + $upgrade => 'A paid dynamic resource upgrade exhausted its automatic provisioning retries.', + default => 'A paid dynamic-capacity order exhausted its immediate provisioning retries.', + }) + ->when( + $upgrade, + fn (MailMessage $message) => $message->line( + 'Upgrade ID: '.($this->snapshot['upgrade_id'] ?? 'unknown') + ) + ) + ->line('Service ID: '.$serviceId) + ->line('Invoice ID: '.($this->snapshot['invoice_id'] ?? 'free service')) + ->line('Reservation ID: '.($this->snapshot['reservation_id'] ?? 'unknown')) + ->line('Node ID: '.($this->snapshot['node_id'] ?? 'unknown')) + ->line('Attempts: '.($this->snapshot['attempts'] ?? 0)) + ->line('Last error: '.($this->snapshot['error'] ?? 'unknown')) + ->line(match (true) { + $cancellation => 'The cancellation tombstone remains active. Confirm the external server is deleted before resolving it.', + $upgrade => 'The paid capacity delta remains committed. Reconcile the panel state before manually resolving the upgrade.', + default => 'The paid capacity remains committed and automatic reconciliation will continue.', + }) + ->action('View service', url('/admin/services/'.$serviceId.'/edit')); + } +} diff --git a/Notifications/SchedulerTaskFailureNotification.php b/Notifications/SchedulerTaskFailureNotification.php new file mode 100644 index 0000000..4da7230 --- /dev/null +++ b/Notifications/SchedulerTaskFailureNotification.php @@ -0,0 +1,57 @@ + $context + */ + public function __construct(public array $context) {} + + public function via($notifiable): array + { + return ['mail']; + } + + public function toMail($notifiable): MailMessage + { + $task = (string) ($this->context['task'] ?? 'unknown'); + $lagging = ($this->context['kind'] ?? null) === 'scheduler_lag'; + + return (new MailMessage) + ->subject( + $lagging + ? '[Paymenter] Dynamic Pterodactyl scheduler is lagging' + : '[Paymenter] Dynamic Pterodactyl scheduled task failed' + ) + ->line('Task: '.$task) + ->when( + isset($this->context['entity_type']), + fn (MailMessage $message) => $message->line( + 'Record: ' + .$this->context['entity_type'] + .' #' + .($this->context['entity_id'] ?? 'unknown') + ) + ) + ->when( + isset($this->context['lag_seconds']), + fn (MailMessage $message) => $message->line( + 'Seconds since last successful run: ' + .$this->context['lag_seconds'] + ) + ) + ->line( + 'Last error: ' + .($this->context['error'] ?? 'The scheduler missed its healthy-run threshold.') + ) + ->line( + 'Inspect the scheduler heartbeat record and the application log before retrying or changing fulfillment state.' + ) + ->action('Open admin panel', url('/admin')); + } +} diff --git a/PROGRESS.md b/PROGRESS.md index 77d5ec6..f5d907b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -6,9 +6,9 @@ Active implementation tracking. **Claude: Update this as you work.** ## Current Status -**Phase**: dp-19 slider→reservation-API wiring shipped -**Last Updated**: 2026-04-27 -**Last Session**: dp-19 shipped. Wired `dynamicSliderGroup` Alpine component to `POST /api/dynamic-pterodactyl/reservation`; swapped route middleware to `checkout` for guest support; made `cart_item_id` optional; added `Cart::checkout()` confirmation hook; 3 new guest tests green; CodeRabbit 2 findings fixed. +**Phase**: Deep release verification of dynamic stock v4 +**Last Updated**: 2026-07-26 +**Last Session**: Deep acceptance audit of complete-vector stock, durable payment, provisioning, and capacity-aware upgrades. --- @@ -16,7 +16,16 @@ Active implementation tracking. **Claude: Update this as you work.** > **For Claude**: Read this + "Current Session State" to quickly understand where we are. -All documentation and scaffolding complete. 9 spec files + 4 support files (CLAUDE.md, DECISIONS.md, PROGRESS.md, CHANGELOG.md) + skeleton directory. Ready to begin Phase 1: Database migrations and core models. Start with 01-DATABASE.md. +The implementation is on the linked draft remediation PRs. Checkout and +upgrade quotes now compute live, step-aligned RAM/CPU/disk bounds on one +eligible node; cart and upgrade mutations repeat the proof under locks and +reserve exact ports. A 15-minute cart hold becomes a seven-day invoice +guarantee, then non-expiring paid capacity until exact Pterodactyl +provisioning or upgrade reconciliation succeeds. + +Before release, run the full CI matrix and a real Pterodactyl 1.12.3+ staging +canary. Deploy Paymenter core first in maintenance mode, run the extension +migration/readiness gate, restart queue workers, and only then restore traffic. --- @@ -66,22 +75,65 @@ All documentation and scaffolding complete. 9 spec files + 4 support files (CLAU > **Claude**: Update this frequently during work, not just at session end. > This survives compaction and helps resume quickly. -**Last checkpoint**: 2026-04-27 -**Working on**: No active task -**Status**: dp-19 shipped. `dynamicSliderGroup` Alpine wired to reservation API; guest support via `checkout` middleware; `Cart::checkout()` confirms reservations with extension-disabled fallback. CodeRabbit findings fixed (Alpine x-data fallback + obsidian theme sync). -**Current file**: PROGRESS.md -**Next action**: All dp-NN backlog items complete. Next work determined by driver. -**Blockers**: None +**Last checkpoint**: 2026-07-26 +**Working on**: Final cross-repository release verification +**Status**: Complete-vector live quotes, authoritative per-node CPU stock, +exact allocation holds, seven-day invoice guarantees, durable paid +fulfillment, cancellation reconciliation, and capacity-aware upgrades are +implemented on the two draft remediation branches. +**Current file**: final static checks, CI, and lifecycle documentation +**Next action**: Publish the reviewed trees, run the full GitHub matrix, then +exercise checkout/payment/provisioning/upgrade/cancellation against a +dedicated Pterodactyl 1.12.3+ staging panel. +**Blockers**: Local PHP/Composer are unavailable. GitHub CI supplies the real +PHP, Composer, MariaDB, and SQLite execution gate; only the external-panel +canary remains environment-specific. ### This Session's Changes: -1. dp-19 — direct commits on `dynamic-slider` (extension) and `dynamic-slider/1.4.7` (outer Paymenter). +1. Established a clean Paymenter 1.5.6 companion branch. +2. Retired the browser reservation API/token flow and invoice-paid confirmation listener. +3. Added the cart-to-provisioning reservation protocol and cross-repository provisioner integration. +4. Retired the unused independent-max availability and extension-owned pricing + routes; complete-vector checkout and upgrade quotes are now the sole + customer stock contracts. +5. Added SQLite-safe status/generated-column migrations and a PHP 8.3/8.4 + SQLite cross-repository matrix alongside MariaDB 11/12. +6. Added authoritative per-node CPU policies, exact allocation claims, + managed-node isolation, and deterministic complete-vector placement. +7. Added seven-day invoice guarantees, non-expiring paid commitments, + retry/reconciliation, durable cancellation, and operator attention states. +8. Added fixed-node dynamic resource upgrades with immutable source/target + snapshots, delta reservations, exact build reconciliation, and idempotent + billing/credit application. + +--- + +## 2026-07-25 — Release-blocker remediation + +**Status**: Implemented locally; awaiting full container verification +**Branches**: `codex/paymenter-1.5.6-remediation` in both repositories + +Implemented the ten-step repair order from the private-code audit: + +- Paymenter 1.5.6 baseline; +- one server-owned reservation; +- immutable configuration fingerprint; +- atomic guest transfer and no browser token; +- fail-closed cart/checkout; +- seven-day invoice guarantee followed by a non-expiring paid commitment; +- actual `node` field binding; +- lowercase config keys and slider serialization; +- explicit per-node CPU inventory and overcommit; +- exact allocation/port reservation; +- capacity-aware upgrade and cancellation lifecycles; +- replacement lifecycle/concurrency tests and current documentation. --- ## dp-19 — Wire customer-facing slider to reservation API -**Status**: Shipped +**Status**: Superseded by the server-owned complete-vector quote protocol **PR**: committed directly on `dynamic-slider` (default branch; no PR base) **Date**: 2026-04-27 diff --git a/README.md b/README.md index f89a887..58925d8 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,76 @@ -# Dynamic Resource Sliders for Pterodactyl Integration - -**Version:** 3.1.0 -**Status:** Final Design -**Pattern:** Companion Extension - ---- - -## Quick Summary - -Enable Paymenter customers to select exact RAM, CPU, and Disk amounts using sliders during checkout for Pterodactyl game servers, with real-time availability tracking and automatic node selection. - ---- - -## Architecture Diagram - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Product Configuration │ -├─────────────────────────────────────────────────────────────────────┤ -│ Server Extension: Pterodactyl (built-in, unchanged) │ -│ └── Handles: Server creation, ports, users │ -├─────────────────────────────────────────────────────────────────────┤ -│ Configurable Options: │ -│ ├── memory (Number) ─────┐ │ -│ ├── cpu (Number) ────────┼── Flow to createServer() │ -│ ├── disk (Number) ───────┘ │ -│ └── location_id (Select) ── Triggers availability fetch │ -├─────────────────────────────────────────────────────────────────────┤ -│ DynamicPterodactyl (Companion Extension): │ -│ ├── Real-time availability API │ -│ ├── Resource reservation system (15-min TTL) │ -│ ├── Slider config reads via SliderConfigReaderService │ -│ ├── No custom frontend assets; native core slider UI │ -│ ├── Admin dashboard & configuration UI │ -│ └── Event hooks for cart/invoice/service lifecycle │ -└─────────────────────────────────────────────────────────────────────┘ +# Dynamic Pterodactyl + +Companion extension for Paymenter 1.5.6 that adds native RAM/CPU/disk +sliders, live complete-vector stock, exact port reservations, deterministic +node selection, capacity-aware upgrades, and Filament 5 administration to the +built-in Pterodactyl server extension. + +Production deployments require Pterodactyl Panel and Wings 1.12.3 or newer. +The stock and provisioning API contracts are verified against Panel 1.12.3, +but a real-panel staging canary remains a release gate. + +## Ownership + +| Concern | Authority | +|---|---| +| Slider UI and price calculation | Paymenter core | +| Checkout capacity identity and node selection | Dynamic Pterodactyl | +| Server creation and actual allocation | Built-in Pterodactyl extension | + +The browser never owns a reservation. A synchronous cart listener creates one server-owned hold, checkout binds it to the service, and the provisioner consumes it only after Pterodactyl accepts the server. + +```mermaid +flowchart TD + A["Native sliders"] --> B["Transactional cart hold"] + B --> C["Seven-day invoice guarantee"] + C --> D["Paid commitment"] + D --> E["Exact node, limits, and ports"] + E --> F["Verified Pterodactyl server"] ``` ---- - -## Why Companion Pattern? - -| Factor | Standalone | Companion ✓ | -|--------|------------|-------------| -| Lines of code | ~800+ | ~400 | -| Pterodactyl provisioning | Must reimplement | Reuses existing | -| Upstream bug fixes | Manual port | Automatic | -| If extension fails | Product broken | Graceful degradation | - ---- - -## Document Index - -| Document | Purpose | Read When... | -|----------|---------|--------------| -| [01-DATABASE.md](01-DATABASE.md) | Schema, migrations, relationships | Setting up tables, understanding data model | -| [02-SERVICES.md](02-SERVICES.md) | Core business logic services | Implementing backend functionality | -| [03-API.md](03-API.md) | REST endpoints, controllers | Building API layer | -| [04-EVENTS.md](04-EVENTS.md) | Cart/invoice event handlers | Integrating with Paymenter lifecycle | -| [05-ADMIN-UI.md](05-ADMIN-UI.md) | Filament dashboard & resources | Building admin interface | -| [06-FRONTEND.md](06-FRONTEND.md) | Native slider architecture, Alpine.js | Implementing customer-facing UI | -| [07-PRICING-MODELS.md](07-PRICING-MODELS.md) | Pricing logic & JSON configs | Understanding/implementing pricing | -| [08-ALGORITHMS.md](08-ALGORITHMS.md) | Node selection, concurrency | Implementing allocation logic | -| [09-IMPLEMENTATION.md](09-IMPLEMENTATION.md) | Roadmap, testing, risks | Planning & project management | - ---- - -## Key Technical Decisions - -### Real-Time API (No Caching) -- Query Pterodactyl API directly at checkout time -- Eliminates cache staleness issues -- Proven by PteroSync WHMCS module in production - -### Reservation System -- 15-minute TTL holds resources during checkout -- Pessimistic database locking prevents overselling -- Final verification at payment time - -### Three Pricing Models -1. **Linear** - Simple per-unit (e.g., $0.50/GB RAM) -2. **Tiered** - Volume discounts at breakpoints -3. **Base + Addon** - Included resources + overage charges - -Paymenter core is the pricing authority for `dynamic_slider` options. The fork-only core patches in `dp-core-01` (merged) handle slider pricing at runtime via `Plan::dynamicSliderBasePrice()` and `ConfigOption::calculateDynamicPriceDelta()`. This extension's `SliderConfigReaderService` reads slider config metadata; `PricingConfigValidator` is retired in favour of core `DynamicSliderPricingRule`. - -### Frontend Slider -- Customer-facing sliders use Paymenter core's native `dynamic_slider` component -- Rendering is a native HTML range input managed by Alpine.js in the core theme -- Price updates happen client-side, with Livewire entanglement keeping cart state in sync - ---- - -## File Structure - -``` -extensions/Others/DynamicPterodactyl/ -├── DynamicPterodactyl.php # Main extension class -├── database/migrations/ # 7 migration files -├── Admin/ -│ ├── Pages/ # Dashboard, Analytics, Settings, etc. -│ └── Resources/ # AlertConfig, Reservation -├── Http/Controllers/ # API and Admin controllers -├── Models/ # Eloquent models -├── resources/views/ # Blade templates -├── routes/ # API and web routes -└── Services/ # Core business logic -``` - ---- - -## Database Tables - -| Table | Purpose | -|-------|---------| -| `ptero_resource_reservations` | Temporary resource holds during checkout | -| `ptero_audit_logs` | Admin action tracking | -| `ptero_alert_configs` | Capacity alert thresholds | - ---- - -## Cross-Reference: Common Tasks - -| Task | Primary Doc | Also See | -|------|-------------|----------| -| Add new pricing model | 07-PRICING-MODELS | 02-SERVICES | -| Fix reservation bug | 02-SERVICES | 08-ALGORITHMS | -| Modify admin dashboard | 05-ADMIN-UI | - | -| Change slider behavior | 06-FRONTEND | 03-API | -| Add new event hook | 04-EVENTS | 02-SERVICES | -| Understand node selection | 08-ALGORITHMS | 02-SERVICES | - ---- - -## Quick Start for Implementation - -1. **Database first**: Follow [01-DATABASE.md](01-DATABASE.md) to create migrations -2. **Services**: Implement services from [02-SERVICES.md](02-SERVICES.md) -3. **API**: Build endpoints per [03-API.md](03-API.md) -4. **Events**: Wire up handlers from [04-EVENTS.md](04-EVENTS.md) -5. **Admin**: Create Filament UI from [05-ADMIN-UI.md](05-ADMIN-UI.md) -6. **Frontend**: Add sliders using [06-FRONTEND.md](06-FRONTEND.md) - ---- - -## Version History - -| Version | Date | Changes | -|---------|------|---------| -| 1.0 | Nov 2025 | Initial cached architecture | -| 2.0 | Nov 2025 | Real-time API approach | -| 3.0 | Nov 2025 | Companion Extension pattern | -| 3.1 | Nov 2025 | Comprehensive Admin UI, split documentation | +## Safety properties + +- Paymenter baseline is 1.5.6. +- Cart create/edit and checkout fail closed for dynamic products. +- Guest ownership transfers with the cart in one transaction. +- The immutable fingerprint covers customer, cart, Paymenter server extension, hashed panel identity, product, plan, location, node, resources, quantity, currency, pricing version, and formula version. +- A 15-minute cart hold becomes an exact seven-day invoice guarantee at + checkout. Payment converts it to a non-expiring `paid_committed` commitment. +- The actual Pterodactyl request uses the reserved `node`, `memory`, `cpu`, + `disk`, primary allocation, and additional allocations. +- The Paymenter service stays `provisioning` until the external server and its + complete allocation set match the immutable commitment. +- Config keys are lowercase and dynamic `ServiceConfig.slider_value` is serialized correctly. +- CPU is authoritative stock backed by an explicit per-node physical capacity + and configurable basis-point overcommit policy. Nodes without an enabled + policy are ineligible. +- Enabled capacity-policy nodes are dedicated to the reservation-backed flow; + external administrators and automation must not mutate their stock. +- Dynamic resource upgrades reserve a positive capacity delta on the server's + existing node and reconcile the exact build before local billing state moves. +- Dynamic products force quantity to one. +- Customer reservation tokens and reservation endpoints do not exist. + +## Documentation + +| Document | Topic | +|---|---| +| [01-DATABASE.md](01-DATABASE.md) | Reservation schema and lifecycle | +| [02-SERVICES.md](02-SERVICES.md) | Service responsibilities | +| [03-API.md](03-API.md) | Live customer/admin routes | +| [04-EVENTS.md](04-EVENTS.md) | Cart, login, checkout, and provisioning flow | +| [05-ADMIN-UI.md](05-ADMIN-UI.md) | Filament admin surfaces | +| [06-FRONTEND.md](06-FRONTEND.md) | Native slider frontend | +| [07-PRICING-MODELS.md](07-PRICING-MODELS.md) | Core-owned pricing models | +| [08-ALGORITHMS.md](08-ALGORITHMS.md) | Capacity and concurrency | +| [09-IMPLEMENTATION.md](09-IMPLEMENTATION.md) | Deployment and verification | + +## Installation boundary + +This extension must be deployed with the companion Paymenter remediation +branch. Enter maintenance mode, deploy and migrate Paymenter core first, run +the extension migration/readiness gate, restart queue workers, and then leave +maintenance mode. Migrations are forward-only: a failure after schema +activation must remain in maintenance for operator recovery. + +The extension stores no cached Pterodactyl capacity. Customer responses expose aggregate location signals only; raw node data remains admin-only. diff --git a/Services/AlertService.php b/Services/AlertService.php index 1ba6ced..7b7dda0 100644 --- a/Services/AlertService.php +++ b/Services/AlertService.php @@ -9,10 +9,13 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; use Paymenter\Extensions\Others\DynamicPterodactyl\Events\AlertDeliveryFailed; use Paymenter\Extensions\Others\DynamicPterodactyl\Models\AlertConfig; use Paymenter\Extensions\Others\DynamicPterodactyl\Models\AlertDeliveryLog; use Paymenter\Extensions\Others\DynamicPterodactyl\Notifications\CapacityAlertNotification; +use Paymenter\Extensions\Others\DynamicPterodactyl\Notifications\PaymentAttentionNotification; +use Paymenter\Extensions\Others\DynamicPterodactyl\Notifications\ProvisioningFailedNotification; use Paymenter\Extensions\Others\DynamicPterodactyl\Notifications\ReservationShortfallNotification; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\Concerns\AuditsExtensionActions; @@ -30,96 +33,152 @@ public function __construct(ResourceCalculationService $resourceService) /** * Check all locations for capacity alerts */ - public function checkCapacityAlerts(): void + public function checkCapacityAlerts(int $chunkSize = 100): int { - $alertConfigs = DB::table('ptero_alert_configs') - ->where('is_active', true) - ->get(); + $processed = 0; + $chunkSize = max(1, min($chunkSize, 500)); + $schedulerHealth = app(SchedulerHealthService::class); - foreach ($alertConfigs as $config) { - $this->checkAlertConfig($config); - } + DB::table('ptero_alert_configs') + ->where('is_active', true) + ->chunkById( + $chunkSize, + function (Collection $configs) use ( + &$processed, + $schedulerHealth + ): void { + $processed += $schedulerHealth->processRows( + SchedulerHealthService::TASK_CAPACITY_ALERTS, + 'alert_config', + $configs->pluck('id'), + function (int $alertConfigId) use ( + $schedulerHealth + ): bool { + $config = DB::table( + 'ptero_alert_configs' + ) + ->where('id', $alertConfigId) + ->where('is_active', true) + ->first(); + if ($config === null) { + return false; + } + + $this->checkAlertConfig( + $config, + $schedulerHealth + ); + + return true; + } + ); + }, + 'id' + ); + + return $processed; } - private function checkAlertConfig(object $config): void - { + private function checkAlertConfig( + object $config, + ?SchedulerHealthService $schedulerHealth = null, + ): void { // Skip if in cooldown if ($config->last_notification_at && now()->diffInMinutes($config->last_notification_at) < $config->cooldown_minutes) { return; } - try { - if ($config->location_id) { - $locations = [$config->location_id]; - } else { - $locations = collect($this->resourceService->getLocations())->pluck('id'); - } + if ($config->location_id) { + $locations = [$config->location_id]; + } else { + $locations = collect($this->resourceService->getLocations())->pluck('id'); + } - foreach ($locations as $locationId) { - $availability = $this->resourceService->getLocationAvailability($locationId); + $schedulerHealth ??= app(SchedulerHealthService::class); + $schedulerHealth->processRows( + SchedulerHealthService::TASK_CAPACITY_ALERTS, + 'alert_config_location', + collect($locations)->map( + fn ($locationId): string => (int) $config->id + .':' + .(int) $locationId + ), + function (string $identity) use ($config): bool { + [, $locationId] = array_map( + 'intval', + explode(':', $identity, 2) + ); + $availability = $this->resourceService + ->getLocationAvailability($locationId); $alerts = $this->checkThresholds($availability, $config); - if (! empty($alerts) && $this->sendNotifications($config, $availability, $alerts)) { + if ( + ! empty($alerts) + && $this->sendNotifications( + $config, + $availability, + $alerts + ) + ) { DB::table('ptero_alert_configs') ->where('id', $config->id) ->update(['last_notification_at' => now()]); } + + return true; } - } catch (\Exception $e) { - Log::error('Alert check failed', [ - 'config_id' => $config->id, - 'error' => $e->getMessage(), - ]); - } + ); } private function checkThresholds(array $availability, object $config): array { $alerts = []; - $memoryUtilization = $availability['total_capacity']['memory'] > 0 - ? ($availability['total_allocated']['memory'] / $availability['total_capacity']['memory']) * 100 - : 0; - - $diskUtilization = $availability['total_capacity']['disk'] > 0 - ? ($availability['total_allocated']['disk'] / $availability['total_capacity']['disk']) * 100 - : 0; - - if ($memoryUtilization >= $config->memory_critical_threshold) { - $alerts[] = [ - 'type' => 'critical', - 'resource' => 'memory', - 'utilization' => $memoryUtilization, - 'usage_percent' => round($memoryUtilization, 1), - 'threshold' => (int) $config->memory_critical_threshold, - ]; - } elseif ($memoryUtilization >= $config->memory_warning_threshold) { - $alerts[] = [ - 'type' => 'warning', - 'resource' => 'memory', - 'utilization' => $memoryUtilization, - 'usage_percent' => round($memoryUtilization, 1), - 'threshold' => (int) $config->memory_warning_threshold, - ]; - } - - if ($diskUtilization >= $config->disk_critical_threshold) { - $alerts[] = [ - 'type' => 'critical', - 'resource' => 'disk', - 'utilization' => $diskUtilization, - 'usage_percent' => round($diskUtilization, 1), - 'threshold' => (int) $config->disk_critical_threshold, - ]; - } elseif ($diskUtilization >= $config->disk_warning_threshold) { - $alerts[] = [ - 'type' => 'warning', - 'resource' => 'disk', - 'utilization' => $diskUtilization, - 'usage_percent' => round($diskUtilization, 1), - 'threshold' => (int) $config->disk_warning_threshold, - ]; + foreach (['memory', 'cpu', 'disk'] as $resource) { + $capacity = (int) data_get( + $availability, + "total_capacity.{$resource}", + 0 + ); + $available = data_get( + $availability, + "total_available.{$resource}" + ); + $used = $available !== null + ? max(0, $capacity - (int) $available) + : (int) data_get( + $availability, + "total_allocated.{$resource}", + 0 + ); + $utilization = $capacity > 0 + ? ($used / $capacity) * 100 + : 0.0; + $warning = (int) ( + $config->{"{$resource}_warning_threshold"} ?? 80 + ); + $critical = (int) ( + $config->{"{$resource}_critical_threshold"} ?? 95 + ); + + if ($utilization >= $critical) { + $alerts[] = [ + 'type' => 'critical', + 'resource' => $resource, + 'utilization' => $utilization, + 'usage_percent' => round($utilization, 1), + 'threshold' => $critical, + ]; + } elseif ($utilization >= $warning) { + $alerts[] = [ + 'type' => 'warning', + 'resource' => $resource, + 'utilization' => $utilization, + 'usage_percent' => round($utilization, 1), + 'threshold' => $warning, + ]; + } } return $alerts; @@ -130,7 +189,7 @@ private function sendNotifications(object $config, array $availability, array $a $locationScope = $availability['location_id'] ?? $config->location_id ?? null; $locationName = $availability['location_name'] ?? $config->location_name - ?? ($locationScope !== null ? 'Location #' . $locationScope : 'All Locations'); + ?? ($locationScope !== null ? 'Location #'.$locationScope : 'All Locations'); $alertConfig = $this->hydrateAlertConfig((object) array_merge((array) $config, [ 'location_id' => $locationScope, 'location_name' => $locationName, @@ -142,14 +201,21 @@ private function sendNotifications(object $config, array $availability, array $a if ($config->email_notifications) { $channelsTried[] = 'email'; - $recipients = $this->getAdminRecipients(); - - if ($recipients->isEmpty()) { - Log::warning('No admin recipients configured for capacity alert', [ + $configuredEmails = $this->configuredNotificationEmails($config); + $recipients = $this->getAdminRecipients()->reject( + fn (User $recipient): bool => in_array( + strtolower((string) $recipient->email), + $configuredEmails, + true + ) + ); + + if ($recipients->isEmpty() && $configuredEmails === []) { + Log::warning('No email recipients configured for capacity alert', [ 'alert_config_id' => $config->id, ]); $channelsFailed[] = 'email'; - $lastError = 'No admin recipients configured'; + $lastError = 'No email recipients configured'; } else { $emailDelivered = false; @@ -171,6 +237,29 @@ private function sendNotifications(object $config, array $availability, array $a } } + foreach ($configuredEmails as $email) { + try { + Notification::route('mail', $email)->notify( + new CapacityAlertNotification( + $alertConfig, + $alerts, + ) + ); + $emailDelivered = true; + } catch (\Throwable $e) { + Log::warning( + 'Failed to send configured capacity alert email', + [ + 'alert_config_id' => $config->id, + 'recipient_email' => $email, + 'error' => $e->getMessage(), + ] + ); + $lastError = $e->getMessage(); + $this->reportThrowable($e); + } + } + if ($emailDelivered) { $channelsOk[] = 'email'; } else { @@ -192,8 +281,8 @@ private function sendNotifications(object $config, array $availability, array $a 'title' => 'Resource Usage Alert', 'color' => $alertColor, 'fields' => array_map(fn ($alert) => [ - 'name' => ucfirst($alert['type']) . ' - ' . ucfirst($alert['resource']), - 'value' => round($alert['utilization'], 1) . '% utilized', + 'name' => ucfirst($alert['type']).' - '.ucfirst($alert['resource']), + 'value' => round($alert['utilization'], 1).'% utilized', 'inline' => true, ], $alerts), 'footer' => [ @@ -360,6 +449,132 @@ public function notifyShortfall( } } + /** + * Notify operators after the immediate queue retry series is exhausted. + * ReservationService persists a deduplication timestamp before calling this. + * + * @param array $snapshot + */ + public function notifyProvisioningFailure(array $snapshot): void + { + $operation = ($snapshot['operation'] ?? 'provisioning') === 'cancellation' + ? 'cancellation' + : 'provisioning'; + $recipients = $this->getAdminRecipients(); + if ($recipients->isEmpty()) { + Log::critical("Dynamic server {$operation} requires attention", $snapshot); + + return; + } + + foreach ($recipients as $recipient) { + try { + $recipient->notify(new ProvisioningFailedNotification($snapshot)); + } catch (\Throwable $exception) { + Log::error("Failed to notify operator about {$operation} failure", [ + 'service_id' => $snapshot['service_id'] ?? null, + 'recipient_id' => $recipient->id ?? null, + 'error' => $exception->getMessage(), + ]); + $this->reportThrowable($exception); + } + } + + $this->safeAudit( + $operation.'_failure_alerted', + 'resource_reservation', + (int) ($snapshot['reservation_id'] ?? 0), + [ + 'service_id' => $snapshot['service_id'] ?? null, + 'invoice_id' => $snapshot['invoice_id'] ?? null, + 'attempts' => $snapshot['attempts'] ?? null, + ] + ); + } + + /** + * Notify operators when a paid resource upgrade exhausts automatic + * retries. ServiceUpgradeService persists its deduplication timestamp + * before this method is called. + * + * @param array $snapshot + */ + public function notifyUpgradeFailure(array $snapshot): void + { + $recipients = $this->getAdminRecipients(); + if ($recipients->isEmpty()) { + Log::critical( + 'Dynamic resource upgrade requires attention', + $snapshot + ); + } + + foreach ($recipients as $recipient) { + try { + $recipient->notify( + new ProvisioningFailedNotification($snapshot) + ); + } catch (\Throwable $exception) { + Log::error( + 'Failed to notify operator about dynamic upgrade failure', + [ + 'upgrade_id' => $snapshot['upgrade_id'] ?? null, + 'service_id' => $snapshot['service_id'] ?? null, + 'recipient_id' => $recipient->id ?? null, + 'error' => $exception->getMessage(), + ] + ); + $this->reportThrowable($exception); + } + } + + $reservationId = (int) ($snapshot['reservation_id'] ?? 0); + $this->safeAudit( + 'upgrade_failure_alerted', + $reservationId > 0 ? 'resource_reservation' : 'service_upgrade', + $reservationId > 0 + ? $reservationId + : (int) ($snapshot['upgrade_id'] ?? 0), + [ + 'upgrade_id' => $snapshot['upgrade_id'] ?? null, + 'service_id' => $snapshot['service_id'] ?? null, + 'invoice_id' => $snapshot['invoice_id'] ?? null, + 'reservation_id' => $snapshot['reservation_id'] ?? null, + 'attempts' => $snapshot['attempts'] ?? null, + 'error' => $snapshot['error'] ?? null, + ] + ); + } + + /** + * Notify operators that an external/partial payment exists but its + * capacity guarantee may no longer be consumed. + * + * @param array $snapshot + */ + public function notifyPaymentAttention(array $snapshot): void + { + $recipients = $this->getAdminRecipients(); + if ($recipients->isEmpty()) { + Log::critical('Capacity invoice payment requires refund review', $snapshot); + + return; + } + + foreach ($recipients as $recipient) { + try { + $recipient->notify(new PaymentAttentionNotification($snapshot)); + } catch (\Throwable $exception) { + Log::error('Failed to notify operator about a late capacity payment', [ + 'invoice_id' => $snapshot['invoice_id'] ?? null, + 'recipient_id' => $recipient->id ?? null, + 'error' => $exception->getMessage(), + ]); + $this->reportThrowable($exception); + } + } + } + /** * Get all admin users (non-null role_id = admin in Paymenter). */ @@ -368,6 +583,38 @@ private function getAdminRecipients(): Collection return User::whereNotNull('role_id')->get(); } + /** + * @return array + */ + private function configuredNotificationEmails(object $config): array + { + $emails = $config->notification_emails ?? []; + if (is_string($emails)) { + $decoded = json_decode($emails, true); + $emails = is_array($decoded) ? $decoded : []; + } + if (! is_array($emails)) { + return []; + } + + $normalized = []; + foreach ($emails as $email) { + if (! is_string($email)) { + continue; + } + + $email = strtolower(trim($email)); + if ( + $email !== '' + && filter_var($email, FILTER_VALIDATE_EMAIL) !== false + ) { + $normalized[$email] = $email; + } + } + + return array_values($normalized); + } + private function hydrateAlertConfig(object $config): AlertConfig { if ($config instanceof AlertConfig) { diff --git a/Services/AllocationSelectionService.php b/Services/AllocationSelectionService.php new file mode 100644 index 0000000..01ab2f7 --- /dev/null +++ b/Services/AllocationSelectionService.php @@ -0,0 +1,236 @@ + $available + * @param list $requiredPorts + * @param list $allowedPortRanges + * @return list|null + */ + public function select( + array $available, + int $allocationCount, + array $requiredPorts = [], + array $allowedPortRanges = [], + bool $dedicatedIp = false + ): ?array { + if ($allocationCount < 1) { + throw new \InvalidArgumentException('At least one allocation is required.'); + } + + foreach ($requiredPorts as $requiredPort) { + if (! is_int($requiredPort) || $requiredPort < 1 || $requiredPort > 65535) { + throw new \InvalidArgumentException( + 'Required Pterodactyl ports must be integer values between 1 and 65535.' + ); + } + } + if (count(array_unique($requiredPorts)) !== count($requiredPorts)) { + throw new \InvalidArgumentException( + 'A required Pterodactyl port cannot be requested more than once.' + ); + } + if (count($requiredPorts) > $allocationCount) { + throw new \InvalidArgumentException( + 'Required ports cannot exceed the requested allocation count.' + ); + } + + foreach ($available as $allocation) { + if ( + ! is_int($allocation['id'] ?? null) + || $allocation['id'] < 1 + || ! is_string($allocation['ip'] ?? null) + || $allocation['ip'] === '' + || ! is_int($allocation['port'] ?? null) + || $allocation['port'] < 1 + || $allocation['port'] > 65535 + ) { + return null; + } + if ( + $dedicatedIp + && ! is_bool($allocation['ip_in_use'] ?? null) + ) { + return null; + } + } + $allowedPortRanges = $this->validatedRanges($allowedPortRanges); + + usort( + $available, + fn (array $left, array $right): int => $left['id'] <=> $right['id'] + ); + + $ids = array_column($available, 'id'); + if (count(array_unique($ids)) !== count($ids)) { + return null; + } + + if ($dedicatedIp) { + $candidates = []; + $byIp = []; + foreach ($available as $allocation) { + $key = $this->canonicalIp($allocation['ip']); + $byIp[$key] ??= [ + 'in_use' => false, + 'allocations' => [], + ]; + $byIp[$key]['in_use'] = $byIp[$key]['in_use'] + || $allocation['ip_in_use']; + $byIp[$key]['allocations'][] = $allocation; + } + foreach ($byIp as $ipGroup) { + if ($ipGroup['in_use']) { + continue; + } + $selected = $this->selectFromPool( + $ipGroup['allocations'], + $allocationCount, + $requiredPorts, + $allowedPortRanges + ); + if ($selected !== null) { + $candidates[] = $selected; + } + } + usort( + $candidates, + fn (array $left, array $right): int => min(array_column($left, 'id')) + <=> min(array_column($right, 'id')) + ); + + return $candidates[0] ?? null; + } + + return $this->selectFromPool( + $available, + $allocationCount, + $requiredPorts, + $allowedPortRanges + ); + } + + /** + * @param list $available + * @param list $requiredPorts + * @param list $allowedPortRanges + * @return list|null + */ + private function selectFromPool( + array $available, + int $allocationCount, + array $requiredPorts, + array $allowedPortRanges + ): ?array { + if (count($available) < $allocationCount) { + return null; + } + + $selected = []; + $selectedIds = []; + if ($allowedPortRanges !== []) { + $primary = collect($available)->first( + fn (array $allocation): bool => $this->portAllowed( + $allocation['port'], + $allowedPortRanges + ) + ); + if (! is_array($primary)) { + return null; + } + $selected[] = $primary; + $selectedIds[$primary['id']] = true; + } + foreach ($requiredPorts as $requiredPort) { + if (collect($selected)->contains( + fn (array $allocation): bool => $allocation['port'] === $requiredPort + )) { + continue; + } + $matches = array_values(array_filter( + $available, + fn (array $allocation): bool => $allocation['port'] === $requiredPort + && ! isset($selectedIds[$allocation['id']]) + )); + if ($matches === []) { + return null; + } + + $selected[] = $matches[0]; + $selectedIds[$matches[0]['id']] = true; + } + + foreach ($available as $allocation) { + if (count($selected) >= $allocationCount) { + break; + } + if (isset($selectedIds[$allocation['id']])) { + continue; + } + + $selected[] = $allocation; + $selectedIds[$allocation['id']] = true; + } + + return count($selected) === $allocationCount ? $selected : null; + } + + /** + * @param list $ranges + * @return list + */ + private function validatedRanges(array $ranges): array + { + foreach ($ranges as $range) { + if ( + ! is_array($range) + || ! is_int($range['from'] ?? null) + || ! is_int($range['to'] ?? null) + || $range['from'] < 1 + || $range['to'] > 65535 + || $range['from'] > $range['to'] + ) { + throw new \InvalidArgumentException( + 'Pterodactyl port ranges must be inclusive integer bounds between 1 and 65535.' + ); + } + } + + return array_values($ranges); + } + + /** + * @param list $ranges + */ + private function portAllowed(int $port, array $ranges): bool + { + foreach ($ranges as $range) { + if ($port >= $range['from'] && $port <= $range['to']) { + return true; + } + } + + return false; + } + + private function canonicalIp(string $ip): string + { + $packed = @inet_pton(trim($ip)); + + return $packed === false + ? strtolower(trim($ip)) + : bin2hex($packed); + } +} diff --git a/Services/AuditLogService.php b/Services/AuditLogService.php index 42ea642..a1dca75 100644 --- a/Services/AuditLogService.php +++ b/Services/AuditLogService.php @@ -24,7 +24,7 @@ public function log( $user = Auth::user(); return DB::table('ptero_audit_logs')->insertGetId([ - 'user_id' => $user?->id ?? 0, + 'user_id' => $user?->id, 'user_name' => $user?->name ?? 'System', 'user_email' => $user?->email ?? 'system@localhost', 'action' => $action, diff --git a/Services/ConfigOptionSetupService.php b/Services/ConfigOptionSetupService.php index 61fc310..7c0ce31 100644 --- a/Services/ConfigOptionSetupService.php +++ b/Services/ConfigOptionSetupService.php @@ -2,9 +2,13 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Services; +use App\Helpers\ExtensionHelper; use App\Models\ConfigOption; -use App\Rules\DynamicSliderPricingRule; +use App\Models\Product; +use App\Rules\DynamicSliderMetadataRule; +use App\Support\PanelEndpointIdentity; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\Concerns\AuditsExtensionActions; class ConfigOptionSetupService @@ -44,11 +48,30 @@ class ConfigOptionSetupService public function createDynamicSliderOptions(int $productId, array $config, array $locations = []): array { $created = DB::transaction(function () use ($productId, $config, $locations) { + $product = Product::query() + ->with(['plans.prices', 'settings', 'server']) + ->lockForUpdate() + ->findOrFail($productId); + $this->assertEligibleProduct($product); + $locations = $this->validatedLocations($product, $locations); + $this->validateSingleCurrency($product); + // Product is the shared first lock for quote readers and + // configuration writers. Retire only unbound cart quotes before + // guarded option/price mutations; invoice and upgrade commitments + // remain immutable and deliberately block the wizard. + $this->invalidateUnpaidReservations($productId); + $this->synchronizePlanBasePrice($product, $config); + + $product->allow_quantity = 'disabled'; + $product->save(); + $out = []; foreach (['memory', 'cpu', 'disk'] as $resourceType) { $enableKey = "enable_{$resourceType}_slider"; if (($config[$enableKey] ?? true) === false) { + $this->retireResourceOption($productId, $resourceType); + continue; } @@ -59,12 +82,13 @@ public function createDynamicSliderOptions(int $productId, array $config, array ); } - if (! empty($locations)) { - $out['location'] = $this->createLocationOption($productId, $locations); + $location = $this->synchronizeLocationOption($productId, $locations); + if ($location !== null) { + $out['location'] = $location; } return $out; - }); + }, 5); if (! empty($created)) { $this->safeAudit('setup_run', 'product_config', $productId, [ @@ -80,13 +104,21 @@ private function createResourceOption(int $productId, string $resourceType, arra { $defaults = $this->resourceDefaults[$resourceType] ?? []; - $metadata = $this->buildResourceMetadata($resourceType, $config, $defaults); + $metadata = $this->buildResourceMetadata( + $productId, + $resourceType, + $config, + $defaults + ); $existingOption = $this->findExistingOption($productId, $resourceType); if ($existingOption) { $existingOption->update([ 'type' => 'dynamic_slider', + 'env_variable' => $resourceType, + 'hidden' => false, + 'upgradable' => true, 'metadata' => $metadata, ]); @@ -96,7 +128,7 @@ private function createResourceOption(int $productId, string $resourceType, arra $option = ConfigOption::create([ 'name' => ucfirst($resourceType), 'type' => 'dynamic_slider', - 'env_variable' => strtoupper($resourceType), + 'env_variable' => $resourceType, 'hidden' => false, 'sort' => match ($resourceType) { 'memory' => 1, @@ -114,34 +146,69 @@ private function createResourceOption(int $productId, string $resourceType, arra return $option; } - private function buildResourceMetadata(string $resourceType, array $config, array $defaults): array - { + private function buildResourceMetadata( + int $productId, + string $resourceType, + array $config, + array $defaults + ): array { $pricingModel = $config['pricing_model'] ?? 'linear'; $divisor = $defaults['display_divisor'] ?? 1; $pricing = $this->buildPricingMetadata($resourceType, $pricingModel, $config); - $errors = []; - (new DynamicSliderPricingRule())->validate('metadata.pricing', $pricing, function (string $message) use (&$errors) { - $errors[] = $message; - }); - - if ($errors !== []) { - throw new \InvalidArgumentException(implode('; ', $errors)); - } - $metadata = [ + 'managed_by' => 'dynamic_pterodactyl', + 'managed_product_id' => $productId, 'resource_type' => $resourceType, - 'min' => (int) (($config["{$resourceType}_min"] ?? ($defaults['min'] / $divisor)) * $divisor), - 'max' => (int) (($config["{$resourceType}_max"] ?? ($defaults['max'] / $divisor)) * $divisor), - 'step' => (int) (($config["{$resourceType}_step"] ?? ($defaults['step'] / $divisor)) * $divisor), - 'default' => (int) (($config["{$resourceType}_default"] ?? ($defaults['default'] / $divisor)) * $divisor), + 'min' => $this->scaleDisplayValue( + $config["{$resourceType}_min"] ?? ($defaults['min'] / $divisor), + $divisor, + "{$resourceType} minimum" + ), + 'max' => $this->scaleDisplayValue( + $config["{$resourceType}_max"] ?? ($defaults['max'] / $divisor), + $divisor, + "{$resourceType} maximum" + ), + 'step' => $this->scaleDisplayValue( + $config["{$resourceType}_step"] ?? ($defaults['step'] / $divisor), + $divisor, + "{$resourceType} step" + ), + 'default' => $this->scaleDisplayValue( + $config["{$resourceType}_default"] ?? ($defaults['default'] / $divisor), + $divisor, + "{$resourceType} default" + ), 'unit' => $defaults['unit'], 'display_unit' => $defaults['display_unit'], 'display_divisor' => $defaults['display_divisor'], 'pricing' => $pricing, ]; + // Pterodactyl interprets zero RAM, CPU, or disk as an unlimited + // resource. Dynamic stock must never allow a customer-selectable zero + // minimum to bypass the finite inventory contract. + if ($metadata['min'] <= 0) { + throw new \InvalidArgumentException( + ucfirst($resourceType).' minimum must be greater than zero.' + ); + } + + $errors = []; + (new DynamicSliderMetadataRule)->validate( + 'metadata', + $metadata, + function (string $message) use (&$errors): void { + $errors[] = $message; + } + ); + + if ($errors !== []) { + throw new \InvalidArgumentException(implode('; ', $errors)); + } + return $metadata; } @@ -149,47 +216,122 @@ private function buildPricingMetadata(string $resourceType, string $pricingModel { $pricing = [ 'model' => $pricingModel, - 'base_price' => (float) ($config['base_price'] ?? 0), ]; switch ($pricingModel) { case 'linear': - $pricing['rate_per_unit'] = (float) ($config["{$resourceType}_rate"] ?? 0); + $pricing['rate_per_unit'] = $this->nonNegativeDecimal( + $config["{$resourceType}_rate"] ?? 0, + "{$resourceType} rate" + ); break; case 'tiered': - $pricing['tiers'] = $config["{$resourceType}_tiers"] ?? []; + $pricing['tiers'] = collect( + $config["{$resourceType}_tiers"] ?? [] + )->values()->map(function ($tier, int $index) use ($resourceType): array { + if (! is_array($tier)) { + throw new \InvalidArgumentException( + ucfirst($resourceType).' pricing tiers must be arrays.' + ); + } + + return [ + 'up_to' => ($tier['up_to'] ?? null) === null + ? null + : $this->nonNegativeDecimal( + $tier['up_to'], + "{$resourceType} tier ".($index + 1).' limit' + ), + 'rate' => $this->nonNegativeDecimal( + $tier['rate'] ?? null, + "{$resourceType} tier ".($index + 1).' rate' + ), + ]; + })->all(); break; case 'base_addon': - $pricing['included_units'] = (float) ($config["{$resourceType}_included"] ?? 0); - $pricing['overage_rate'] = (float) ($config["{$resourceType}_overage"] ?? 0); + $pricing['included_units'] = $this->nonNegativeDecimal( + $config["{$resourceType}_included"] ?? 0, + "{$resourceType} included units" + ); + $pricing['overage_rate'] = $this->nonNegativeDecimal( + $config["{$resourceType}_overage"] ?? 0, + "{$resourceType} overage rate" + ); break; + + default: + throw new \InvalidArgumentException( + 'Unknown dynamic resource pricing model.' + ); } return $pricing; } - private function createLocationOption(int $productId, array $locations): ConfigOption + private function synchronizeLocationOption(int $productId, array $locations): ?ConfigOption { - $existingLocation = $this->findExistingOption($productId, 'location'); + $existingLocation = $this->findExistingOption( + $productId, + 'location', + $locations !== [] + ); + + if ($locations === []) { + if ($existingLocation !== null) { + $existingLocation->update([ + 'hidden' => true, + 'upgradable' => false, + ]); + $existingLocation->children()->update(['hidden' => true]); + } + + return null; + } if ($existingLocation) { $locationOption = $existingLocation; + $locationOption->update([ + 'env_variable' => 'location', + 'hidden' => false, + 'upgradable' => false, + 'metadata' => array_merge( + (array) ($locationOption->metadata ?? []), + [ + 'managed_by' => 'dynamic_pterodactyl', + 'managed_product_id' => $productId, + 'resource_type' => 'location', + ] + ), + ]); } else { $locationOption = ConfigOption::create([ 'name' => 'Location', 'type' => 'select', - 'env_variable' => 'LOCATION', + 'env_variable' => 'location', 'hidden' => false, 'sort' => 0, 'parent_id' => null, + 'upgradable' => false, + 'metadata' => [ + 'managed_by' => 'dynamic_pterodactyl', + 'managed_product_id' => $productId, + 'resource_type' => 'location', + ], ]); $locationOption->products()->syncWithoutDetaching([$productId]); } + $selectedLocationIds = []; foreach ($locations as $loc) { + if (! isset($loc['id']) || ! is_numeric($loc['id'])) { + throw new \InvalidArgumentException('Every selected location must have a numeric Pterodactyl ID.'); + } + + $selectedLocationIds[] = (string) $loc['id']; $locationName = $loc['long'] ?: $loc['short']; ConfigOption::updateOrCreate([ 'parent_id' => $locationOption->id, @@ -199,28 +341,82 @@ private function createLocationOption(int $productId, array $locations): ConfigO 'type' => 'option', 'hidden' => false, 'sort' => 0, + 'metadata' => [ + 'managed_by' => 'dynamic_pterodactyl', + 'managed_product_id' => $productId, + 'managed_location_id' => (int) $loc['id'], + ], ]); } + $locationOption->children() + ->whereNotIn('env_variable', $selectedLocationIds) + ->update(['hidden' => true]); + return $locationOption; } + private function retireResourceOption(int $productId, string $resourceType): void + { + $option = $this->findExistingOption($productId, $resourceType, false); + if ($option === null) { + return; + } + + $option->update([ + 'hidden' => true, + 'upgradable' => false, + ]); + } + + private function synchronizePlanBasePrice(Product $product, array $config): void + { + $monthlyBase = $this->nonNegativeDecimal( + $config['base_price'] ?? 0, + 'monthly base price' + ); + + foreach ($product->plans as $plan) { + $multiplier = match ($plan->billing_unit) { + 'day' => (int) $plan->billing_period / 30, + 'week' => (int) $plan->billing_period / 4, + 'year' => (int) $plan->billing_period * 12, + default => max(1, (int) $plan->billing_period), + }; + + $plan->dynamic_slider_base_price = round($monthlyBase * $multiplier, 2); + $plan->save(); + } + } + + private function validateSingleCurrency(Product $product): void + { + $currencies = $product->plans + ->flatMap(fn ($plan) => $plan->prices->pluck('currency_code')) + ->filter() + ->unique() + ->values(); + + if ($currencies->count() > 1) { + throw new \InvalidArgumentException( + 'Dynamic slider pricing currently supports one product currency. Configure separate products for each currency.' + ); + } + } + public function checkExistingOptions(int $productId): array { $resourceTypes = ['memory', 'cpu', 'disk']; - $existing = DB::table('config_options') - ->join('config_option_products', 'config_options.id', '=', 'config_option_products.config_option_id') - ->where('config_option_products.product_id', $productId) - ->where('config_options.type', 'dynamic_slider') - ->whereNull('config_options.parent_id') - ->get(['config_options.id', 'config_options.name', 'config_options.metadata']); - $existingTypes = []; - foreach ($existing as $option) { - $metadata = json_decode($option->metadata, true); - $resourceType = $metadata['resource_type'] ?? strtolower($option->name); - if (in_array($resourceType, $resourceTypes)) { + foreach ($this->productOptions($productId) as $option) { + $metadata = (array) ($option->metadata ?? []); + $resourceType = strtolower((string) ($metadata['resource_type'] ?? '')); + if ( + $option->type === 'dynamic_slider' + && ($metadata['managed_by'] ?? null) === 'dynamic_pterodactyl' + && in_array($resourceType, $resourceTypes, true) + ) { $existingTypes[$resourceType] = $option->id; } } @@ -232,19 +428,326 @@ public function checkExistingOptions(int $productId): array ]; } - private function findExistingOption(int $productId, string $name): ?ConfigOption + private function findExistingOption( + int $productId, + string $name, + bool $failOnConflict = true + ): ?ConfigOption { + $name = strtolower($name); + $candidates = $this->productOptions($productId) + ->filter(function (ConfigOption $option) use ($name): bool { + $metadata = (array) ($option->metadata ?? []); + + return ($metadata['managed_by'] ?? null) === 'dynamic_pterodactyl' + && ( + strtolower((string) ($metadata['resource_type'] ?? '')) === $name + || ( + $name === 'location' + && strtolower((string) $option->env_variable) === 'location' + ) + ); + }) + ->values(); + + if ($candidates->count() > 1) { + throw new \InvalidArgumentException( + "This product has multiple Dynamic Pterodactyl {$name} options." + ); + } + if ($candidates->count() === 1) { + $option = $candidates->first(); + $metadata = (array) ($option->metadata ?? []); + $owner = $metadata['managed_product_id'] ?? null; + if ($owner !== null && (int) $owner !== $productId) { + throw new \InvalidArgumentException( + "The managed {$name} option belongs to a different product." + ); + } + + // One-time adoption of options created by an earlier plugin version + // is permitted only when the explicit managed_by marker is present. + if ($owner === null) { + $option->metadata = array_merge($metadata, [ + 'managed_product_id' => $productId, + 'resource_type' => $name, + ]); + $option->save(); + } + + return $option; + } + + $conflict = $this->productOptions($productId)->first( + fn (ConfigOption $option): bool => strtolower((string) $option->env_variable) === $name + || strtolower((string) $option->name) === $name + ); + if ($conflict !== null && $failOnConflict) { + throw new \InvalidArgumentException( + "An unmanaged {$name} configuration option already exists. Rename or remove it before running this wizard." + ); + } + + return null; + } + + private function productOptions(int $productId) { - $result = DB::table('config_options') - ->join('config_option_products', 'config_options.id', '=', 'config_option_products.config_option_id') - ->where('config_option_products.product_id', $productId) - ->where(function ($query) use ($name) { - $query->whereRaw('LOWER(config_options.name) = ?', [strtolower($name)]) - ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(config_options.metadata, '$.resource_type')) = ?", [$name]); + return ConfigOption::query() + ->whereNull('parent_id') + ->whereHas( + 'products', + fn ($query) => $query->whereKey($productId) + ) + ->get(); + } + + private function assertEligibleProduct(Product $product): void + { + if ($product->hidden) { + throw new \InvalidArgumentException( + 'Dynamic stock cannot be configured on a hidden product.' + ); + } + if ( + $product->server === null + || ! $product->server->enabled + || $product->server->extension !== 'Pterodactyl' + ) { + throw new \InvalidArgumentException( + 'Dynamic stock requires an enabled Pterodactyl server extension.' + ); + } + + $settings = ExtensionHelper::settingsToArray( + $product->server->settings + ); + $host = trim((string) ($settings['host'] ?? '')); + try { + $panelIdentity = PanelEndpointIdentity::hash($host); + } catch (\InvalidArgumentException) { + $panelIdentity = ''; + } + if ( + $panelIdentity === '' + || ! hash_equals( + app(PterodactylInventoryService::class)->panelIdentity(), + $panelIdentity + ) + ) { + throw new \InvalidArgumentException( + 'The product provisioner and Dynamic Pterodactyl stock service must target the same panel.' + ); + } + } + + /** + * Re-resolve every selected location from the authoritative panel. When no + * customer choice is configured, the product must have one static location. + * + * @return list + */ + private function validatedLocations(Product $product, array $locations): array + { + $available = collect(app(PterodactylInventoryService::class)->locations()) + ->keyBy('id'); + + if ($locations !== []) { + $validated = []; + foreach ($locations as $location) { + $id = is_array($location) + ? ($location['id'] ?? null) + : null; + if (! is_int($id) && ! ( + is_string($id) + && preg_match('/^[1-9]\d*$/D', $id) === 1 + )) { + throw new \InvalidArgumentException( + 'Every selected location must have a positive integer ID.' + ); + } + + $authoritative = $available->get((int) $id); + if (! is_array($authoritative)) { + throw new \InvalidArgumentException( + 'A selected location no longer exists in Pterodactyl.' + ); + } + $validated[(int) $id] = $authoritative; + } + + return array_values($validated); + } + + $raw = $product->settings + ->firstWhere('key', 'location_ids') + ?->value; + $ids = is_array($raw) ? $raw : [$raw]; + $ids = collect($ids) + ->filter(fn ($id): bool => $id !== null && $id !== '') + ->map(function ($id): int { + if (! is_int($id) && ! ( + is_string($id) + && preg_match('/^[1-9]\d*$/D', $id) === 1 + )) { + throw new \InvalidArgumentException( + 'The product static location must be a positive integer.' + ); + } + + return (int) $id; }) - ->whereNull('config_options.parent_id') - ->first(); + ->unique() + ->values(); - return $result ? ConfigOption::find($result->id) : null; + if ($ids->count() !== 1 || ! $available->has($ids->first())) { + throw new \InvalidArgumentException( + 'Select customer locations or configure exactly one valid static Pterodactyl location on the product.' + ); + } + + return []; + } + + private function scaleDisplayValue( + mixed $value, + int $divisor, + string $label + ): int { + if ($divisor <= 0) { + throw new \InvalidArgumentException( + "The {$label} display divisor is invalid." + ); + } + + $text = is_int($value) || is_float($value) + ? (string) $value + : (is_string($value) ? $value : ''); + if ( + preg_match('/^(0|[1-9]\d*)(?:\.(\d+))?$/D', $text, $matches) !== 1 + ) { + throw new \InvalidArgumentException( + "The {$label} must be a non-negative decimal without exponent notation." + ); + } + + $whole = filter_var( + $matches[1], + FILTER_VALIDATE_INT, + FILTER_NULL_ON_FAILURE + ); + if ($whole === null || $whole > intdiv(PHP_INT_MAX, $divisor)) { + throw new \InvalidArgumentException( + "The {$label} is outside the supported range." + ); + } + + $scaled = $whole * $divisor; + $fraction = $matches[2] ?? ''; + if ($fraction === '') { + return $scaled; + } + if (strlen($fraction) > 18) { + throw new \InvalidArgumentException( + "The {$label} has more precision than the internal unit supports." + ); + } + + $denominator = 10 ** strlen($fraction); + $numerator = (int) $fraction; + if ($numerator > intdiv(PHP_INT_MAX, $divisor)) { + throw new \InvalidArgumentException( + "The {$label} is outside the supported range." + ); + } + $fractionProduct = $numerator * $divisor; + if ($fractionProduct % $denominator !== 0) { + throw new \InvalidArgumentException( + "The {$label} cannot be represented exactly in internal resource units." + ); + } + + $fractionScaled = intdiv($fractionProduct, $denominator); + if ($scaled > PHP_INT_MAX - $fractionScaled) { + throw new \InvalidArgumentException( + "The {$label} is outside the supported range." + ); + } + + return $scaled + $fractionScaled; + } + + private function nonNegativeDecimal(mixed $value, string $label): float + { + $text = is_int($value) || is_float($value) + ? (string) $value + : (is_string($value) ? $value : ''); + if ( + preg_match('/^(0|[1-9]\d*)(?:\.\d+)?$/D', $text) !== 1 + || ! is_finite((float) $text) + || (float) $text < 0 + ) { + throw new \InvalidArgumentException( + "The {$label} must be a finite non-negative decimal without exponent notation." + ); + } + + return (float) $text; + } + + private function invalidateUnpaidReservations(int $productId): void + { + $candidateQuery = DB::table('ptero_resource_reservations') + ->where('product_id', $productId) + ->where('status', 'pending') + ->whereNull('service_id') + ->whereNull('invoice_id'); + if (Schema::hasColumn('ptero_resource_reservations', 'service_upgrade_id')) { + $candidateQuery->whereNull('service_upgrade_id'); + } + $candidateIds = $candidateQuery->orderBy('id')->pluck('id'); + + foreach ($candidateIds as $candidateId) { + $reservation = DB::table('ptero_resource_reservations') + ->where('id', $candidateId) + ->lockForUpdate() + ->first(); + if ( + $reservation === null + || $reservation->status !== 'pending' + || (int) $reservation->product_id !== $productId + || $reservation->service_id !== null + || $reservation->invoice_id !== null + || ( + property_exists($reservation, 'service_upgrade_id') + && $reservation->service_upgrade_id !== null + ) + ) { + continue; + } + + $updates = [ + 'status' => 'cancelled', + 'admin_notes' => 'Unbound cart quote invalidated after dynamic product configuration changed.', + 'updated_at' => now(), + ]; + if (Schema::hasColumn('ptero_resource_reservations', 'upgrade_guard_id')) { + $updates['upgrade_guard_id'] = null; + } + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->where('status', 'pending') + ->update($updates); + + if (Schema::hasTable('ptero_reservation_allocations')) { + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservation->id) + ->whereNull('released_at') + ->update([ + 'released_at' => now(), + 'updated_at' => now(), + ]); + } + } } public static function getProductsWithSlidersCount(): int @@ -252,6 +755,7 @@ public static function getProductsWithSlidersCount(): int return DB::table('config_options') ->join('config_option_products', 'config_options.id', '=', 'config_option_products.config_option_id') ->where('config_options.type', 'dynamic_slider') + ->where('config_options.hidden', false) ->whereNull('config_options.parent_id') ->distinct('config_option_products.product_id') ->count('config_option_products.product_id'); diff --git a/Services/LegacyReservationReadinessService.php b/Services/LegacyReservationReadinessService.php new file mode 100644 index 0000000..2fa4f47 --- /dev/null +++ b/Services/LegacyReservationReadinessService.php @@ -0,0 +1,43 @@ + + * }> + */ + public function blockers(): array + { + return $this->gate()->blockers(); + } + + public function assertReady(): void + { + $this->gate()->assertReady(); + } + + private function gate(): object + { + $gate = require dirname(__DIR__).'/migration-readiness.php'; + if (! is_object($gate) || ! method_exists($gate, 'assertReady')) { + throw new \RuntimeException( + 'Dynamic Pterodactyl migration readiness hook is invalid.' + ); + } + + return $gate; + } +} diff --git a/Services/NodeSelectionService.php b/Services/NodeSelectionService.php index 570ecd1..d4aab6b 100644 --- a/Services/NodeSelectionService.php +++ b/Services/NodeSelectionService.php @@ -6,62 +6,95 @@ class NodeSelectionService { private ResourceCalculationService $resourceService; - public function __construct(ResourceCalculationService $resourceService) - { + public function __construct( + ResourceCalculationService $resourceService, + private readonly AllocationSelectionService $allocations + ) { $this->resourceService = $resourceService; } /** - * Select the best node for given resource requirements + * Select an eligible node and one primary Pterodactyl allocation. * - * Algorithm: Best-fit with headroom weighting - * - Memory: 50% weight (most commonly upgraded) - * - Disk: 35% weight (harder to migrate) - * - CPU: 15% weight (often unlimited/shared) + * @param array{memory: int, cpu: int, disk: int} $requirements */ - public function selectBestNode(int $locationId, array $requirements): ?array - { - $locationData = $this->resourceService->getLocationAvailability($locationId); + public function selectBestNode( + int $locationId, + array $requirements, + ?string $excludeReservationToken = null + ): ?array { + return $this->selectBestNodeWithAllocations( + $locationId, + $requirements, + 1, + $excludeReservationToken + ); + } + + /** + * Select an eligible node and the exact free allocations that should be + * locked by the reservation transaction. + * + * @param array{memory: int, cpu: int, disk: int} $requirements + */ + public function selectBestNodeWithAllocations( + int $locationId, + array $requirements, + int $allocationCount = 1, + ?string $excludeReservationToken = null, + array $requiredPorts = [], + array $allowedPortRanges = [], + bool $dedicatedIp = false + ): ?array { + $locationData = $this->resourceService->getLocationAvailability( + $locationId, + $excludeReservationToken + ); $candidates = []; foreach ($locationData['nodes'] as $node) { - // Skip nodes in maintenance mode - if ($node['maintenance_mode'] ?? false) { + if (! ($node['eligible'] ?? false)) { continue; } - // Check if node can accommodate requirements - if ($node['available']['memory'] < $requirements['memory']) { - continue; - } - if ($node['available']['cpu'] < $requirements['cpu']) { - continue; - } - if ($node['available']['disk'] < $requirements['disk']) { + $selectedAllocations = $this->allocations->select( + $node['available_allocations'] ?? [], + $allocationCount, + $requiredPorts, + $allowedPortRanges, + $dedicatedIp + ); + if ($selectedAllocations === null) { continue; } - // Calculate remaining headroom after allocation - $remainingMemory = $node['available']['memory'] - $requirements['memory']; - $remainingCpu = $node['available']['cpu'] - $requirements['cpu']; - $remainingDisk = $node['available']['disk'] - $requirements['disk']; + foreach (['memory', 'cpu', 'disk'] as $resource) { + if ( + ! isset($requirements[$resource]) + || ! is_int($requirements[$resource]) + || $requirements[$resource] < 1 + || (int) ($node['available'][$resource] ?? -1) < $requirements[$resource] + ) { + continue 2; + } + } - // Weighted score: prioritize memory headroom, then disk, then CPU - $memoryScore = ($remainingMemory / max(1, $node['total']['memory'])) * 0.50; - $diskScore = ($remainingDisk / max(1, $node['total']['disk'])) * 0.35; - $cpuScore = ($remainingCpu / max(1, $node['total']['cpu'])) * 0.15; + $remaining = [ + 'memory' => $node['available']['memory'] - $requirements['memory'], + 'cpu' => $node['available']['cpu'] - $requirements['cpu'], + 'disk' => $node['available']['disk'] - $requirements['disk'], + ]; - $score = $memoryScore + $diskScore + $cpuScore; + $score = ($remaining['memory'] / max(1, $node['total']['memory'])) * 0.50 + + ($remaining['cpu'] / max(1, $node['total']['cpu'])) * 0.15 + + ($remaining['disk'] / max(1, $node['total']['disk'])) * 0.35; + $node['selected_allocations'] = $selectedAllocations; $candidates[] = [ 'node' => $node, 'score' => $score, - 'remaining' => [ - 'memory' => $remainingMemory, - 'cpu' => $remainingCpu, - 'disk' => $remainingDisk, - ], + 'remaining' => $remaining, ]; } @@ -69,8 +102,13 @@ public function selectBestNode(int $locationId, array $requirements): ?array return null; } - // Sort by score descending, return highest - usort($candidates, fn ($a, $b) => $b['score'] <=> $a['score']); + usort($candidates, function (array $left, array $right): int { + $score = $right['score'] <=> $left['score']; + + return $score !== 0 + ? $score + : $left['node']['node_id'] <=> $right['node']['node_id']; + }); return $candidates[0]['node']; } diff --git a/Services/ProductResourceConfigurationService.php b/Services/ProductResourceConfigurationService.php new file mode 100644 index 0000000..8c7af0e --- /dev/null +++ b/Services/ProductResourceConfigurationService.php @@ -0,0 +1,682 @@ +inventory = $inventory; + } + + /** + * Resolve and strictly validate the complete resource vector represented by + * a product and the customer's current configuration. + * + * @param array $submittedOptions + * @return array{ + * product_id: int, + * location_id: int, + * resources: array{memory: int, cpu: int, disk: int}, + * sliders: array, + * allocation_count: int, + * required_ports: list, + * allocation_mappings: list, + * allowed_port_ranges: list, + * dedicated_ip: bool + * } + */ + public function forQuote(Product $product, array $submittedOptions): array + { + if (DB::transactionLevel() === 0) { + return DB::transaction( + fn (): array => $this->forQuote( + $product, + $submittedOptions + ), + 5 + ); + } + + $product = app(CapacityConfigurationLockService::class) + ->lockProduct((int) $product->id); + $product->loadMissing([ + 'settings', + 'server.settings', + ]); + + if ($product->server === null || $product->server->extension !== 'Pterodactyl') { + throw new InvalidStockConfigurationException( + 'This product does not use the Pterodactyl server extension.' + ); + } + + $provisioningUrl = $this->normalizePanelUrl((string) ( + $product->server->settings?->firstWhere('key', 'host')?->value ?? '' + )); + if ( + $provisioningUrl === '' + || ! hash_equals( + $this->inventory()->panelIdentity(), + hash('sha256', $provisioningUrl) + ) + ) { + throw new InvalidStockConfigurationException( + 'The product and stock service are not configured for the same Pterodactyl panel.' + ); + } + $this->inventory()->assertExclusiveProvisioningControl(); + + $selected = $this->normalizeSubmittedOptions($submittedOptions); + $activeOptions = $this->activeProductOptions($product); + if (array_diff(array_keys($selected), $activeOptions->modelKeys()) !== []) { + throw new InvalidResourceSelectionException( + 'A submitted configuration option is not available for this product.' + ); + } + + $settings = $product->settings + ->mapWithKeys(fn ($setting): array => [ + strtolower((string) $setting->key) => $setting->value, + ]); + if (! in_array($settings->get('node'), [null, '', 0, '0'], true)) { + throw new InvalidStockConfigurationException( + 'Dynamic stock products must not be pinned to a Pterodactyl node.' + ); + } + if (trim((string) ($settings->get('cpu_pinning') ?? '')) !== '') { + throw new InvalidStockConfigurationException( + 'Dynamic CPU stock cannot be combined with static CPU pinning.' + ); + } + $resources = []; + $sliders = []; + $locationId = null; + + foreach ($activeOptions as $option) { + $resource = strtolower((string) $option->getMetadata('resource_type', '')); + + if ($option->type === 'dynamic_slider' && in_array( + $resource, + ['memory', 'cpu', 'disk'], + true + )) { + if (isset($sliders[$resource])) { + throw new InvalidStockConfigurationException( + "The product has more than one {$resource} slider." + ); + } + + $slider = $this->sliderConfiguration($option); + $value = array_key_exists((int) $option->id, $selected) + ? $this->selectionInteger( + $selected[(int) $option->id], + "{$resource} selection" + ) + : $slider['default']; + + if ( + $value < $slider['min'] + || $value > $slider['max'] + || (($value - $slider['min']) % $slider['step']) !== 0 + ) { + throw new InvalidResourceSelectionException( + "The selected {$resource} value is outside its allowed range or step." + ); + } + + $resources[$resource] = $value; + $sliders[$resource] = $slider; + } + + if ($this->isLocationOption($option)) { + if ($locationId !== null) { + throw new InvalidStockConfigurationException( + 'The product has more than one location option.' + ); + } + + $locationId = $this->selectedLocation($option, $selected); + } + } + + foreach (['memory', 'cpu', 'disk'] as $resource) { + if (isset($resources[$resource])) { + continue; + } + + $resources[$resource] = $this->positiveConfigurationInteger( + $settings->get($resource), + "product {$resource}" + ); + } + + if ($sliders === []) { + throw new InvalidStockConfigurationException( + 'This product does not have a dynamic resource slider.' + ); + } + + $locationId ??= $this->staticLocation($settings->get('location_ids')); + + $portArray = $settings->get('port_array'); + $dedicatedIp = $this->configurationBoolean( + $settings->get('dedicated_ip'), + 'dedicated IP' + ); + $allowedPortRanges = $this->portRanges( + $settings->get('port_range') + ); + if ( + $portArray !== null + && $portArray !== '' + && ($dedicatedIp || $allowedPortRanges !== []) + ) { + throw new InvalidStockConfigurationException( + 'A product cannot combine a port array with dedicated IP or port-range deployment.' + ); + } + $additionalAllocations = $this->nonNegativeConfigurationInteger( + $settings->get('additional_allocations') ?? 0, + 'additional allocations' + ); + if ($additionalAllocations > 100) { + throw new InvalidStockConfigurationException( + 'A product cannot reserve more than 100 additional allocations.' + ); + } + $allocationRequirements = $this->allocationRequirements( + $portArray, + $additionalAllocations + ); + + return [ + 'product_id' => (int) $product->id, + 'location_id' => $locationId, + 'resources' => [ + 'memory' => $resources['memory'], + 'cpu' => $resources['cpu'], + 'disk' => $resources['disk'], + ], + 'sliders' => $sliders, + 'allocation_count' => $allocationRequirements['count'], + 'required_ports' => $allocationRequirements['ports'], + 'allocation_mappings' => $allocationRequirements['mappings'], + 'allowed_port_ranges' => $allowedPortRanges, + 'dedicated_ip' => $dedicatedIp, + ]; + } + + /** + * Resolve the current database truth instead of trusting a possibly stale + * eager-loaded relation. Retired wizard options remain attached for + * historical services, but hidden options and child values are not active + * customer inputs. + * + * @return Collection + */ + private function activeProductOptions(Product $product): Collection + { + return ConfigOption::query() + ->whereHas( + 'products', + fn ($query) => $query->whereKey($product->getKey()) + ) + ->where('hidden', false) + ->whereNull('parent_id') + ->orderBy('sort') + ->orderByDesc('id') + ->get(); + } + + /** + * @return array{config_option_id: int, min: int, max: int, step: int, default: int} + */ + private function sliderConfiguration(ConfigOption $option): array + { + $minimum = $this->positiveConfigurationInteger( + $option->getMetadata('min'), + "{$option->name} minimum" + ); + $maximum = $this->positiveConfigurationInteger( + $option->getMetadata('max'), + "{$option->name} maximum" + ); + $step = $this->positiveConfigurationInteger( + $option->getMetadata('step'), + "{$option->name} step" + ); + $default = $this->positiveConfigurationInteger( + $option->getMetadata('default'), + "{$option->name} default" + ); + + if ( + $maximum < $minimum + || (($maximum - $minimum) % $step) !== 0 + || $default < $minimum + || $default > $maximum + || (($default - $minimum) % $step) !== 0 + ) { + throw new InvalidStockConfigurationException( + "The {$option->name} slider metadata is inconsistent." + ); + } + + return [ + 'config_option_id' => (int) $option->id, + 'min' => $minimum, + 'max' => $maximum, + 'step' => $step, + 'default' => $default, + ]; + } + + /** + * @param array $selected + */ + private function selectedLocation(ConfigOption $option, array $selected): int + { + if (! array_key_exists((int) $option->id, $selected)) { + throw new InvalidResourceSelectionException( + 'A deployment location must be selected.' + ); + } + + $childId = $this->selectionInteger( + $selected[(int) $option->id], + 'location selection' + ); + $child = $option->availableChildren()->whereKey($childId)->first(); + if ($child === null) { + throw new InvalidResourceSelectionException( + 'The selected deployment location is not available for this product.' + ); + } + + return $this->positiveConfigurationInteger( + $child->env_variable, + 'Pterodactyl location ID' + ); + } + + private function isLocationOption(ConfigOption $option): bool + { + return strtolower(trim((string) $option->env_variable)) === 'location' + || strtolower(trim((string) $option->name)) === 'location'; + } + + private function staticLocation(mixed $rawLocations): int + { + if (is_string($rawLocations)) { + $decoded = json_decode($rawLocations, true); + $rawLocations = json_last_error() === JSON_ERROR_NONE + ? $decoded + : $rawLocations; + } + + $locations = is_array($rawLocations) ? array_values($rawLocations) : [$rawLocations]; + if (count($locations) !== 1) { + throw new InvalidStockConfigurationException( + 'Products without a location option must have exactly one static location.' + ); + } + + return $this->positiveConfigurationInteger( + $locations[0], + 'static Pterodactyl location' + ); + } + + /** + * @return array{ + * count: int, + * ports: list, + * mappings: list + * } + */ + private function allocationRequirements( + mixed $raw, + int $additionalAllocations = 0 + ): array { + if ($raw === null || $raw === '') { + $mappings = [[ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => null, + 'is_primary' => true, + ]]; + for ($index = 0; $index < $additionalAllocations; $index++) { + $mappings[] = [ + 'environment_key' => 'NONE', + 'requested_port' => null, + 'is_primary' => false, + ]; + } + + return [ + 'count' => count($mappings), + 'ports' => [], + 'mappings' => $mappings, + ]; + } + + if (! is_string($raw)) { + throw new InvalidStockConfigurationException( + 'The product port mapping is invalid.' + ); + } + + $decoded = json_decode($raw, true); + if (! is_array($decoded) || $decoded === []) { + throw new InvalidStockConfigurationException( + 'The product port mapping is invalid.' + ); + } + + $count = 0; + $environmentKeys = []; + $requestedPorts = []; + $mappings = []; + foreach ($decoded as $environmentKey => $ports) { + if ( + ! is_string($environmentKey) + || preg_match('/^[A-Z][A-Z0-9_]*$/', $environmentKey) !== 1 + || isset($environmentKeys[strtoupper($environmentKey)]) + ) { + throw new InvalidStockConfigurationException( + 'The product port mapping contains a duplicate or malformed environment key.' + ); + } + $environmentKeys[strtoupper($environmentKey)] = true; + + $ports = is_array($ports) ? array_values($ports) : [$ports]; + if ( + strtoupper($environmentKey) !== 'NONE' + && count($ports) !== 1 + ) { + throw new InvalidStockConfigurationException( + "The product port mapping may assign exactly one port to {$environmentKey}. " + .'Use NONE or additional allocations for unbound ports.' + ); + } + foreach ($ports as $index => $port) { + $port = $this->positiveConfigurationInteger($port, 'port mapping'); + if ($port > 65535) { + throw new InvalidStockConfigurationException( + 'The product port mapping contains an invalid port.' + ); + } + if (isset($requestedPorts[$port])) { + throw new InvalidStockConfigurationException( + 'The product port mapping cannot request the same port more than once.' + ); + } + $requestedPorts[$port] = true; + $mappings[] = [ + 'environment_key' => $environmentKey, + 'requested_port' => $port, + 'is_primary' => $environmentKey === 'SERVER_PORT' && $index === 0, + ]; + $count++; + } + } + + if (! isset($environmentKeys['SERVER_PORT'])) { + throw new InvalidStockConfigurationException( + 'The product port mapping must define SERVER_PORT.' + ); + } + for ($index = 0; $index < $additionalAllocations; $index++) { + $mappings[] = [ + 'environment_key' => 'NONE', + 'requested_port' => null, + 'is_primary' => false, + ]; + $count++; + } + + return [ + 'count' => max(1, $count), + 'ports' => array_map('intval', array_keys($requestedPorts)), + 'mappings' => $mappings, + ]; + } + + /** + * @return list + */ + private function portRanges(mixed $raw): array + { + if ($raw === null || $raw === '' || $raw === []) { + return []; + } + + if (is_string($raw)) { + $decoded = json_decode($raw, true); + $raw = json_last_error() === JSON_ERROR_NONE + ? $decoded + : explode(',', $raw); + } + if (! is_array($raw) || ! array_is_list($raw)) { + throw new InvalidStockConfigurationException( + 'The product port-range configuration must be a list.' + ); + } + + $ranges = []; + foreach ($raw as $range) { + if ( + ! is_string($range) + || preg_match( + '/^\s*(\d{1,5})(?:\s*-\s*(\d{1,5}))?\s*$/', + $range, + $matches + ) !== 1 + ) { + throw new InvalidStockConfigurationException( + 'Each product port range must be a port or inclusive start-end pair.' + ); + } + $from = (int) $matches[1]; + $to = isset($matches[2]) && $matches[2] !== '' + ? (int) $matches[2] + : $from; + if ($from < 1 || $to > 65535 || $from > $to) { + throw new InvalidStockConfigurationException( + 'Each product port range must stay between 1 and 65535.' + ); + } + $ranges[] = ['from' => $from, 'to' => $to]; + } + + usort( + $ranges, + fn (array $left, array $right): int => [$left['from'], $left['to']] <=> [$right['from'], $right['to']] + ); + $merged = []; + foreach ($ranges as $range) { + $last = array_key_last($merged); + if ( + $last !== null + && $range['from'] <= $merged[$last]['to'] + 1 + ) { + $merged[$last]['to'] = max( + $merged[$last]['to'], + $range['to'] + ); + + continue; + } + $merged[] = $range; + } + + return $merged; + } + + private function configurationBoolean(mixed $value, string $field): bool + { + if ($value === null || $value === '') { + return false; + } + if (is_bool($value)) { + return $value; + } + if (is_int($value) && ($value === 0 || $value === 1)) { + return $value === 1; + } + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['0', 'false', 'off', 'no'], true)) { + return false; + } + if (in_array($normalized, ['1', 'true', 'on', 'yes'], true)) { + return true; + } + } + + throw new InvalidStockConfigurationException( + "The {$field} configuration must be a boolean." + ); + } + + /** + * Accept Paymenter's list of {option_id,value} entries and the keyed map + * emitted by the quote client. + * + * @param array $submitted + * @return array + */ + private function normalizeSubmittedOptions(array $submitted): array + { + $normalized = []; + + if (array_is_list($submitted)) { + foreach ($submitted as $entry) { + if (! is_array($entry) || ! array_key_exists('option_id', $entry)) { + throw new InvalidResourceSelectionException( + 'Configuration options must identify an option and value.' + ); + } + + $optionId = $this->selectionInteger( + $entry['option_id'], + 'configuration option ID' + ); + if (array_key_exists($optionId, $normalized)) { + throw new InvalidResourceSelectionException( + 'A configuration option cannot be submitted more than once.' + ); + } + $normalized[$optionId] = $entry['value'] ?? null; + } + + return $normalized; + } + + foreach ($submitted as $optionId => $value) { + $normalized[$this->selectionInteger( + $optionId, + 'configuration option ID' + )] = $value; + } + + return $normalized; + } + + private function positiveConfigurationInteger(mixed $value, string $field): int + { + try { + $value = $this->strictInteger($value); + } catch (\InvalidArgumentException) { + throw new InvalidStockConfigurationException( + "The {$field} configuration must be an integer." + ); + } + + if ($value <= 0) { + throw new InvalidStockConfigurationException( + "The {$field} configuration must be positive." + ); + } + + return $value; + } + + private function nonNegativeConfigurationInteger(mixed $value, string $field): int + { + try { + $value = $this->strictInteger($value); + } catch (\InvalidArgumentException) { + throw new InvalidStockConfigurationException( + "The {$field} configuration must be an integer." + ); + } + + if ($value < 0) { + throw new InvalidStockConfigurationException( + "The {$field} configuration cannot be negative." + ); + } + + return $value; + } + + private function selectionInteger(mixed $value, string $field): int + { + try { + return $this->strictInteger($value); + } catch (\InvalidArgumentException) { + throw new InvalidResourceSelectionException( + "The {$field} must be an integer." + ); + } + } + + private function strictInteger(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + if ( + is_string($value) + && preg_match('/^(0|[1-9]\d*)$/', $value) === 1 + ) { + $validated = filter_var($value, FILTER_VALIDATE_INT); + if ($validated !== false) { + return $validated; + } + } + + throw new \InvalidArgumentException('Expected an integer.'); + } + + private function normalizePanelUrl(string $url): string + { + if (trim($url) === '') { + return ''; + } + + try { + return PanelEndpointIdentity::canonicalUrl($url); + } catch (\InvalidArgumentException) { + return ''; + } + } + + private function inventory(): PterodactylInventoryService + { + return $this->inventory ??= app(PterodactylInventoryService::class); + } +} diff --git a/Services/PterodactylInventoryService.php b/Services/PterodactylInventoryService.php new file mode 100644 index 0000000..97c05be --- /dev/null +++ b/Services/PterodactylInventoryService.php @@ -0,0 +1,1073 @@ +>|null + */ + private ?array $nodeSnapshot = null; + + /** + * The optional configuration argument keeps the production constructor + * container-friendly while allowing the HTTP contract to be tested without + * persisting extension settings. + * + * @param array{ + * pterodactyl_url?: string, + * pterodactyl_api_key?: string, + * exclusive_provisioning_control?: mixed + * }|null $config + */ + public function __construct(?array $config = null) + { + $config ??= $this->extensionConfig(); + $this->apiUrl = $this->normalizePanelUrl((string) ($config['pterodactyl_url'] ?? '')); + $this->apiKey = trim((string) ($config['pterodactyl_api_key'] ?? '')); + $this->exclusiveProvisioningControl = filter_var( + $config['exclusive_provisioning_control'] ?? false, + FILTER_VALIDATE_BOOLEAN + ); + + if ( + $this->apiUrl !== '' + && filter_var($this->apiUrl, FILTER_VALIDATE_URL) === false + ) { + throw new \RuntimeException('The Pterodactyl inventory URL is invalid.'); + } + } + + public function panelIdentity(): string + { + $this->assertConfigured(); + + return PanelEndpointIdentity::hash($this->apiUrl); + } + + public function hasExclusiveProvisioningControl(): bool + { + return $this->exclusiveProvisioningControl; + } + + /** + * A seven-day local hold is a real guarantee only if every server create, + * move, resize, and allocation assignment on eligible nodes goes through + * this Paymenter deployment. This is an explicit operational contract; + * the Pterodactyl API cannot technically prevent a panel administrator. + */ + public function assertExclusiveProvisioningControl(): void + { + $this->assertConfigured(); + + if (! $this->exclusiveProvisioningControl) { + throw new \RuntimeException( + 'Dynamic stock requires administrator-confirmed exclusive provisioning control ' + .'over every eligible Pterodactyl node.' + ); + } + } + + /** + * @return list + */ + public function locations(): array + { + return array_map(function (array $resource): array { + $attributes = $this->attributes($resource, 'location'); + + return [ + 'id' => $this->positiveInteger($attributes['id'] ?? null, 'location.id'), + 'short' => $this->stringValue($attributes['short'] ?? null, 'location.short'), + 'long' => $this->stringValue($attributes['long'] ?? null, 'location.long', true), + ]; + }, $this->paginated('/api/application/locations')); + } + + /** + * Pterodactyl does not allow location_id as a NodeController filter. + * Read every page and apply the location constraint locally. + * + * @return list> + */ + public function nodesInLocation(int $locationId): array + { + if ($locationId <= 0) { + throw new \InvalidArgumentException('A positive Pterodactyl location ID is required.'); + } + + return array_values(array_filter( + $this->nodes(), + fn (array $node): bool => $node['location_id'] === $locationId + )); + } + + /** + * @return list> + */ + public function nodes(): array + { + if ($this->nodeSnapshot !== null) { + return $this->nodeSnapshot; + } + + $this->nodeSnapshot = array_map(function (array $resource): array { + $attributes = $this->attributes($resource, 'node'); + $allocated = $attributes['allocated_resources'] ?? null; + + if (! is_array($allocated)) { + throw new \RuntimeException( + 'Pterodactyl node inventory is missing allocated_resources. ' + .'Grant the application key node read permission and use Pterodactyl 1.12.3 or newer.' + ); + } + + return [ + 'id' => $this->positiveInteger($attributes['id'] ?? null, 'node.id'), + 'uuid' => $this->stringValue($attributes['uuid'] ?? null, 'node.uuid'), + 'name' => $this->stringValue($attributes['name'] ?? null, 'node.name'), + 'fqdn' => $this->stringValue($attributes['fqdn'] ?? null, 'node.fqdn'), + 'public' => $this->booleanValue($attributes['public'] ?? null, 'node.public'), + 'maintenance_mode' => $this->booleanValue( + $attributes['maintenance_mode'] ?? null, + 'node.maintenance_mode' + ), + 'location_id' => $this->positiveInteger( + $attributes['location_id'] ?? null, + 'node.location_id' + ), + 'memory' => $this->nonNegativeInteger($attributes['memory'] ?? null, 'node.memory'), + 'disk' => $this->nonNegativeInteger($attributes['disk'] ?? null, 'node.disk'), + 'memory_overallocate' => $this->integerValue( + $attributes['memory_overallocate'] ?? null, + 'node.memory_overallocate' + ), + 'disk_overallocate' => $this->integerValue( + $attributes['disk_overallocate'] ?? null, + 'node.disk_overallocate' + ), + 'allocated_resources' => [ + 'memory' => $this->nonNegativeInteger( + $allocated['memory'] ?? null, + 'node.allocated_resources.memory' + ), + 'disk' => $this->nonNegativeInteger( + $allocated['disk'] ?? null, + 'node.allocated_resources.disk' + ), + ], + 'available_allocations' => $this->availableNodeAllocations( + $resource + ), + ]; + }, $this->paginated( + '/api/application/nodes', + ['include' => 'allocations'] + )); + + return $this->nodeSnapshot; + } + + /** + * Server inventory is required for CPU accounting. A missing permission is + * an upstream error, never an implicit empty-server result. + * + * @param list $nodeIds + * @return array, + * allocation_headroom: int + * }>> + */ + public function serversForNodes(array $nodeIds): array + { + $nodeIds = array_values(array_unique(array_map('intval', $nodeIds))); + $grouped = array_fill_keys($nodeIds, []); + + if ($nodeIds === []) { + return []; + } + + foreach ($this->paginated( + '/api/application/servers', + ['include' => 'allocations'] + ) as $resource) { + $attributes = $this->attributes($resource, 'server'); + $nodeId = $this->positiveInteger($attributes['node'] ?? null, 'server.node'); + + if (! array_key_exists($nodeId, $grouped)) { + continue; + } + + $limits = $attributes['limits'] ?? null; + if (! is_array($limits)) { + throw new \RuntimeException('Pterodactyl server inventory is missing limits.'); + } + $featureLimits = $attributes['feature_limits'] ?? null; + if (! is_array($featureLimits)) { + throw new \RuntimeException( + 'Pterodactyl server inventory is missing feature_limits.' + ); + } + $allocationLimit = $this->nonNegativeInteger( + $featureLimits['allocations'] ?? null, + 'server.feature_limits.allocations' + ); + $allocationResources = $this->relationshipData( + $resource, + $attributes, + 'server', + 'allocations', + 'Grant the application key allocation read permission.' + ); + $assignedAllocationIds = array_map(function (array $allocation): int { + $allocationAttributes = $this->attributes($allocation, 'allocation'); + + return $this->positiveInteger( + $allocationAttributes['id'] ?? null, + 'server.allocations.id' + ); + }, $allocationResources); + if ( + $assignedAllocationIds === [] + || count(array_unique($assignedAllocationIds)) + !== count($assignedAllocationIds) + || ! in_array( + $this->positiveInteger( + $attributes['allocation'] ?? null, + 'server.allocation' + ), + $assignedAllocationIds, + true + ) + ) { + throw new \RuntimeException( + 'Pterodactyl server allocation inventory is incomplete or inconsistent.' + ); + } + $externalId = $attributes['external_id'] ?? null; + if ( + $externalId !== null + && ( + ! is_string($externalId) + || trim($externalId) === '' + ) + ) { + throw new \RuntimeException( + 'Pterodactyl field server.external_id must be null or a non-empty string.' + ); + } + + $grouped[$nodeId][] = [ + 'id' => $this->positiveInteger($attributes['id'] ?? null, 'server.id'), + 'uuid' => $this->stringValue( + $attributes['uuid'] ?? null, + 'server.uuid' + ), + 'identifier' => $this->stringValue( + $attributes['identifier'] ?? null, + 'server.identifier' + ), + 'external_id' => $externalId === null + ? null + : trim($externalId), + 'node' => $nodeId, + 'memory' => $this->nonNegativeInteger( + $limits['memory'] ?? null, + 'server.limits.memory' + ), + 'cpu' => $this->nonNegativeInteger($limits['cpu'] ?? null, 'server.limits.cpu'), + 'disk' => $this->nonNegativeInteger( + $limits['disk'] ?? null, + 'server.limits.disk' + ), + 'allocation_limit' => $allocationLimit, + 'assigned_allocation_ids' => $assignedAllocationIds, + 'allocation_headroom' => max( + 0, + $allocationLimit - count($assignedAllocationIds) + ), + ]; + } + + return $grouped; + } + + /** + * @return list + */ + public function availableAllocationsForNode(int $nodeId): array + { + if ($this->nodeSnapshot !== null) { + $node = collect($this->nodeSnapshot)->firstWhere('id', $nodeId); + if (! is_array($node)) { + throw new \RuntimeException( + "Pterodactyl node {$nodeId} is absent from the inventory snapshot." + ); + } + + return $node['available_allocations']; + } + + $parsed = []; + foreach ($this->paginated("/api/application/nodes/{$nodeId}/allocations") as $resource) { + $attributes = $this->attributes($resource, 'allocation'); + $assigned = $this->booleanValue($attributes['assigned'] ?? null, 'allocation.assigned'); + $port = $this->positiveInteger( + $attributes['port'] ?? null, + 'allocation.port' + ); + if ($port > 65535) { + throw new \RuntimeException( + 'Pterodactyl field allocation.port must not exceed 65535.' + ); + } + + $parsed[] = [ + 'id' => $this->positiveInteger($attributes['id'] ?? null, 'allocation.id'), + 'ip' => $this->stringValue($attributes['ip'] ?? null, 'allocation.ip'), + 'port' => $port, + 'assigned' => $assigned, + ]; + } + $allocations = $this->availableAllocations($parsed); + + usort($allocations, fn (array $left, array $right): int => $left['id'] <=> $right['id']); + + return $allocations; + } + + /** + * Parse the NodeTransformer allocations include. A missing/null + * relationship means the API key lacks allocation permission and must fail + * stock closed rather than trigger per-node fallback requests. + * + * @return list + */ + private function availableNodeAllocations(array $nodeResource): array + { + $attributes = $this->attributes($nodeResource, 'node'); + $resources = $this->relationshipData( + $nodeResource, + $attributes, + 'node', + 'allocations', + 'Grant the application key allocation read permission.' + ); + + $parsed = []; + foreach ($resources as $resource) { + $attributes = $this->attributes($resource, 'allocation'); + $assigned = $this->booleanValue( + $attributes['assigned'] ?? null, + 'allocation.assigned' + ); + + $port = $this->positiveInteger( + $attributes['port'] ?? null, + 'allocation.port' + ); + if ($port > 65535) { + throw new \RuntimeException( + 'Pterodactyl field allocation.port must not exceed 65535.' + ); + } + + $parsed[] = [ + 'id' => $this->positiveInteger( + $attributes['id'] ?? null, + 'allocation.id' + ), + 'ip' => $this->stringValue( + $attributes['ip'] ?? null, + 'allocation.ip' + ), + 'port' => $port, + 'assigned' => $assigned, + ]; + } + $allocations = $this->availableAllocations($parsed); + + usort( + $allocations, + fn (array $left, array $right): int => $left['id'] <=> $right['id'] + ); + + return $allocations; + } + + /** + * @param list $allocations + * @return list + */ + private function availableAllocations(array $allocations): array + { + $assignedIps = []; + foreach ($allocations as $allocation) { + if ($allocation['assigned']) { + $assignedIps[$this->canonicalIp($allocation['ip'])] = true; + } + } + + return array_values(array_map( + fn (array $allocation): array => [ + 'id' => $allocation['id'], + 'ip' => $allocation['ip'], + 'port' => $allocation['port'], + 'ip_in_use' => isset( + $assignedIps[$this->canonicalIp($allocation['ip'])] + ), + ], + array_filter( + $allocations, + fn (array $allocation): bool => ! $allocation['assigned'] + ) + )); + } + + private function canonicalIp(string $ip): string + { + $packed = @inet_pton(trim($ip)); + + return $packed === false + ? strtolower(trim($ip)) + : bin2hex($packed); + } + + /** + * Resolve the server identity used by Paymenter without scanning or + * trusting customer-provided node/resource data. + * + * @return array{ + * id: int, + * uuid: string, + * identifier: string, + * external_id: string, + * user_id: int, + * user_external_id: string, + * user_email: string, + * nest_id: int, + * egg_id: int, + * node: int, + * memory: int, + * cpu: int, + * disk: int, + * swap: int, + * io: int, + * threads: string|null, + * database_limit: int, + * allocation_limit: int, + * backup_limit: int, + * allocation: int, + * assigned_allocation_ids: list + * } + */ + public function serverByExternalId(int|string $externalId): array + { + $externalId = trim((string) $externalId); + if ($externalId === '') { + throw new \InvalidArgumentException('A Pterodactyl external server ID is required.'); + } + + $payload = $this->get( + '/api/application/servers/external/'.rawurlencode($externalId), + ['include' => 'allocations'] + ); + $attributes = $this->attributes($payload, 'server'); + $limits = $attributes['limits'] ?? null; + + if (! is_array($limits)) { + throw new \RuntimeException('Pterodactyl server inventory is missing limits.'); + } + $featureLimits = $attributes['feature_limits'] ?? null; + $threads = $limits['threads'] ?? null; + if ( + ! is_array($featureLimits) + || ($threads !== null && ! is_string($threads)) + ) { + throw new \RuntimeException( + 'Pterodactyl server inventory is missing build limits.' + ); + } + $primaryAllocation = $this->positiveInteger( + $attributes['allocation'] ?? null, + 'server.allocation' + ); + $allocationResources = $this->relationshipData( + $payload, + $attributes, + 'server', + 'allocations' + ); + $assignedAllocationIds = array_map(function (array $allocation): int { + $allocationAttributes = $this->attributes($allocation, 'allocation'); + + return $this->positiveInteger( + $allocationAttributes['id'] ?? null, + 'server.allocations.id' + ); + }, $allocationResources); + sort($assignedAllocationIds, SORT_NUMERIC); + if ( + $assignedAllocationIds === [] + || count(array_unique($assignedAllocationIds)) + !== count($assignedAllocationIds) + || ! in_array($primaryAllocation, $assignedAllocationIds, true) + ) { + throw new \RuntimeException( + 'Pterodactyl server allocation inventory is incomplete or inconsistent.' + ); + } + + $uuid = $this->stringValue( + $attributes['uuid'] ?? null, + 'server.uuid' + ); + if (! Str::isUuid($uuid)) { + throw new \RuntimeException( + 'Pterodactyl field server.uuid must be a valid UUID.' + ); + } + $serverExternalId = $this->stringValue( + $attributes['external_id'] ?? null, + 'server.external_id' + ); + $userId = $this->positiveInteger( + $attributes['user'] ?? null, + 'server.user' + ); + $userPayload = $this->get( + '/api/application/users/'.$userId, + [] + ); + $userAttributes = $this->attributes($userPayload, 'user'); + if ( + $this->positiveInteger( + $userAttributes['id'] ?? null, + 'user.id' + ) !== $userId + ) { + throw new \RuntimeException( + 'Pterodactyl returned a different server owner identity.' + ); + } + + return [ + 'id' => $this->positiveInteger($attributes['id'] ?? null, 'server.id'), + 'uuid' => $uuid, + 'identifier' => $this->stringValue( + $attributes['identifier'] ?? null, + 'server.identifier' + ), + 'external_id' => $serverExternalId, + 'user_id' => $userId, + 'user_external_id' => $this->stringValue( + $userAttributes['external_id'] ?? null, + 'user.external_id' + ), + 'user_email' => strtolower(trim($this->stringValue( + $userAttributes['email'] ?? null, + 'user.email' + ))), + 'nest_id' => $this->positiveInteger( + $attributes['nest'] ?? null, + 'server.nest' + ), + 'egg_id' => $this->positiveInteger( + $attributes['egg'] ?? null, + 'server.egg' + ), + 'node' => $this->positiveInteger($attributes['node'] ?? null, 'server.node'), + 'memory' => $this->nonNegativeInteger( + $limits['memory'] ?? null, + 'server.limits.memory' + ), + 'cpu' => $this->nonNegativeInteger($limits['cpu'] ?? null, 'server.limits.cpu'), + 'disk' => $this->nonNegativeInteger( + $limits['disk'] ?? null, + 'server.limits.disk' + ), + 'swap' => $this->nonNegativeInteger( + $limits['swap'] ?? null, + 'server.limits.swap' + ), + 'io' => $this->nonNegativeInteger( + $limits['io'] ?? null, + 'server.limits.io' + ), + 'threads' => $threads, + 'database_limit' => $this->nonNegativeInteger( + $featureLimits['databases'] ?? null, + 'server.feature_limits.databases' + ), + 'allocation_limit' => $this->nonNegativeInteger( + $featureLimits['allocations'] ?? null, + 'server.feature_limits.allocations' + ), + 'backup_limit' => $this->nonNegativeInteger( + $featureLimits['backups'] ?? null, + 'server.feature_limits.backups' + ), + 'allocation' => $primaryAllocation, + 'assigned_allocation_ids' => $assignedAllocationIds, + ]; + } + + public function testConnection(): array + { + try { + $nodes = $this->nodes(); + if ($nodes === []) { + throw new \RuntimeException( + 'Pterodactyl returned no nodes, so inventory permissions cannot be verified.' + ); + } + + $this->serversForNodes(array_column($nodes, 'id')); + $this->availableAllocationsForNode((int) $nodes[0]['id']); + + return [ + 'success' => true, + 'message' => 'Connection successful', + 'node_count' => count($nodes), + ]; + } catch (\Throwable $exception) { + return [ + 'success' => false, + 'message' => $exception->getMessage(), + ]; + } + } + + /** + * @return list> + */ + private function paginated(string $path, array $query = []): array + { + $page = 1; + $resources = []; + $resourceIdentities = []; + $paginationSnapshot = null; + + while (true) { + if ($page > self::MAX_PAGINATION_PAGES) { + throw new \RuntimeException( + 'Pterodactyl pagination exceeds the safe page limit.' + ); + } + + $payload = $this->get($path, [ + ...$query, + 'page' => $page, + 'per_page' => 100, + ]); + $data = $payload['data'] ?? null; + + if (! is_array($data) || ! array_is_list($data)) { + throw new \RuntimeException('Pterodactyl returned an invalid paginated resource payload.'); + } + + $pagination = $payload['meta']['pagination'] ?? null; + if (! is_array($pagination)) { + throw new \RuntimeException('Pterodactyl returned invalid pagination metadata.'); + } + + $currentPage = $this->positiveInteger( + $pagination['current_page'] ?? null, + 'pagination.current_page' + ); + $total = $this->nonNegativeInteger( + $pagination['total'] ?? null, + 'pagination.total' + ); + $perPage = $this->positiveInteger( + $pagination['per_page'] ?? null, + 'pagination.per_page' + ); + $totalPages = $this->paginationLastPage($pagination); + $dataCount = count($data); + + if (array_key_exists('count', $pagination)) { + $advertisedCount = $this->nonNegativeInteger( + $pagination['count'], + 'pagination.count' + ); + if ($advertisedCount !== $dataCount) { + throw new \RuntimeException( + 'Pterodactyl pagination count does not match the page payload.' + ); + } + } + + $calculatedPages = max( + 1, + intdiv($total, $perPage) + ($total % $perPage === 0 ? 0 : 1) + ); + if ($totalPages !== $calculatedPages) { + throw new \RuntimeException( + 'Pterodactyl pagination total is inconsistent with its page bounds.' + ); + } + if ( + $currentPage !== $page + || $currentPage > $totalPages + ) { + throw new \RuntimeException( + 'Pterodactyl pagination skipped, repeated, or returned an unexpected page.' + ); + } + if ($totalPages > self::MAX_PAGINATION_PAGES) { + throw new \RuntimeException( + 'Pterodactyl pagination exceeds the safe page limit.' + ); + } + + $pageSnapshot = [ + 'total' => $total, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + ]; + if ($paginationSnapshot === null) { + $paginationSnapshot = $pageSnapshot; + } elseif ($paginationSnapshot !== $pageSnapshot) { + throw new \RuntimeException( + 'Pterodactyl pagination metadata changed during the inventory read.' + ); + } + + $expectedCount = $currentPage < $totalPages + ? $perPage + : $total - (($totalPages - 1) * $perPage); + if ($dataCount !== $expectedCount) { + throw new \RuntimeException( + 'Pterodactyl pagination page size is inconsistent with its total.' + ); + } + + foreach ($data as $resource) { + $identity = $this->paginatedResourceIdentity($resource); + if (isset($resourceIdentities[$identity])) { + throw new \RuntimeException( + 'Pterodactyl pagination returned a duplicate resource.' + ); + } + + $resourceIdentities[$identity] = true; + $resources[] = $resource; + } + + if (count($resources) > $total) { + throw new \RuntimeException( + 'Pterodactyl pagination returned more resources than advertised.' + ); + } + + if ($currentPage === $totalPages) { + if (count($resources) !== $total) { + throw new \RuntimeException( + 'Pterodactyl pagination ended before the advertised total.' + ); + } + + break; + } + + $page++; + } + + return $resources; + } + + /** + * Pterodactyl's Fractal serializer calls the final page `total_pages`. + * Accept `last_page` as a compatible paginator alias, but never accept + * conflicting values when an intermediary supplies both. + * + * @param array $pagination + */ + private function paginationLastPage(array $pagination): int + { + $totalPages = array_key_exists('total_pages', $pagination) + ? $this->positiveInteger( + $pagination['total_pages'], + 'pagination.total_pages' + ) + : null; + $lastPage = array_key_exists('last_page', $pagination) + ? $this->positiveInteger( + $pagination['last_page'], + 'pagination.last_page' + ) + : null; + + if ($totalPages === null && $lastPage === null) { + throw new \RuntimeException( + 'Pterodactyl pagination is missing the final page.' + ); + } + if ( + $totalPages !== null + && $lastPage !== null + && $totalPages !== $lastPage + ) { + throw new \RuntimeException( + 'Pterodactyl pagination contains conflicting final pages.' + ); + } + + return $totalPages ?? $lastPage; + } + + private function paginatedResourceIdentity(mixed $resource): string + { + if (! is_array($resource)) { + throw new \RuntimeException( + 'Pterodactyl returned an invalid paginated resource.' + ); + } + + $object = $resource['object'] ?? null; + $attributes = $resource['attributes'] ?? null; + if ( + ! is_string($object) + || trim($object) === '' + || ! is_array($attributes) + ) { + throw new \RuntimeException( + 'Pterodactyl returned an invalid paginated resource identity.' + ); + } + + return trim($object).':'.$this->positiveInteger( + $attributes['id'] ?? null, + 'paginated_resource.id' + ); + } + + private function get(string $path, array $query): array + { + $this->assertConfigured(); + + try { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Accept' => 'application/json', + ]) + ->timeout(3) + ->connectTimeout(2) + ->retry( + 2, + 250, + fn ($exception): bool => $exception instanceof ConnectionException, + throw: false + ) + ->get($this->apiUrl.$path, $query); + } catch (ConnectionException $exception) { + \report($exception); + + throw new \RuntimeException('Pterodactyl API connection failed.', previous: $exception); + } + + if ($response->status() === 429) { + throw new \RuntimeException('Pterodactyl rate limit exceeded. Retry in a few seconds.'); + } + + if ($response->failed()) { + \report(new \RuntimeException(sprintf( + 'Pterodactyl inventory API error (%d) body: %s', + $response->status(), + $response->body() + ))); + + throw new \RuntimeException(sprintf( + 'Pterodactyl inventory API error (%d). Verify the application API key permissions.', + $response->status() + )); + } + + $payload = $response->json(); + if (! is_array($payload)) { + throw new \RuntimeException('Pterodactyl returned invalid JSON inventory data.'); + } + + return $payload; + } + + private function assertConfigured(): void + { + if ($this->apiUrl === '' || $this->apiKey === '') { + throw new \RuntimeException( + 'Pterodactyl inventory credentials are not configured.' + ); + } + } + + private function attributes(array $resource, string $expectedObject): array + { + $object = $resource['object'] ?? null; + $attributes = $resource['attributes'] ?? null; + + if ($object !== $expectedObject || ! is_array($attributes)) { + throw new \RuntimeException("Pterodactyl returned an invalid {$expectedObject} resource."); + } + + return $attributes; + } + + /** + * Pterodactyl's Fractal serializer merges included resources into the + * transformed data before wrapping it in `attributes`. Accept the legacy + * root-level shape only when the official nested relationship container is + * absent; never let a conflicting root value override stock API data. + * + * @return list> + */ + private function relationshipData( + array $resource, + array $attributes, + string $object, + string $relationship, + string $remediation = '' + ): array { + $relationships = array_key_exists('relationships', $attributes) + ? $attributes['relationships'] + : ($resource['relationships'] ?? null); + $relationshipResource = is_array($relationships) + ? ($relationships[$relationship] ?? null) + : null; + $data = is_array($relationshipResource) + ? ($relationshipResource['data'] ?? null) + : null; + + if (! is_array($data) || ! array_is_list($data)) { + $message = "Pterodactyl {$object} inventory is missing the " + ."{$relationship} relationship."; + if ($remediation !== '') { + $message .= ' '.$remediation; + } + + throw new \RuntimeException($message); + } + + return $data; + } + + private function positiveInteger(mixed $value, string $field): int + { + $value = $this->integerValue($value, $field); + + if ($value <= 0) { + throw new \RuntimeException("Pterodactyl field {$field} must be positive."); + } + + return $value; + } + + private function nonNegativeInteger(mixed $value, string $field): int + { + $value = $this->integerValue($value, $field); + + if ($value < 0) { + throw new \RuntimeException("Pterodactyl field {$field} must not be negative."); + } + + return $value; + } + + private function integerValue(mixed $value, string $field): int + { + if (! is_int($value) && ! ( + is_string($value) + && preg_match('/^-?(0|[1-9]\d*)$/', $value) === 1 + )) { + throw new \RuntimeException("Pterodactyl field {$field} must be an integer."); + } + + $validated = filter_var($value, FILTER_VALIDATE_INT); + if ($validated === false) { + throw new \RuntimeException( + "Pterodactyl field {$field} is outside the supported integer range." + ); + } + + return $validated; + } + + private function booleanValue(mixed $value, string $field): bool + { + if (! is_bool($value)) { + throw new \RuntimeException("Pterodactyl field {$field} must be a boolean."); + } + + return $value; + } + + private function stringValue(mixed $value, string $field, bool $allowEmpty = false): string + { + if (! is_string($value) || (! $allowEmpty && trim($value) === '')) { + throw new \RuntimeException("Pterodactyl field {$field} must be a string."); + } + + return $value; + } + + private function normalizePanelUrl(string $url): string + { + if (trim($url) === '') { + return ''; + } + + try { + return PanelEndpointIdentity::canonicalUrl($url); + } catch (\InvalidArgumentException $exception) { + throw new \RuntimeException( + 'The Pterodactyl inventory URL is invalid.', + previous: $exception + ); + } + } + + /** + * @return array + */ + private function extensionConfig(): array + { + return Extension::query() + ->where('extension', 'DynamicPterodactyl') + ->where('enabled', true) + ->first() + ?->settings + ->pluck('value', 'key') + ->toArray() ?? []; + } +} diff --git a/Services/QuoteRateLimitConfigurationService.php b/Services/QuoteRateLimitConfigurationService.php new file mode 100644 index 0000000..b7c1fb7 --- /dev/null +++ b/Services/QuoteRateLimitConfigurationService.php @@ -0,0 +1,102 @@ +|null $config + */ + public function __construct(private readonly ?array $config = null) {} + + /** + * @return array{per_ip: int, global: int, panel_identity: string} + */ + public function configuration(): array + { + $config = $this->config ?? $this->extensionConfig(); + $panelUrl = trim((string) ($config['pterodactyl_url'] ?? '')); + + try { + $panelIdentity = PanelEndpointIdentity::hash($panelUrl); + } catch (\InvalidArgumentException $exception) { + throw new InvalidStockConfigurationException( + 'Dynamic quote rate limiting requires a valid Pterodactyl panel URL.', + previous: $exception + ); + } + + return [ + 'per_ip' => $this->boundedInteger( + $config['quote_rate_limit_per_ip'] ?? null, + self::DEFAULT_PER_IP, + self::MAX_PER_IP, + 'per-IP quote rate limit' + ), + 'global' => $this->boundedInteger( + $config['quote_rate_limit_global'] ?? null, + self::DEFAULT_GLOBAL, + self::MAX_GLOBAL, + 'global quote rate limit' + ), + 'panel_identity' => $panelIdentity, + ]; + } + + private function boundedInteger( + mixed $value, + int $default, + int $maximum, + string $label + ): int { + if ($value === null || $value === '') { + return $default; + } + if ( + ! is_int($value) + && ! ( + is_string($value) + && preg_match('/^(0|[1-9]\d*)$/D', $value) === 1 + ) + ) { + throw new InvalidStockConfigurationException( + "The {$label} must be a whole number." + ); + } + + $validated = filter_var($value, FILTER_VALIDATE_INT); + if ($validated === false || $validated < 1 || $validated > $maximum) { + throw new InvalidStockConfigurationException( + "The {$label} must be between 1 and {$maximum} requests per minute." + ); + } + + return $validated; + } + + /** + * @return array + */ + private function extensionConfig(): array + { + return Extension::query() + ->where('extension', 'DynamicPterodactyl') + ->where('enabled', true) + ->first() + ?->settings + ->pluck('value', 'key') + ->toArray() ?? []; + } +} diff --git a/Services/QuoteRateLimiterService.php b/Services/QuoteRateLimiterService.php new file mode 100644 index 0000000..31b2f37 --- /dev/null +++ b/Services/QuoteRateLimiterService.php @@ -0,0 +1,37 @@ +configuration->configuration(); + $panelIdentity = $configuration['panel_identity']; + + return [ + Limit::perMinute($configuration['per_ip'])->by( + 'dynamic-pterodactyl:quotes:ip:' + .$panelIdentity.':'.$request->ip() + ), + Limit::perMinute($configuration['global'])->by( + 'dynamic-pterodactyl:quotes:panel:'.$panelIdentity + ), + ]; + }); + } +} diff --git a/Services/ReservationConfigurationService.php b/Services/ReservationConfigurationService.php new file mode 100644 index 0000000..fd57a09 --- /dev/null +++ b/Services/ReservationConfigurationService.php @@ -0,0 +1,823 @@ +>, + * allocation_requirements: array{ + * required_count: int, + * mappings: array>, + * allowed_port_ranges: array, + * dedicated_ip: bool + * } + * } + */ + public function forCartItem(CartItem $cartItem): array + { + $cartItem->loadMissing([ + 'cart', + 'product.configOptions.children', + 'product.server.settings', + 'plan', + ]); + + if (! $this->requiresReservation($cartItem->product_id)) { + throw new \LogicException('The cart item does not use dynamic resource sliders.'); + } + + if ((int) $cartItem->quantity !== 1) { + throw new DisplayException('Dynamic resource products currently require a quantity of one.'); + } + + try { + $stockConfiguration = app(ProductResourceConfigurationService::class) + ->forQuote($cartItem->product, (array) ($cartItem->config_options ?? [])); + } catch (InvalidResourceSelectionException|InvalidStockConfigurationException $exception) { + throw new DisplayException($exception->getMessage(), previous: $exception); + } + $this->assertExplicitResourceSelections( + (array) ($cartItem->config_options ?? []), + (array) ($stockConfiguration['sliders'] ?? []) + ); + + $selectedOptions = collect($cartItem->config_options ?? []) + ->keyBy(fn ($option) => (int) data_get($option, 'option_id')); + + $resources = $stockConfiguration['resources']; + $locationId = (int) $stockConfiguration['location_id']; + $configurationOptions = []; + + foreach ($cartItem->product->configOptions as $option) { + $selection = $selectedOptions->get((int) $option->id); + $value = data_get($selection, 'value'); + $resourceType = strtolower((string) $option->getMetadata('resource_type', '')); + $environmentKey = strtolower((string) ($option->env_variable ?: $option->name)); + + if ($option->type === 'dynamic_slider' && in_array($resourceType, ['memory', 'cpu', 'disk'], true)) { + $value = $resources[$resourceType]; + } + + if ($environmentKey === 'location' || strtolower($option->name) === 'location') { + $value = (int) $value; + } + + $configurationOptions[] = [ + 'id' => (int) $option->id, + 'type' => (string) $option->type, + 'environment_key' => $environmentKey, + 'resource_type' => $resourceType ?: null, + 'value' => is_numeric($value) ? (float) $value : $value, + 'metadata' => $this->canonicalize((array) ($option->metadata ?? [])), + ]; + } + + usort($configurationOptions, fn (array $left, array $right) => $left['id'] <=> $right['id']); + + $calculatedPrice = $this->canonicalMoney( + $cartItem->price->total, + (int) $cartItem->quantity + ); + $currencyCode = strtoupper((string) $cartItem->cart->currency_code); + $panel = $this->checkoutPanelIdentity($cartItem); + $allocationRequirements = [ + 'required_count' => (int) $stockConfiguration['allocation_count'], + 'mappings' => (array) $stockConfiguration['allocation_mappings'], + 'allowed_port_ranges' => (array) ( + $stockConfiguration['allowed_port_ranges'] ?? [] + ), + 'dedicated_ip' => (bool) ( + $stockConfiguration['dedicated_ip'] ?? false + ), + ]; + $pricingIdentity = [ + 'product_id' => (int) $cartItem->product_id, + 'plan_id' => (int) $cartItem->plan_id, + 'currency_code' => $currencyCode, + 'calculated_price' => $calculatedPrice, + 'config_options' => $configurationOptions, + ]; + $customerId = $cartItem->cart->user_id !== null + ? (int) $cartItem->cart->user_id + : (auth()->id() !== null ? (int) auth()->id() : null); + $provisioningIdentity = $this->provisioningIdentity( + $cartItem->product, + $customerId + ); + + return [ + 'customer_id' => $customerId, + 'cart_id' => (int) $cartItem->cart_id, + 'server_extension_id' => $panel['server_extension_id'], + 'panel_identity' => $panel['panel_identity'], + 'product_id' => (int) $cartItem->product_id, + 'plan_id' => (int) $cartItem->plan_id, + 'quantity' => (int) $cartItem->quantity, + 'currency_code' => $currencyCode, + 'location_id' => $locationId, + 'resources' => $resources, + 'calculated_price' => $calculatedPrice, + 'pricing_version' => hash('sha256', $this->canonicalJson($pricingIdentity)), + 'formula_version' => self::FORMULA_VERSION, + 'config_options' => $configurationOptions, + 'allocation_requirements' => $allocationRequirements, + 'provisioning_identity' => $provisioningIdentity, + ]; + } + + /** + * Quotes may use configured defaults for their first render, but a stored + * cart item is a billing input and must explicitly carry every active + * resource slider. Otherwise a slider attached after the checkout page was + * opened could default into the reservation while CartItem pricing omits + * its marginal charge. + * + * @param array $submittedOptions + * @param array> $sliders + */ + private function assertExplicitResourceSelections( + array $submittedOptions, + array $sliders + ): void { + $submittedIds = []; + if (array_is_list($submittedOptions)) { + foreach ($submittedOptions as $selection) { + $optionId = StrictInteger::parse( + data_get($selection, 'option_id') + ); + if ($optionId !== null) { + $submittedIds[$optionId] = true; + } + } + } else { + foreach (array_keys($submittedOptions) as $optionId) { + $optionId = StrictInteger::parse($optionId); + if ($optionId !== null) { + $submittedIds[$optionId] = true; + } + } + } + + foreach ($sliders as $slider) { + $optionId = StrictInteger::parse( + $slider['config_option_id'] ?? null + ); + if ($optionId !== null && isset($submittedIds[$optionId])) { + continue; + } + + throw new DisplayException( + 'The product resource options changed before reservation. ' + .'Reload checkout and explicitly select every resource again.' + ); + } + } + + public function requiresReservation(int $productId): bool + { + $usesPterodactyl = Product::query() + ->whereKey($productId) + ->whereHas('server', fn ($query) => $query->where('extension', 'Pterodactyl')) + ->exists(); + + if (! $usesPterodactyl) { + return false; + } + + return ConfigOption::query() + ->whereHas('products', fn ($query) => $query->whereKey($productId)) + ->where('type', 'dynamic_slider') + ->where('hidden', false) + ->whereNull('parent_id') + ->get() + ->contains(fn (ConfigOption $option) => in_array( + strtolower((string) $option->getMetadata('resource_type', '')), + ['memory', 'cpu', 'disk'], + true + )); + } + + /** + * A seven-day capacity claim is truthful only when every server create, + * move, resize, and allocation assignment on eligible nodes participates + * in this protocol. + */ + public function assertExclusiveProvisioningControl(): void + { + try { + app(PterodactylInventoryService::class) + ->assertExclusiveProvisioningControl(); + } catch (\RuntimeException $exception) { + throw new DisplayException( + 'Dynamic ordering is disabled until an administrator confirms that this Paymenter instance exclusively controls all provisioning on eligible Pterodactyl nodes.', + previous: $exception + ); + } + } + + /** + * @param array $snapshot + * @return array + */ + public function withNode(array $snapshot, int $nodeId): array + { + return $this->canonicalize([ + 'customer_id' => $snapshot['customer_id'], + 'cart_id' => $snapshot['cart_id'], + 'server_extension_id' => $snapshot['server_extension_id'], + 'panel_identity' => $snapshot['panel_identity'], + 'product_id' => $snapshot['product_id'], + 'plan_id' => $snapshot['plan_id'], + 'quantity' => $snapshot['quantity'], + 'currency_code' => $snapshot['currency_code'], + 'location_id' => $snapshot['location_id'], + 'node_id' => $nodeId, + 'resources' => $snapshot['resources'], + 'calculated_price' => $snapshot['calculated_price'], + 'pricing_version' => $snapshot['pricing_version'], + 'formula_version' => $snapshot['formula_version'], + 'config_options' => $snapshot['config_options'], + 'allocation_requirements' => $snapshot['allocation_requirements'], + 'provisioning_identity' => $snapshot['provisioning_identity'], + ]); + } + + /** + * Add the exact Pterodactyl placement selected under the capacity lock. + * + * @param array $snapshot + * @param array> $allocations + * @return array + */ + public function withPlacement(array $snapshot, int $nodeId, array $allocations): array + { + $payload = $this->withNode($snapshot, $nodeId); + $payload['allocations'] = array_values(array_map( + fn (array $allocation) => [ + 'allocation_id' => (int) ($allocation['allocation_id'] ?? $allocation['id'] ?? 0), + 'ip' => (string) ($allocation['ip'] ?? ''), + 'port' => (int) ($allocation['port'] ?? 0), + 'environment_key' => $allocation['environment_key'] ?? null, + 'is_primary' => (bool) ($allocation['is_primary'] ?? false), + ], + $allocations + )); + + usort( + $payload['allocations'], + fn (array $left, array $right) => $left['allocation_id'] <=> $right['allocation_id'] + ); + + return $this->canonicalize($payload); + } + + /** + * Transfer a guest payload to its authenticated customer without changing + * any pricing, placement, or resource input. + * + * @param array $payload + * @return array + */ + public function withCustomer( + array $payload, + int $customerId, + string $customerEmail + ): array { + $payload['customer_id'] = $customerId; + $payload['provisioning_identity']['user_external_id'] + = $this->pterodactylUserExternalId($customerId); + $payload['provisioning_identity']['user_email'] + = $this->normalizeCustomerEmail($customerEmail); + + return $this->canonicalize($payload); + } + + /** + * @param array $payload + */ + public function fingerprint(array $payload): string + { + return hash('sha256', $this->canonicalJson($payload)); + } + + /** + * Prove that the allocation claim rows are the exact materialization of a + * signed checkout snapshot. + * + * Pending and paid commitments must still own active claims. Confirmed + * commitments must retain the same historical rows after their claims are + * released, so stock accounting can bridge stale Pterodactyl inventory + * without trusting a detached or rewritten allocation row. + * + * @param iterable $claims + * @return array + */ + public function verifiedAllocationSnapshot( + object $reservation, + iterable $claims + ): array { + $payload = $reservation->configuration_payload; + if (is_string($payload)) { + try { + $payload = json_decode( + $payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (\JsonException $exception) { + throw new InvalidStockConfigurationException( + 'The checkout allocation snapshot is unreadable.', + previous: $exception + ); + } + } + + if ( + ($reservation->purpose ?? null) !== 'checkout' + || ! is_array($payload) + || ! is_string($reservation->configuration_fingerprint) + || ! hash_equals( + $reservation->configuration_fingerprint, + $this->fingerprint($payload) + ) + || (string) ($payload['panel_identity'] ?? '') + !== (string) $reservation->panel_identity + || StrictInteger::parse($payload['node_id'] ?? null) === null + || (int) $payload['node_id'] !== (int) $reservation->node_id + || StrictInteger::parse( + $payload['location_id'] ?? null + ) === null + || (int) $payload['location_id'] + !== (int) $reservation->location_id + || StrictInteger::parse( + data_get($payload, 'resources.memory') + ) === null + || (int) data_get($payload, 'resources.memory') + !== (int) $reservation->memory + || StrictInteger::parse( + data_get($payload, 'resources.cpu') + ) === null + || (int) data_get($payload, 'resources.cpu') + !== (int) $reservation->cpu + || StrictInteger::parse( + data_get($payload, 'resources.disk') + ) === null + || (int) data_get($payload, 'resources.disk') + !== (int) $reservation->disk + ) { + throw new InvalidStockConfigurationException( + 'The checkout allocation snapshot failed its immutable capacity integrity check.' + ); + } + + $rawExpected = $payload['allocations'] ?? null; + $requiredCount = StrictInteger::parse( + data_get($payload, 'allocation_requirements.required_count') + ); + if ( + ! is_array($rawExpected) + || $rawExpected === [] + || $requiredCount === null + || $requiredCount <= 0 + || count($rawExpected) !== $requiredCount + ) { + throw new InvalidStockConfigurationException( + 'The checkout allocation snapshot has no valid signed allocation set.' + ); + } + + $expected = []; + foreach ($rawExpected as $allocation) { + if (! is_array($allocation)) { + throw new InvalidStockConfigurationException( + 'The checkout allocation snapshot has an invalid signed allocation.' + ); + } + + $allocationId = StrictInteger::parse( + $allocation['allocation_id'] ?? null + ); + $port = StrictInteger::parse($allocation['port'] ?? null); + $ip = $allocation['ip'] ?? null; + $environmentKey = $allocation['environment_key'] ?? null; + $isPrimary = $allocation['is_primary'] ?? null; + if ( + $allocationId === null + || $allocationId <= 0 + || $port === null + || $port <= 0 + || $port > 65535 + || ! is_string($ip) + || trim($ip) === '' + || ( + $environmentKey !== null + && ! is_string($environmentKey) + ) + || ! is_bool($isPrimary) + ) { + throw new InvalidStockConfigurationException( + 'The checkout allocation snapshot has an invalid signed allocation.' + ); + } + + $expected[] = [ + 'panel_identity' => (string) $reservation->panel_identity, + 'node_id' => (int) $reservation->node_id, + 'allocation_id' => $allocationId, + 'ip' => $ip, + 'port' => $port, + 'environment_key' => $environmentKey, + 'is_primary' => $isPrimary, + ]; + } + usort( + $expected, + fn (array $left, array $right): int => $left['allocation_id'] <=> $right['allocation_id'] + ); + + $claimRows = collect($claims)->values(); + $actual = $claimRows + ->map(fn (object $allocation): array => [ + 'panel_identity' => (string) $allocation->panel_identity, + 'node_id' => (int) $allocation->node_id, + 'allocation_id' => (int) $allocation->allocation_id, + 'ip' => (string) ($allocation->ip ?? ''), + 'port' => (int) $allocation->port, + 'environment_key' => $allocation->environment_key, + 'is_primary' => (bool) $allocation->is_primary, + ]) + ->sortBy('allocation_id') + ->values() + ->all(); + $allocationIds = array_column($actual, 'allocation_id'); + $status = (string) ($reservation->status ?? ''); + $releaseStateIsValid = match ($status) { + 'pending', 'paid_committed' => ! $claimRows->contains( + fn (object $allocation): bool => $allocation->released_at !== null + ), + 'confirmed' => ! $claimRows->contains( + fn (object $allocation): bool => $allocation->released_at === null + ), + default => false, + }; + + if ( + $expected !== $actual + || count(array_unique($allocationIds)) !== count($allocationIds) + || collect($actual)->where('is_primary', true)->count() !== 1 + || ! $releaseStateIsValid + ) { + throw new InvalidStockConfigurationException( + 'Allocation claims no longer match the immutable checkout reservation.' + ); + } + + return $payload; + } + + private function canonicalMoney(mixed $unitPrice, int $quantity): string + { + if ( + $quantity !== 1 + || ! is_string($unitPrice) + || preg_match('/^(0|[1-9]\d*)\.(\d{2})$/D', $unitPrice) !== 1 + ) { + throw new DisplayException( + 'The dynamic product price is outside the supported invoice format.' + ); + } + + $wholeDigits = strlen(strstr($unitPrice, '.', true)); + if ($wholeDigits > 15) { + throw new DisplayException( + 'The dynamic product price exceeds the supported invoice range.' + ); + } + + return $unitPrice; + } + + /** + * Prove that the service still owns the immutable checkout payload. + * + * Product defaults, config-option metadata, and server settings are + * intentionally not recomputed here: administrators may edit them while a + * seven-day quote is open. The signed reservation remains authoritative + * and the provisioner overrides placement and resources from that snapshot. + */ + public function assertServiceMatches(Service $service, object $reservation): void + { + try { + $payload = json_decode( + (string) $reservation->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (\JsonException $exception) { + throw new \RuntimeException( + 'The capacity reservation snapshot is unreadable.', + previous: $exception + ); + } + if ( + ! is_array($payload) + || ! hash_equals( + (string) $reservation->configuration_fingerprint, + $this->fingerprint($payload) + ) + ) { + throw new \RuntimeException( + 'The capacity reservation snapshot failed its integrity check.' + ); + } + + $reservationIdentity = [ + 'customer_id' => (int) $reservation->user_id, + 'server_extension_id' => (int) $reservation->server_extension_id, + 'panel_identity' => (string) $reservation->panel_identity, + 'product_id' => (int) $reservation->product_id, + 'plan_id' => (int) $reservation->plan_id, + 'quantity' => (int) $reservation->quantity, + 'currency_code' => strtoupper((string) $reservation->currency_code), + 'user_id' => (int) $reservation->user_id, + 'memory' => (int) $reservation->memory, + 'cpu' => (int) $reservation->cpu, + 'disk' => (int) $reservation->disk, + 'location' => (int) $reservation->location_id, + 'node_id' => (int) $reservation->node_id, + ]; + $payloadIdentity = [ + 'customer_id' => StrictInteger::parse( + $payload['customer_id'] ?? null + ), + 'server_extension_id' => StrictInteger::parse( + $payload['server_extension_id'] ?? null + ), + 'panel_identity' => (string) ($payload['panel_identity'] ?? ''), + 'product_id' => StrictInteger::parse( + $payload['product_id'] ?? null + ), + 'plan_id' => StrictInteger::parse( + $payload['plan_id'] ?? null + ), + 'quantity' => StrictInteger::parse( + $payload['quantity'] ?? null + ), + 'currency_code' => strtoupper((string) ($payload['currency_code'] ?? '')), + 'user_id' => StrictInteger::parse( + $payload['customer_id'] ?? null + ), + 'memory' => StrictInteger::parse( + data_get($payload, 'resources.memory') + ), + 'cpu' => StrictInteger::parse( + data_get($payload, 'resources.cpu') + ), + 'disk' => StrictInteger::parse( + data_get($payload, 'resources.disk') + ), + 'location' => StrictInteger::parse( + $payload['location_id'] ?? null + ), + 'node_id' => StrictInteger::parse( + $payload['node_id'] ?? null + ), + ]; + $serviceIdentity = [ + 'service_id' => (int) $service->id, + 'product_id' => (int) $service->product_id, + 'plan_id' => (int) $service->plan_id, + 'quantity' => (int) $service->quantity, + 'currency_code' => strtoupper((string) $service->currency_code), + 'user_id' => (int) $service->user_id, + ]; + $expectedServiceIdentity = [ + 'service_id' => (int) $reservation->service_id, + 'product_id' => (int) $reservation->product_id, + 'plan_id' => (int) $reservation->plan_id, + 'quantity' => (int) $reservation->quantity, + 'currency_code' => strtoupper((string) $reservation->currency_code), + 'user_id' => (int) $reservation->user_id, + ]; + + if ( + $reservationIdentity !== $payloadIdentity + || $serviceIdentity !== $expectedServiceIdentity + || (int) $reservation->quantity !== 1 + || (int) $service->quantity !== 1 + ) { + throw new \RuntimeException( + 'The service identity does not match its immutable capacity reservation.' + ); + } + + $provisioningIdentity = (array) ( + $payload['provisioning_identity'] ?? [] + ); + if ( + StrictInteger::parse($provisioningIdentity['nest_id'] ?? null) === null + || (int) $provisioningIdentity['nest_id'] <= 0 + || StrictInteger::parse($provisioningIdentity['egg_id'] ?? null) === null + || (int) $provisioningIdentity['egg_id'] <= 0 + || ! hash_equals( + $this->pterodactylUserExternalId((int) $service->user_id), + (string) ($provisioningIdentity['user_external_id'] ?? '') + ) + || strtolower(trim((string) ( + $provisioningIdentity['user_email'] ?? '' + ))) === '' + ) { + throw new \RuntimeException( + 'The provisioning identity does not match its immutable capacity reservation.' + ); + } + } + + /** + * @return array{ + * nest_id: int, + * egg_id: int, + * user_external_id: string|null, + * user_email: string|null + * } + */ + private function provisioningIdentity( + Product $product, + ?int $customerId + ): array { + $settings = ExtensionHelper::settingsToArray($product->settings); + $nestId = StrictInteger::parse($settings['nest_id'] ?? null); + $eggId = StrictInteger::parse($settings['egg_id'] ?? null); + if ( + $nestId === null + || $nestId <= 0 + || $eggId === null + || $eggId <= 0 + ) { + throw new DisplayException( + 'The dynamic product must have one valid Pterodactyl nest and egg.' + ); + } + + return [ + 'nest_id' => $nestId, + 'egg_id' => $eggId, + 'user_external_id' => $customerId === null + ? null + : $this->pterodactylUserExternalId($customerId), + 'user_email' => $customerId === null + ? null + : $this->paymenterUserEmail($customerId), + ]; + } + + private function pterodactylUserExternalId(int $customerId): string + { + if ($customerId <= 0) { + throw new \InvalidArgumentException( + 'A valid Paymenter customer is required for provisioning.' + ); + } + + return "paymenter-user-{$customerId}"; + } + + private function paymenterUserEmail(int $customerId): string + { + return $this->normalizeCustomerEmail((string) User::query() + ->whereKey($customerId) + ->value('email')); + } + + private function normalizeCustomerEmail(string $email): string + { + $email = strtolower(trim($email)); + if ($email === '') { + throw new \InvalidArgumentException( + 'A valid Paymenter customer email is required for provisioning.' + ); + } + + return $email; + } + + /** + * @return array{server_extension_id: int, panel_identity: string} + */ + private function checkoutPanelIdentity(CartItem $cartItem): array + { + $server = $cartItem->product->server; + $serverSettings = $server?->settings?->pluck('value', 'key') ?? collect(); + $capacityExtension = Extension::query() + ->where('extension', 'DynamicPterodactyl') + ->where('enabled', true) + ->first(); + $capacitySettings = $capacityExtension?->settings() + ->pluck('value', 'key') ?? collect(); + + $provisioningUrl = $this->normalizePanelUrl((string) $serverSettings->get('host')); + $capacityUrl = $this->normalizePanelUrl((string) $capacitySettings->get('pterodactyl_url')); + + if ( + $server === null + || $server->extension !== 'Pterodactyl' + || $provisioningUrl === '' + || $capacityUrl === '' + || ! hash_equals($provisioningUrl, $capacityUrl) + ) { + throw new DisplayException( + 'The capacity service and this product must use the same Pterodactyl panel.' + ); + } + + return [ + 'server_extension_id' => (int) $server->id, + 'panel_identity' => hash('sha256', $provisioningUrl), + ]; + } + + private function normalizePanelUrl(string $url): string + { + if (trim($url) === '') { + return ''; + } + + try { + return PanelEndpointIdentity::canonicalUrl($url); + } catch (\InvalidArgumentException) { + return ''; + } + } + + /** + * @return array + */ + private function canonicalize(array $value): array + { + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = $this->canonicalize($item); + } elseif ( + is_float($item) + && is_finite($item) + && floor($item) === $item + && $item >= PHP_INT_MIN + && $item <= PHP_INT_MAX + ) { + // JSON columns may normalize 4096.0 to 4096. Treat integral + // numeric values identically before both persistence and + // fingerprinting so a database round trip cannot invalidate + // an otherwise immutable reservation. + $value[$key] = (int) $item; + } + } + + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } + + private function canonicalJson(array $value): string + { + return json_encode( + $this->canonicalize($value), + JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_SLASHES + ); + } +} diff --git a/Services/ReservationService.php b/Services/ReservationService.php index 451aa24..44072af 100644 --- a/Services/ReservationService.php +++ b/Services/ReservationService.php @@ -2,14 +2,28 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Services; +use App\Exceptions\DisplayException; +use App\Exceptions\PermanentProvisioningException; +use App\Models\CartItem; use App\Models\Extension; +use App\Models\Invoice; +use App\Models\Service; use App\Models\User; +use App\Services\Invoice\CancelInvoiceService; +use App\Services\Invoice\CapacityInvoicePaymentService; +use App\Services\Service\CapacityConfigurationLockService; +use App\Services\Service\FulfillmentStatusTransitionService; +use App\Services\Service\ProductStockService; +use App\Services\Service\ServiceJobDispatchService; +use App\Support\StrictInteger; use Carbon\Carbon; -use Illuminate\Database\QueryException; +use Carbon\CarbonInterface; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use Paymenter\Extensions\Others\DynamicPterodactyl\Exceptions\InvalidStockConfigurationException; +use Paymenter\Extensions\Others\DynamicPterodactyl\Models\ReservationAllocation; use Paymenter\Extensions\Others\DynamicPterodactyl\Models\ResourceReservation; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\Concerns\AuditsExtensionActions; @@ -17,16 +31,27 @@ class ReservationService { use AuditsExtensionActions; + private const MAX_ADMIN_EXTENSION_MINUTES = 60; + + private const PROVISIONING_LEASE_MINUTES = 5; + + private const RETRY_DELAYS_SECONDS = [15, 60, 300, 900, 1800, 3600, 10800]; + private NodeSelectionService $nodeService; + private ReservationConfigurationService $configurationService; + private int $ttlMinutes; public function __construct( - NodeSelectionService $nodeService + NodeSelectionService $nodeService, + ReservationConfigurationService $configurationService ) { $this->nodeService = $nodeService; + $this->configurationService = $configurationService; $config = Extension::where('extension', 'DynamicPterodactyl') + ->where('enabled', true) ->first() ?->settings ->pluck('value', 'key') @@ -35,254 +60,1613 @@ public function __construct( } /** - * Create a resource reservation + * Create or refresh the one server-owned hold for a cart item. + * + * Browser-provided tokens and idempotency keys are deliberately not accepted. * - * Uses database transaction with pessimistic locking - * Retries up to 5 times on deadlock + * @return array{id: int, node_id: int, expires_at: string, status: string} */ - public function create( - int $productId, - int $locationId, - array $resources, - ?int $cartItemId = null, - ?int $userId = null, - ?string $idempotencyKey = null - ): array { - try { - return DB::transaction(function () use ($productId, $locationId, $resources, $cartItemId, $userId, $idempotencyKey) { - // Lock pending reservations for this location + public function reserveForCartItem(CartItem $cartItem): array + { + return DB::transaction(function () use ($cartItem) { + // Product is the shared first lock for configuration readers and + // writers. Build the snapshot only after the complete + // Product→option→plan→price lock set is held, then retain those + // locks until the capacity hold has been inserted. + $product = app(CapacityConfigurationLockService::class) + ->lockProduct((int) $cartItem->product_id); + $cartItem = CartItem::query() + ->whereKey($cartItem->id) + ->lockForUpdate() + ->firstOrFail(); + if ((int) $cartItem->product_id !== (int) $product->id) { + throw new \RuntimeException( + 'The cart product changed while its capacity configuration was being locked.' + ); + } + $plan = $product->plans->firstWhere( + 'id', + (int) $cartItem->plan_id + ); + if ($plan === null) { + throw new DisplayException( + 'The selected product plan is no longer available.' + ); + } + $cartItem->setRelation('product', $product); + $cartItem->setRelation('plan', $plan); + $cartItem->loadMissing('cart'); + + $snapshot = $this->configurationService + ->forCartItem($cartItem); + $ownerId = $snapshot['customer_id']; + $previousScope = DB::table('ptero_resource_reservations') + ->where('cart_item_id', $cartItem->id) + ->where('status', ResourceReservation::STATUS_PENDING) + ->first(['panel_identity', 'location_id']); + $scopes = collect([ + [ + 'panel_identity' => (string) $snapshot['panel_identity'], + 'location_id' => (int) $snapshot['location_id'], + ], + $previousScope !== null && $previousScope->panel_identity !== null + ? [ + 'panel_identity' => (string) $previousScope->panel_identity, + 'location_id' => (int) $previousScope->location_id, + ] + : null, + ]) + ->filter() + ->unique(fn (array $scope) => $this->capacityScopeKey($scope)) + ->sortBy(fn (array $scope) => $this->capacityScopeKey($scope)) + ->values(); + + foreach ($scopes as $scope) { + DB::table('ptero_capacity_scopes')->insertOrIgnore([ + ...$scope, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + foreach ($scopes as $scope) { + DB::table('ptero_capacity_scopes') + ->where('panel_identity', $scope['panel_identity']) + ->where('location_id', $scope['location_id']) + ->lockForUpdate() + ->first(); + } + foreach ($scopes as $scope) { DB::table('ptero_resource_reservations') - ->where('location_id', $locationId) - ->where('status', 'pending') + ->where('panel_identity', $scope['panel_identity']) + ->where('location_id', $scope['location_id']) + ->where(function (Builder $query) { + $this->applyCapacityHoldingScope($query); + }) ->lockForUpdate() ->get(); + } - if ($idempotencyKey !== null) { - $this->expireStaleIdempotencyReservations($userId, $idempotencyKey); + $existing = DB::table('ptero_resource_reservations') + ->where('cart_item_id', $cartItem->id) + ->where('status', 'pending') + ->lockForUpdate() + ->first(); + if ( + $existing !== null + && ! $scopes->contains( + fn (array $scope) => $this->capacityScopeKey($scope) + === $this->capacityScopeKey([ + 'panel_identity' => (string) $existing->panel_identity, + 'location_id' => (int) $existing->location_id, + ]) + ) + ) { + throw new \RuntimeException( + 'The capacity scope changed concurrently; retry the cart update.' + ); + } - $existingReservation = $this->getActiveByIdempotencyKey($userId, $idempotencyKey); - if ($existingReservation) { - Log::info('Returning existing reservation for idempotent create request', [ - 'reservation_id' => $existingReservation->id, - 'user_id' => $userId, - 'idempotency_key' => $idempotencyKey, - ]); + if ($existing !== null && $existing->user_id !== null && (int) $existing->user_id !== (int) $ownerId) { + throw new DisplayException('This cart capacity hold belongs to a different customer.'); + } - return $this->presentReservation($existingReservation); - } + if ($existing !== null && Carbon::parse($existing->expires_at)->isPast()) { + DB::table('ptero_resource_reservations') + ->where('id', $existing->id) + ->update([ + 'status' => 'expired', + 'updated_at' => now(), + ]); + $this->releaseAllocationClaims((int) $existing->id); + $existing = null; + } + + if ($existing !== null && $this->matchesSnapshot($existing, $snapshot)) { + $this->assertAllocationClaimsMatch($existing); + $expiresAt = now()->addMinutes($this->ttlMinutes); + + DB::table('ptero_resource_reservations') + ->where('id', $existing->id) + ->update([ + 'user_id' => $ownerId, + 'expires_at' => $expiresAt, + 'guaranteed_until' => $expiresAt, + 'updated_at' => now(), + ]); + + return [ + 'id' => (int) $existing->id, + 'node_id' => (int) $existing->node_id, + 'expires_at' => $expiresAt->toIso8601String(), + 'status' => 'pending', + ]; + } + + $excludedToken = $existing?->token; + if ($existing !== null) { + DB::table('ptero_resource_reservations') + ->where('id', $existing->id) + ->update([ + 'status' => 'cancelled', + 'admin_notes' => 'Replaced after cart configuration changed.', + 'updated_at' => now(), + ]); + $this->releaseAllocationClaims((int) $existing->id); + } + + $requiredAllocations = max( + 1, + (int) data_get($snapshot, 'allocation_requirements.required_count', 1) + ); + $requiredPorts = collect( + data_get($snapshot, 'allocation_requirements.mappings', []) + ) + ->pluck('requested_port') + ->filter(fn ($port) => $port !== null) + ->map(fn ($port) => (int) $port) + ->values() + ->all(); + $node = method_exists($this->nodeService, 'selectBestNodeWithAllocations') + ? $this->nodeService->selectBestNodeWithAllocations( + $snapshot['location_id'], + $snapshot['resources'], + $requiredAllocations, + $excludedToken, + $requiredPorts, + (array) data_get( + $snapshot, + 'allocation_requirements.allowed_port_ranges', + [] + ), + (bool) data_get( + $snapshot, + 'allocation_requirements.dedicated_ip', + false + ) + ) + : $this->nodeService->selectBestNode( + $snapshot['location_id'], + $snapshot['resources'], + $excludedToken + ); + + if ($node === null) { + throw new DisplayException('No node has enough capacity for this configuration.'); + } + + $availableAllocations = array_values( + $node['selected_allocations'] ?? $node['available_allocations'] ?? [] + ); + if (count($availableAllocations) < $requiredAllocations) { + throw new DisplayException( + 'No node has enough unassigned allocations for this configuration.' + ); + } + + $selectedAllocations = $this->mapAllocationRequirements( + $availableAllocations, + (array) data_get($snapshot, 'allocation_requirements.mappings', []) + ); + $payload = $this->configurationService->withPlacement( + $snapshot, + (int) $node['node_id'], + $selectedAllocations + ); + $fingerprint = $this->configurationService->fingerprint($payload); + $token = Str::random(64); + $expiresAt = now()->addMinutes($this->ttlMinutes); + + $id = DB::table('ptero_resource_reservations')->insertGetId([ + 'token' => $token, + 'idempotency_key' => null, + 'cart_item_id' => $cartItem->id, + 'cart_item_guard_id' => $cartItem->id, + 'cart_id' => $snapshot['cart_id'], + 'server_extension_id' => $snapshot['server_extension_id'], + 'panel_identity' => $snapshot['panel_identity'], + 'service_id' => null, + 'user_id' => $ownerId, + 'product_id' => $snapshot['product_id'], + 'plan_id' => $snapshot['plan_id'], + 'quantity' => $snapshot['quantity'], + 'currency_code' => $snapshot['currency_code'], + 'configuration_fingerprint' => $fingerprint, + 'configuration_payload' => json_encode($payload, JSON_THROW_ON_ERROR), + 'pricing_version' => $snapshot['pricing_version'], + 'formula_version' => $snapshot['formula_version'], + 'node_id' => $node['node_id'], + 'location_id' => $snapshot['location_id'], + 'memory' => $snapshot['resources']['memory'], + 'cpu' => $snapshot['resources']['cpu'], + 'disk' => $snapshot['resources']['disk'], + 'calculated_price' => $snapshot['calculated_price'], + 'pricing_breakdown' => json_encode([], JSON_THROW_ON_ERROR), + 'status' => 'pending', + 'expires_at' => $expiresAt, + 'guaranteed_until' => $expiresAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + foreach ($selectedAllocations as $allocation) { + ReservationAllocation::query()->create([ + 'reservation_id' => $id, + 'panel_identity' => $snapshot['panel_identity'], + 'node_id' => (int) $node['node_id'], + 'allocation_id' => (int) $allocation['allocation_id'], + 'ip' => $allocation['ip'] ?: null, + 'port' => (int) $allocation['port'], + 'environment_key' => $allocation['environment_key'], + 'is_primary' => (bool) $allocation['is_primary'], + ]); + } + + $this->safeAudit('created', 'reservation', $id, [ + 'reservation_id' => $id, + 'configuration_fingerprint' => $fingerprint, + 'product_id' => $snapshot['product_id'], + 'plan_id' => $snapshot['plan_id'], + 'location_id' => $snapshot['location_id'], + 'node_id' => $node['node_id'], + 'memory' => $snapshot['resources']['memory'], + 'cpu' => $snapshot['resources']['cpu'], + 'disk' => $snapshot['resources']['disk'], + 'cart_item_id' => $cartItem->id, + ]); + + return [ + 'id' => $id, + 'node_id' => (int) $node['node_id'], + 'expires_at' => $expiresAt->toIso8601String(), + 'status' => 'pending', + ]; + }, 5); + } + + /** + * Transfer every guest hold with the cart inside the login transaction. + */ + public function transferCartOwnership(int $cartId, int $userId): int + { + return DB::transaction(function () use ($cartId, $userId) { + $user = User::query() + ->whereKey($userId) + ->lockForUpdate() + ->firstOrFail(); + $reservations = DB::table('ptero_resource_reservations') + ->where('cart_id', $cartId) + ->where('status', 'pending') + ->lockForUpdate() + ->get(); + + foreach ($reservations as $reservation) { + if ($reservation->user_id !== null && (int) $reservation->user_id !== $userId) { + throw new \RuntimeException('A capacity hold in this cart belongs to a different customer.'); } - $node = $this->nodeService->selectBestNode($locationId, $resources); + if ($reservation->user_id !== null) { + continue; + } - if (!$node) { - throw new \RuntimeException('No node with sufficient resources available'); + $payload = json_decode( + (string) $reservation->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + if (! is_array($payload) || (int) ($payload['cart_id'] ?? 0) !== $cartId) { + throw new \RuntimeException('Capacity hold payload does not match its cart.'); } - $token = Str::random(64); - $expiresAt = now()->addMinutes($this->ttlMinutes); + $payload = $this->configurationService->withCustomer( + $payload, + $userId, + (string) $user->email + ); - $id = DB::table('ptero_resource_reservations')->insertGetId([ - 'token' => $token, - 'idempotency_key' => $idempotencyKey, - 'cart_item_id' => $cartItemId, - 'user_id' => $userId, - 'node_id' => $node['node_id'], - 'location_id' => $locationId, - 'memory' => $resources['memory'], - 'cpu' => $resources['cpu'], - 'disk' => $resources['disk'], - 'calculated_price' => 0, - 'pricing_breakdown' => json_encode([]), - 'status' => 'pending', - 'expires_at' => $expiresAt, - 'created_at' => now(), + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'user_id' => $userId, + 'configuration_payload' => json_encode($payload, JSON_THROW_ON_ERROR), + 'configuration_fingerprint' => $this->configurationService->fingerprint($payload), + 'updated_at' => now(), + ]); + } + + return $reservations->whereNull('user_id')->count(); + }, 5); + } + + /** + * Attach the validated cart hold to the newly created service. + */ + public function bindCartItemToService( + CartItem $cartItem, + Service $service, + User $user, + CarbonInterface $holdUntil, + ?Invoice $invoice = null + ): void { + $snapshot = $this->configurationService->forCartItem($cartItem); + + DB::transaction(function () use ($cartItem, $service, $user, $holdUntil, $invoice, $snapshot) { + $lockedInvoice = null; + if ($invoice !== null) { + $lockedInvoice = Invoice::query() + ->whereKey($invoice->id) + ->lockForUpdate() + ->firstOrFail(); + } + $lockedService = Service::query() + ->whereKey($service->id) + ->lockForUpdate() + ->firstOrFail(); + $reservation = DB::table('ptero_resource_reservations') + ->where('cart_item_id', $cartItem->id) + ->where('status', 'pending') + ->lockForUpdate() + ->first(); + $invoiceItems = $lockedInvoice === null + ? collect() + : DB::table('invoice_items') + ->where('invoice_id', $lockedInvoice->id) + ->orderBy('id') + ->lockForUpdate() + ->get(); + + if ($reservation === null || Carbon::parse($reservation->expires_at)->isPast()) { + throw new DisplayException('Capacity hold expired during checkout. Please reconfigure this product.'); + } + + if ($reservation->user_id !== null && (int) $reservation->user_id !== (int) $user->id) { + throw new DisplayException('This capacity hold belongs to a different customer.'); + } + + if ($reservation->service_id !== null && (int) $reservation->service_id !== (int) $lockedService->id) { + throw new \RuntimeException('This capacity hold is already bound to another service.'); + } + + if (! $this->matchesSnapshot($reservation, $snapshot)) { + throw new DisplayException('The cart configuration changed after capacity was reserved. Please reconfigure this product.'); + } + + if ( + (int) $lockedService->product_id !== (int) $reservation->product_id + || (int) $lockedService->plan_id !== (int) $reservation->plan_id + || (int) $lockedService->quantity !== (int) $reservation->quantity + || strtoupper((string) $lockedService->currency_code) !== strtoupper((string) $reservation->currency_code) + ) { + throw new \RuntimeException('The new service does not match its capacity reservation.'); + } + if ( + $lockedInvoice !== null + && ( + $lockedInvoice->status !== Invoice::STATUS_PENDING + || (int) $lockedInvoice->user_id !== (int) $user->id + || strtoupper((string) $lockedInvoice->currency_code) + !== strtoupper((string) $lockedService->currency_code) + ) + ) { + throw new \RuntimeException( + 'The checkout invoice does not match its capacity-backed service.' + ); + } + if ( + $lockedInvoice !== null + && ! $invoiceItems->contains( + fn ($item): bool => (int) $item->reference_id === (int) $lockedService->id + && (string) $item->reference_type === $lockedService->getMorphClass() + && (int) $item->quantity === (int) $lockedService->quantity + ) + ) { + throw new \RuntimeException( + 'The checkout invoice is missing the immutable capacity-backed service line.' + ); + } + + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'service_id' => $lockedService->id, + 'service_guard_id' => $lockedService->id, + 'invoice_id' => $lockedInvoice?->id, + 'user_id' => $user->id, + 'expires_at' => $holdUntil, + 'guaranteed_until' => $holdUntil, 'updated_at' => now(), ]); - $this->safeAudit('created', 'reservation', $id, [ - 'token_prefix' => substr($token, 0, 8) . '...', - 'product_id' => $productId, - 'location_id' => $locationId, - 'node_id' => $node['node_id'], - 'memory' => $resources['memory'], - 'cpu' => $resources['cpu'], - 'disk' => $resources['disk'], - 'price' => 0, - 'cart_item_id' => $cartItemId, + $this->safeAudit('reservation_bound', 'resource_reservation', $reservation->id, [ + 'service_id' => $lockedService->id, + 'invoice_id' => $lockedInvoice?->id, + 'user_id' => $user->id, + 'configuration_fingerprint' => $reservation->configuration_fingerprint, + ]); + }, 5); + } + + /** + * Turn an invoice-backed hold into a durable, non-expiring fulfillment + * commitment. The caller is responsible for running this inside the same + * transaction that marks the invoice paid. + */ + public function preflightPaidService( + Service $service, + Invoice $invoice + ): ?string { + try { + $hasBoundReservation = $this->hasCheckoutReservation( + (int) $service->id + ); + if ( + ! $hasBoundReservation + && ! $this->configurationService->requiresReservation( + (int) $service->product_id + ) + ) { + return null; + } + + $this->configurationService->assertExclusiveProvisioningControl(); + + DB::transaction(function () use ($service, $invoice): void { + $lockedInvoice = Invoice::query() + ->whereKey($invoice->id) + ->lockForUpdate() + ->firstOrFail(); + $lockedService = Service::query() + ->whereKey($service->id) + ->lockForUpdate() + ->firstOrFail(); + $reservation = $this->checkoutCommitmentQuery( + $lockedService->id + ) + ->lockForUpdate() + ->first(); + if ($reservation === null) { + throw new \RuntimeException( + 'A paid dynamic resource service has no capacity reservation.' + ); + } + if ( + $reservation->invoice_id !== null + && (int) $reservation->invoice_id + !== (int) $lockedInvoice->id + ) { + throw new \RuntimeException( + 'The paid invoice does not own this capacity commitment.' + ); + } + if (in_array($reservation->status, [ + ResourceReservation::STATUS_PAID_COMMITTED, + ResourceReservation::STATUS_CONFIRMED, + ], true)) { + return; + } + if ($reservation->status !== ResourceReservation::STATUS_PENDING) { + throw new \RuntimeException( + "Cannot commit a {$reservation->status} capacity reservation after payment." + ); + } + + $guaranteedUntil = Carbon::parse( + $reservation->guaranteed_until + ?? $reservation->expires_at + ); + if ($guaranteedUntil->isPast()) { + throw new \RuntimeException( + 'The seven-day capacity guarantee expired before payment completed.' + ); + } + + $this->assertInvoiceLineMatchesReservation( + $lockedService, + $lockedInvoice, + $reservation + ); + $this->configurationService->assertServiceMatches( + $lockedService, + $reservation + ); + $this->assertAllocationClaimsMatch( + $reservation + ); + }, 5); + + return null; + } catch (\Throwable $exception) { + return "Capacity-backed service {$service->id} failed payment preflight: {$exception->getMessage()}"; + } + } + + public function commitPaidService(Service $service, Invoice $invoice): bool + { + $hasBoundReservation = $this->hasCheckoutReservation((int) $service->id); + if ( + ! $hasBoundReservation + && ! $this->configurationService->requiresReservation((int) $service->product_id) + ) { + return false; + } + + $this->configurationService->assertExclusiveProvisioningControl(); + + return DB::transaction(function () use ($service, $invoice) { + $lockedInvoice = Invoice::query() + ->whereKey($invoice->id) + ->lockForUpdate() + ->firstOrFail(); + $lockedService = Service::query()->whereKey($service->id)->lockForUpdate()->firstOrFail(); + $reservation = $this->checkoutCommitmentQuery($lockedService->id) + ->lockForUpdate() + ->first(); + + if ($reservation === null) { + throw new \RuntimeException( + 'A paid dynamic resource service has no capacity reservation.' + ); + } + + if ($reservation->invoice_id !== null && (int) $reservation->invoice_id !== (int) $lockedInvoice->id) { + throw new \RuntimeException('The paid invoice does not own this capacity commitment.'); + } + + if ($reservation->status === ResourceReservation::STATUS_PENDING) { + $this->assertInvoiceLineMatchesReservation( + $lockedService, + $lockedInvoice, + $reservation + ); + } + + if ($reservation->status === ResourceReservation::STATUS_CONFIRMED) { + $cancellationRequested = $reservation->cancellation_requested_at !== null + || $lockedService->cancellation()->where('type', 'immediate')->exists() + || in_array($lockedService->status, [ + Service::STATUS_CANCELLED, + Service::STATUS_CANCELLATION_PENDING, + ], true); + if (! $cancellationRequested) { + $lockedService->status = Service::STATUS_ACTIVE; + $this->persistFulfillmentService($lockedService); + } + + return true; + } + + if ($reservation->status === ResourceReservation::STATUS_PAID_COMMITTED) { + if (! in_array($lockedService->status, ['active', 'cancelled', 'cancellation_pending'], true)) { + $lockedService->status = 'provisioning'; + $this->persistFulfillmentService($lockedService); + } + + return true; + } + + if ($reservation->status !== ResourceReservation::STATUS_PENDING) { + throw new \RuntimeException( + "Cannot commit a {$reservation->status} capacity reservation after payment." + ); + } + + $guaranteedUntil = Carbon::parse( + $reservation->guaranteed_until ?? $reservation->expires_at + ); + if ($guaranteedUntil->isPast()) { + throw new \RuntimeException( + 'The seven-day capacity guarantee expired before payment completed.' + ); + } + + $this->configurationService->assertServiceMatches($lockedService, $reservation); + $this->assertAllocationClaimsMatch($reservation); + + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'invoice_id' => $lockedInvoice->id, + 'status' => ResourceReservation::STATUS_PAID_COMMITTED, + 'paid_committed_at' => now(), + 'next_provisioning_attempt_at' => now(), + 'updated_at' => now(), ]); - return $this->presentReservation((object) [ - 'id' => $id, - 'token' => $token, - 'node_id' => $node['node_id'], - 'node_name' => $node['name'] ?? null, - 'expires_at' => $expiresAt, - 'calculated_price' => 0, - 'pricing_breakdown' => [], - 'status' => 'pending', + $lockedService->status = 'provisioning'; + if ($lockedService->expires_at === null) { + $lockedService->expires_at = $lockedService->calculateNextDueDate(); + } + $this->persistFulfillmentService($lockedService); + + $this->safeAudit('reservation_paid_committed', 'resource_reservation', $reservation->id, [ + 'service_id' => $lockedService->id, + 'invoice_id' => $lockedInvoice->id, + 'guaranteed_until' => $guaranteedUntil->toIso8601String(), + ]); + + return true; + }, 5); + } + + /** + * Free services have no invoice but require the same durable fulfillment + * state before a queue worker may create a server. + */ + public function commitFreeService(Service $service): bool + { + $hasBoundReservation = $this->hasCheckoutReservation((int) $service->id); + if ( + ! $hasBoundReservation + && ! $this->configurationService->requiresReservation((int) $service->product_id) + ) { + return false; + } + + $this->configurationService->assertExclusiveProvisioningControl(); + + return DB::transaction(function () use ($service) { + $lockedService = Service::query()->whereKey($service->id)->lockForUpdate()->firstOrFail(); + $reservation = $this->checkoutCommitmentQuery($lockedService->id) + ->lockForUpdate() + ->first(); + + if ($reservation === null) { + throw new \RuntimeException( + 'A free dynamic resource service has no capacity reservation.' + ); + } + + if (! in_array($reservation->status, [ + ResourceReservation::STATUS_PENDING, + ResourceReservation::STATUS_PAID_COMMITTED, + ResourceReservation::STATUS_CONFIRMED, + ], true)) { + throw new \RuntimeException("Cannot fulfill a {$reservation->status} free-service reservation."); + } + + $this->configurationService->assertServiceMatches($lockedService, $reservation); + if ($reservation->status === ResourceReservation::STATUS_PENDING) { + $this->assertAllocationClaimsMatch($reservation); + } + + if ($reservation->status === ResourceReservation::STATUS_PENDING) { + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'status' => ResourceReservation::STATUS_PAID_COMMITTED, + 'paid_committed_at' => now(), + 'next_provisioning_attempt_at' => now(), + 'updated_at' => now(), + ]); + } + + if ($reservation->status !== ResourceReservation::STATUS_CONFIRMED) { + $lockedService->status = 'provisioning'; + $lockedService->expires_at ??= $lockedService->calculateNextDueDate(); + $this->persistFulfillmentService($lockedService); + } + + return true; + }, 5); + } + + /** + * Lock and validate a hold immediately before the external create request. + * + * @return array{ + * reservation_id: int, + * panel_identity: string, + * node_id: int, + * location_id: int, + * memory: int, + * cpu: int, + * disk: int, + * provisioning_lease_id: string|null, + * already_consumed: bool, + * nest_id: int, + * egg_id: int, + * user_external_id: string + * }|null + */ + public function beginProvisioning(Service $service): ?array + { + return DB::transaction(function () use ($service) { + $reservation = $this->checkoutCommitmentQuery((int) $service->id) + ->lockForUpdate() + ->first(); + + if ($reservation === null) { + if ($this->configurationService->requiresReservation($service->product_id)) { + throw new PermanentProvisioningException( + 'Dynamic resource service has no capacity reservation.' + ); + } + + return null; + } + + $this->assertAllocationClaimsMatch($reservation); + + if ($reservation->status === 'confirmed') { + return $this->provisioningContext($reservation, true); + } + + if ($reservation->status !== ResourceReservation::STATUS_PAID_COMMITTED) { + throw new PermanentProvisioningException( + "Capacity reservation is {$reservation->status}." + ); + } + + if ($reservation->cancellation_requested_at !== null) { + throw new \RuntimeException('Capacity fulfillment was cancelled before provisioning.'); + } + + if ( + $reservation->provisioning_started_at !== null + && Carbon::parse($reservation->provisioning_started_at) + ->greaterThan(now()->subMinutes(self::PROVISIONING_LEASE_MINUTES)) + ) { + throw new \RuntimeException('Capacity reservation is already being provisioned.'); + } + + try { + $this->configurationService->assertServiceMatches($service, $reservation); + } catch (\RuntimeException $exception) { + throw new PermanentProvisioningException( + $exception->getMessage(), + (int) $exception->getCode(), + $exception + ); + } + + $leaseExpiresAt = now()->addMinutes(self::PROVISIONING_LEASE_MINUTES); + $currentExpiresAt = Carbon::parse($reservation->expires_at); + $leaseId = Str::random(64); + + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'provisioning_started_at' => now(), + 'provisioning_lease_id' => $leaseId, + 'expires_at' => $currentExpiresAt->greaterThan($leaseExpiresAt) + ? $currentExpiresAt + : $leaseExpiresAt, + 'provisioning_attempts' => (int) $reservation->provisioning_attempts + 1, + 'last_provisioning_attempt_at' => now(), + 'next_provisioning_attempt_at' => null, + 'last_provisioning_error' => null, + 'updated_at' => now(), ]); - }, 5); // 5 retry attempts for deadlock - } catch (QueryException $exception) { - if ($this->isActiveIdempotencyDuplicate($exception, $userId, $idempotencyKey)) { - $existingReservation = $this->getActiveByIdempotencyKey($userId, $idempotencyKey); - if ($existingReservation) { - Log::info('Returning existing reservation after duplicate idempotency insert race', [ - 'reservation_id' => $existingReservation->id, - 'user_id' => $userId, - 'idempotency_key' => $idempotencyKey, + + return $this->provisioningContext($reservation, false, $leaseId); + }, 5); + } + + /** + * Confirm the exact external server and activate the Paymenter service. + * A false result means cancellation won the race and the caller must delete + * the external server rather than exposing it to the customer. + * + * @param array $externalServer + */ + public function completeProvisioning( + int|Service $service, + ?string $leaseId = null, + array $externalServer = [] + ): bool { + $serviceId = $service instanceof Service ? (int) $service->id : $service; + + return DB::transaction(function () use ($serviceId, $leaseId, $externalServer) { + $lockedService = Service::query()->whereKey($serviceId)->lockForUpdate()->firstOrFail(); + $reservation = $this->checkoutCommitmentQuery($serviceId) + ->lockForUpdate() + ->first(); + + if ($reservation === null) { + return false; + } + + if ($reservation->status === 'confirmed') { + $alreadyCancelled = $reservation->cancellation_requested_at !== null + || $lockedService->cancellation()->where('type', 'immediate')->exists() + || $lockedService->status === 'cancelled'; + if (! $alreadyCancelled) { + $lockedService->status = Service::STATUS_ACTIVE; + $this->persistFulfillmentService($lockedService); + } + + return ! $alreadyCancelled; + } + + if ($reservation->status !== ResourceReservation::STATUS_PAID_COMMITTED) { + throw new \RuntimeException("Cannot consume a {$reservation->status} capacity reservation."); + } + + if ( + $leaseId === null + || $reservation->provisioning_lease_id === null + || ! hash_equals((string) $reservation->provisioning_lease_id, $leaseId) + ) { + throw new \RuntimeException('Provisioning lease no longer owns this capacity reservation.'); + } + + $cancellationRequested = $reservation->cancellation_requested_at !== null + || $lockedService->status === 'cancelled' + || $lockedService->cancellation()->where('type', 'immediate')->exists(); + $externalAttributes = $externalServer['attributes'] ?? $externalServer; + $externalIdentity = $this->externalServerIdentity($externalAttributes); + + if ($cancellationRequested) { + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'cancellation_requested_at' => $reservation->cancellation_requested_at ?? now(), + ...$externalIdentity, + 'last_reconciled_at' => now(), + 'provisioning_started_at' => null, + 'provisioning_lease_id' => null, + 'updated_at' => now(), ]); + $lockedService->status = 'cancellation_pending'; + $this->persistFulfillmentService($lockedService); + + return false; + } + + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'status' => 'confirmed', + 'consumed_at' => now(), + ...$externalIdentity, + 'last_reconciled_at' => now(), + 'provisioning_started_at' => null, + 'provisioning_lease_id' => null, + 'last_provisioning_error' => null, + 'updated_at' => now(), + ]); + + $this->releaseAllocationClaims((int) $reservation->id); + $lockedService->status = Service::STATUS_ACTIVE; + $this->persistFulfillmentService($lockedService); + + $this->safeAudit('reservation_consumed', 'resource_reservation', $reservation->id, [ + 'service_id' => $serviceId, + 'node_id' => $reservation->node_id, + 'configuration_fingerprint' => $reservation->configuration_fingerprint, + ]); + + return true; + }, 5); + } + + public function failProvisioning( + int $serviceId, + ?string $leaseId, + \Throwable $exception + ): void { + $attempts = (int) $this->checkoutCommitmentQuery($serviceId) + ->value('provisioning_attempts'); + $delay = self::RETRY_DELAYS_SECONDS[ + min(max(0, $attempts - 1), count(self::RETRY_DELAYS_SECONDS) - 1) + ]; + + $this->checkoutCommitmentQuery($serviceId) + ->where('status', ResourceReservation::STATUS_PAID_COMMITTED) + ->where('provisioning_lease_id', $leaseId) + ->update([ + 'provisioning_started_at' => null, + 'provisioning_lease_id' => null, + 'next_provisioning_attempt_at' => now()->addSeconds($delay), + 'last_provisioning_error' => Str::limit($exception->getMessage(), 1000, ''), + 'updated_at' => now(), + ]); + } + + /** + * Persist a cancellation tombstone before any asynchronous delete runs. + * + * Returns true when the service has a dynamic reservation. + */ + public function requestServiceCancellation(Service $service): bool + { + return DB::transaction(function () use ($service) { + $lockedService = Service::query()->whereKey($service->id)->lockForUpdate()->firstOrFail(); + $reservation = $this->checkoutCommitmentQuery($lockedService->id) + ->lockForUpdate() + ->first(); + + if ($reservation === null) { + return false; + } + + if ($reservation->status === ResourceReservation::STATUS_PENDING) { + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'status' => ResourceReservation::STATUS_CANCELLED, + 'cancellation_requested_at' => now(), + 'provisioning_started_at' => null, + 'provisioning_lease_id' => null, + 'admin_notes' => 'Service cancelled before payment.', + 'updated_at' => now(), + ]); + $this->releaseAllocationClaims((int) $reservation->id); + $this->releaseProductStockOnce($reservation, $lockedService); + $lockedService->status = Service::STATUS_CANCELLED; + $this->persistFulfillmentService($lockedService); + + return true; + } + + if ($reservation->status === ResourceReservation::STATUS_CANCELLED) { + return true; + } + + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'cancellation_requested_at' => $reservation->cancellation_requested_at ?? now(), + 'updated_at' => now(), + ]); + $lockedService->status = 'cancellation_pending'; + $this->persistFulfillmentService($lockedService); + + return true; + }, 5); + } + + /** + * Return the integrity-checked checkout contract used to reconcile a + * cancellation after an external create may have succeeded without + * returning a response to Paymenter. + * + * This path is intentionally limited to an unconsumed paid commitment + * with no pinned external identity. It never creates or adopts a + * Pterodactyl customer. + * + * @return array|null + */ + public function cancellationReconciliationContext( + int|Service $service + ): ?array { + $serviceId = $service instanceof Service + ? (int) $service->id + : $service; + + return DB::transaction(function () use ($serviceId) { + $lockedService = Service::query() + ->whereKey($serviceId) + ->lockForUpdate() + ->firstOrFail(); + $reservation = $this->checkoutCommitmentQuery($serviceId) + ->lockForUpdate() + ->first(); + + if ($reservation === null) { + return null; + } + + return $this->buildCancellationReconciliationContext( + $lockedService, + $reservation, + true + ); + }, 5); + } + + /** + * Persist the exact external identity only after the provisioner has + * matched the candidate server to cancellationReconciliationContext(). + * Re-validate the complete signed checkout contract under the same row + * lock so a stale or competing cancellation worker cannot pin a different + * server. + * + * @param array $externalServer + * @return array + */ + public function pinCancellationServerIdentity( + int|Service $service, + array $externalServer, + int $expectedExternalUserId + ): array { + $serviceId = $service instanceof Service + ? (int) $service->id + : $service; - return $this->presentReservation($existingReservation); + return DB::transaction(function () use ( + $serviceId, + $externalServer, + $expectedExternalUserId + ) { + $lockedService = Service::query() + ->whereKey($serviceId) + ->lockForUpdate() + ->firstOrFail(); + $reservation = $this->checkoutCommitmentQuery($serviceId) + ->lockForUpdate() + ->first(); + if ($reservation === null) { + throw new PermanentProvisioningException( + 'The cancellation has no checkout commitment to reconcile.' + ); + } + + $context = $this->buildCancellationReconciliationContext( + $lockedService, + $reservation, + false + ); + if ($context['provisioning_in_flight']) { + throw new \RuntimeException( + 'Provisioning is still in flight; cancellation will retry.' + ); + } + + $this->assertCancellationServerMatchesContext( + $externalServer, + $context, + $serviceId, + $expectedExternalUserId + ); + $attributes = $externalServer['attributes'] ?? $externalServer; + $externalIdentity = $this->externalServerIdentity($attributes); + $storedIdentity = [ + 'external_server_id' => $reservation->external_server_id, + 'external_user_id' => $reservation->external_user_id, + 'external_server_uuid' => $reservation->external_server_uuid, + 'external_server_identifier' => $reservation->external_server_identifier, + ]; + $hasStoredIdentity = collect($storedIdentity) + ->contains(fn ($value): bool => $value !== null); + + if ($hasStoredIdentity) { + $storedIdentityMatches = + is_numeric($storedIdentity['external_server_id']) + && (int) $storedIdentity['external_server_id'] + === $externalIdentity['external_server_id'] + && is_numeric($storedIdentity['external_user_id']) + && (int) $storedIdentity['external_user_id'] + === $externalIdentity['external_user_id'] + && is_string( + $storedIdentity['external_server_uuid'] + ) + && hash_equals( + $storedIdentity['external_server_uuid'], + $externalIdentity['external_server_uuid'] + ) + && is_string( + $storedIdentity['external_server_identifier'] + ) + && hash_equals( + $storedIdentity['external_server_identifier'], + $externalIdentity[ + 'external_server_identifier' + ] + ); + if (! $storedIdentityMatches) { + throw new PermanentProvisioningException( + 'The cancellation found a conflicting pinned Pterodactyl server identity.' + ); } + } else { + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + ...$externalIdentity, + 'last_reconciled_at' => now(), + 'provisioning_started_at' => null, + 'provisioning_lease_id' => null, + 'updated_at' => now(), + ]); + } + + return [ + ...$context, + ...$externalIdentity, + ]; + }, 5); + } + + /** + * Finish cancellation only after the provisioner has established that the + * external server is absent. + */ + public function completeServiceCancellation(int|Service $service): bool + { + $serviceId = $service instanceof Service ? (int) $service->id : $service; + + return DB::transaction(function () use ($serviceId) { + $lockedService = Service::query()->whereKey($serviceId)->lockForUpdate()->firstOrFail(); + $reservation = $this->checkoutCommitmentQuery($serviceId) + ->lockForUpdate() + ->first(); + + if ($reservation === null) { + $lockedService->status = Service::STATUS_CANCELLED; + $this->persistFulfillmentService($lockedService); + + return false; + } + + if ( + $reservation->provisioning_started_at !== null + && Carbon::parse($reservation->provisioning_started_at) + ->greaterThan(now()->subMinutes(self::PROVISIONING_LEASE_MINUTES)) + ) { + throw new \RuntimeException('Provisioning is still in flight; cancellation will retry.'); + } + + if ($reservation->status !== ResourceReservation::STATUS_CONFIRMED) { + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'status' => ResourceReservation::STATUS_CANCELLED, + 'cancellation_requested_at' => $reservation->cancellation_requested_at ?? now(), + 'provisioning_started_at' => null, + 'provisioning_lease_id' => null, + 'next_provisioning_attempt_at' => null, + 'updated_at' => now(), + ]); + $this->releaseAllocationClaims((int) $reservation->id); } - throw $exception; + $this->releaseProductStockOnce($reservation, $lockedService); + $lockedService->status = Service::STATUS_CANCELLED; + $this->persistFulfillmentService($lockedService); + + return true; + }, 5); + } + + public function provisioningMayContinue(int $serviceId, ?string $leaseId): bool + { + if ($leaseId === null) { + return false; + } + + return $this->checkoutCommitmentQuery($serviceId) + ->where('status', ResourceReservation::STATUS_PAID_COMMITTED) + ->where('provisioning_lease_id', $leaseId) + ->whereNull('cancellation_requested_at') + ->exists(); + } + + /** + * Reservation history is deliberately permanent. Administrative and API + * entry points use this check to prevent a local hard-delete or raw + * extension action from bypassing fulfillment and orphaning a server. + */ + public function hasCheckoutReservation(int $serviceId): bool + { + return DB::table('ptero_resource_reservations') + ->where('purpose', 'checkout') + ->where('service_id', $serviceId) + ->exists(); + } + + /** + * Return the immutable identity that every post-provisioning lifecycle + * action must prove before it controls an external server. + * + * @return array|null + */ + public function serverLifecycleIdentity(int|Service $service): ?array + { + $serviceId = $service instanceof Service + ? (int) $service->id + : $service; + $reservation = $this->checkoutCommitmentQuery($serviceId)->first(); + if ($reservation === null) { + return null; } + + try { + $payload = json_decode( + (string) $reservation->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (\JsonException $exception) { + throw new PermanentProvisioningException( + 'The durable server identity snapshot is unreadable.', + previous: $exception + ); + } + if ( + ! is_array($payload) + || ! is_string($reservation->configuration_fingerprint) + || ! hash_equals( + $reservation->configuration_fingerprint, + $this->configurationService->fingerprint($payload) + ) + ) { + throw new PermanentProvisioningException( + 'The durable server identity snapshot failed its integrity check.' + ); + } + + $provisioning = (array) ( + $payload['provisioning_identity'] ?? [] + ); + + return [ + 'reservation_id' => (int) $reservation->id, + 'status' => (string) $reservation->status, + 'panel_identity' => (string) $reservation->panel_identity, + 'node_id' => (int) $reservation->node_id, + 'external_server_id' => $reservation->external_server_id !== null + ? (int) $reservation->external_server_id + : null, + 'external_user_id' => $reservation->external_user_id !== null + ? (int) $reservation->external_user_id + : null, + 'external_server_uuid' => $reservation->external_server_uuid, + 'external_server_identifier' => $reservation->external_server_identifier, + 'external_server_external_id' => (string) $serviceId, + 'user_external_id' => $provisioning['user_external_id'] ?? null, + 'user_email' => $provisioning['user_email'] ?? null, + 'nest_id' => $provisioning['nest_id'] ?? null, + 'egg_id' => $provisioning['egg_id'] ?? null, + ]; + } + + /** + * Called by CreateJob::failed(). The paid commitment remains reserved. + * + * @return array|null + */ + public function recordPermanentProvisioningFailure(Service $service, \Throwable $exception): ?array + { + return DB::transaction(function () use ($service, $exception) { + $lockedService = Service::query() + ->whereKey($service->id) + ->lockForUpdate() + ->firstOrFail(); + $reservation = $this->checkoutCommitmentQuery($lockedService->id) + ->lockForUpdate() + ->first(); + + if ($reservation === null || $reservation->status !== ResourceReservation::STATUS_PAID_COMMITTED) { + return null; + } + + $permanent = $exception instanceof PermanentProvisioningException; + $snapshot = [ + 'permanent' => $permanent, + 'reservation_id' => (int) $reservation->id, + 'service_id' => (int) $lockedService->id, + 'invoice_id' => $reservation->invoice_id !== null ? (int) $reservation->invoice_id : null, + 'node_id' => (int) $reservation->node_id, + 'memory' => (int) $reservation->memory, + 'cpu' => (int) $reservation->cpu, + 'disk' => (int) $reservation->disk, + 'attempts' => (int) $reservation->provisioning_attempts, + 'error' => Str::limit($exception->getMessage(), 1000, ''), + ]; + + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'provisioning_started_at' => null, + 'provisioning_lease_id' => null, + 'last_provisioning_error' => $snapshot['error'], + 'next_provisioning_attempt_at' => $permanent ? null : now()->addHour(), + 'failure_alerted_at' => $reservation->failure_alerted_at ?? now(), + 'updated_at' => now(), + ]); + + if ($permanent) { + $lockedService->status = Service::STATUS_PROVISIONING_FAILED; + $this->persistFulfillmentService($lockedService); + } + + if ($reservation->failure_alerted_at === null) { + app(AlertService::class)->notifyProvisioningFailure($snapshot); + } + + return $snapshot; + }, 5); + } + + /** + * Called by TerminateJob::failed(). The cancellation tombstone remains in + * place so neither a retrying create job nor a duplicate payment can expose + * the server. + * + * @return array|null + */ + public function recordPermanentCancellationFailure( + Service $service, + \Throwable $exception + ): ?array { + return DB::transaction(function () use ($service, $exception) { + $reservation = $this->checkoutCommitmentQuery((int) $service->id) + ->lockForUpdate() + ->first(); + + if ($reservation === null || $reservation->cancellation_requested_at === null) { + return null; + } + + $snapshot = [ + 'operation' => 'cancellation', + 'reservation_id' => (int) $reservation->id, + 'service_id' => (int) $service->id, + 'invoice_id' => $reservation->invoice_id !== null ? (int) $reservation->invoice_id : null, + 'node_id' => (int) $reservation->node_id, + 'attempts' => 8, + 'error' => Str::limit($exception->getMessage(), 1000, ''), + ]; + + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'last_cancellation_error' => $snapshot['error'], + 'cancellation_failure_alerted_at' => $reservation->cancellation_failure_alerted_at ?? now(), + 'updated_at' => now(), + ]); + + if ($reservation->cancellation_failure_alerted_at === null) { + app(AlertService::class)->notifyProvisioningFailure($snapshot); + } + + return $snapshot; + }, 5); + } + + public function markCustomerNotified(int $serviceId): ?bool + { + if (! $this->checkoutCommitmentQuery($serviceId)->exists()) { + return null; + } + + return $this->checkoutCommitmentQuery($serviceId) + ->whereNull('customer_notified_at') + ->update([ + 'customer_notified_at' => now(), + 'updated_at' => now(), + ]) > 0; } - private function getActiveByIdempotencyKey(?int $userId, string $idempotencyKey): ?object + public function customerNotificationPending(int $serviceId): ?bool { - return DB::table('ptero_resource_reservations') - ->when($userId === null, fn ($query) => $query->whereNull('user_id'), fn ($query) => $query->where('user_id', $userId)) - ->where('idempotency_key', $idempotencyKey) - ->where(function ($query) { - $query->where('status', 'confirmed') - ->orWhere(function ($subQuery) { - $subQuery->where('status', 'pending') - ->where('expires_at', '>', now()); - }); - }) - ->orderByDesc('id') - ->first(); + $reservation = $this->checkoutCommitmentQuery($serviceId) + ->first(['customer_notified_at']); + + return $reservation === null ? null : $reservation->customer_notified_at === null; } - /** - * Confirm a reservation (after successful payment) - * - * @param User|null $actor Authenticated user performing the action, or null for system context. - * When provided, authorization is enforced via ResourceReservationPolicy. - * Controller callers MUST pass the actor; system callers MAY pass null. - */ - public function confirm(string $token, int $serviceId, ?User $actor = null): bool + public function cancelForCartItem(int $cartItemId): bool { - $reservation = $this->getByToken($token); + return DB::transaction(function () use ($cartItemId) { + $reservation = DB::table('ptero_resource_reservations') + ->where('cart_item_id', $cartItemId) + ->where('status', 'pending') + ->lockForUpdate() + ->first(); - if ($actor !== null) { - $reservationModel = ResourceReservation::query()->where('token', $token)->first(); - - if ($reservationModel !== null) { - Gate::forUser($actor)->authorize('confirm', $reservationModel); + if ($reservation === null || $reservation->service_id !== null) { + return false; } - } - - $reservationId = $reservation?->id ?? 0; - - $rows = DB::table('ptero_resource_reservations') - ->where('token', $token) - ->where('status', 'pending') - ->where('expires_at', '>', now()) - ->update([ - 'status' => 'confirmed', - 'service_id' => $serviceId, - 'updated_at' => now(), - ]); - if ($rows > 0) { - $this->safeAudit('reservation_confirmed', 'resource_reservation', $reservationId, [ - 'token_prefix' => substr($token, 0, 8), - 'service_id' => $serviceId, - 'node_id' => $reservation->node_id ?? null, - ]); - } + $updated = DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'status' => 'cancelled', + 'admin_notes' => 'Cart item removed.', + 'updated_at' => now(), + ]) > 0; + if ($updated) { + $this->releaseAllocationClaims((int) $reservation->id); + } - return $rows > 0; + return $updated; + }, 5); } /** - * Cancel a reservation - * - * @param User|null $actor Authenticated user performing the action, or null for system context. - * When provided, authorization is enforced via ResourceReservationPolicy. - * Controller callers MUST pass the actor; system callers MAY pass null. + * Admin/system cancellation by the opaque internal token. */ - public function cancel(string $token, ?string $reason = null, string $source = 'system', ?User $actor = null): bool - { - $reservation = $this->getByToken($token); - - if (!$reservation) { + public function cancel( + string $token, + ?string $reason = null, + string $source = 'system', + ?User $actor = null + ): bool { + $reservationModel = ResourceReservation::query() + ->where('token', $token) + ->first(); + if ($reservationModel === null) { return false; } if ($actor !== null) { - $reservationModel = ResourceReservation::query()->where('token', $token)->first(); + Gate::forUser($actor)->authorize('cancel', $reservationModel); + } - if ($reservationModel !== null) { - Gate::forUser($actor)->authorize('cancel', $reservationModel); + $reservation = DB::transaction(function () use ( + $token, + $reason + ): ?object { + $reservation = DB::table('ptero_resource_reservations') + ->where('token', $token) + ->lockForUpdate() + ->first(); + if ( + $reservation === null + || $reservation->status !== 'pending' + || $reservation->service_id !== null + ) { + return null; } - } - $result = DB::table('ptero_resource_reservations') - ->where('token', $token) - ->where('status', 'pending') - ->update([ - 'status' => 'cancelled', - 'admin_notes' => $reason, - 'updated_at' => now(), - ]) > 0; + $updated = DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->where('status', 'pending') + ->whereNull('service_id') + ->update([ + 'status' => 'cancelled', + 'admin_notes' => $reason, + 'updated_at' => now(), + ]); + if ($updated !== 1) { + throw new \RuntimeException( + 'The reservation changed while cancellation was being committed.' + ); + } - if ($result) { - $this->safeAudit('reservation_cancelled', 'resource_reservation', $reservation->id, [ - 'token_prefix' => substr($token, 0, 8), - 'node_id' => $reservation->node_id ?? null, - ]); + $this->releaseAllocationClaims((int) $reservation->id); + + return $reservation; + }, 5); + if ($reservation === null) { + return false; } - return $result; + $this->safeAudit( + 'reservation_cancelled', + 'resource_reservation', + $reservation->id, + [ + 'source' => $source, + 'node_id' => $reservation->node_id, + ] + ); + + return true; } /** - * Extend reservation TTL - * - * @param User|null $actor Authenticated user performing the action, or null for system context. - * When provided, authorization is enforced via ResourceReservationPolicy. - * Controller callers MUST pass the actor; system callers MAY pass null. + * Admin-only TTL extension. */ public function extend(string $token, int $additionalMinutes = 15, ?User $actor = null): bool { - $reservation = $this->getByToken($token); + if ( + $additionalMinutes < 1 + || $additionalMinutes > self::MAX_ADMIN_EXTENSION_MINUTES + ) { + throw new \InvalidArgumentException( + 'A reservation extension must be between 1 and 60 minutes.' + ); + } - if ($actor !== null) { - $reservationModel = ResourceReservation::query()->where('token', $token)->first(); + $extended = DB::transaction(function () use ( + $token, + $additionalMinutes, + $actor + ): ?array { + $reservation = ResourceReservation::query() + ->where('token', $token) + ->lockForUpdate() + ->first(); + if ($reservation === null) { + return null; + } - if ($reservationModel !== null) { - Gate::forUser($actor)->authorize('extend', $reservationModel); + if ($actor !== null) { + Gate::forUser($actor)->authorize('extend', $reservation); } - } - $reservationId = $reservation?->id ?? 0; + if ( + $reservation->status !== ResourceReservation::STATUS_PENDING + || $reservation->service_id !== null + || $reservation->invoice_id !== null + ) { + return null; + } - $rows = DB::table('ptero_resource_reservations') - ->where('token', $token) - ->where('status', 'pending') - ->update([ - 'expires_at' => DB::raw("DATE_ADD(expires_at, INTERVAL {$additionalMinutes} MINUTE)"), - 'updated_at' => now(), - ]); + $now = now(); + $currentExpiresAt = $reservation->expires_at?->copy(); + $maximumExpiresAt = $now->copy()->addMinutes( + self::MAX_ADMIN_EXTENSION_MINUTES + ); + if ( + $currentExpiresAt === null + || ! $currentExpiresAt->greaterThan($now) + || ! $currentExpiresAt->lessThan($maximumExpiresAt) + ) { + return null; + } - if ($rows > 0) { - $this->safeAudit('reservation_extended', 'resource_reservation', $reservationId, [ - 'token_prefix' => substr($token, 0, 8), - 'additional_minutes' => $additionalMinutes, - 'node_id' => $reservation->node_id ?? null, - ]); + $requestedExpiresAt = $currentExpiresAt + ->copy() + ->addMinutes($additionalMinutes); + $newExpiresAt = $requestedExpiresAt->greaterThan( + $maximumExpiresAt + ) + ? $maximumExpiresAt + : $requestedExpiresAt; + $updated = DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->where( + 'status', + ResourceReservation::STATUS_PENDING + ) + ->whereNull('service_id') + ->whereNull('invoice_id') + ->update([ + 'expires_at' => $newExpiresAt, + 'guaranteed_until' => $newExpiresAt, + 'updated_at' => $now, + ]); + if ($updated !== 1) { + throw new \RuntimeException( + 'The reservation changed while its extension was being committed.' + ); + } + + return [ + 'id' => (int) $reservation->id, + 'node_id' => (int) $reservation->node_id, + 'previous_expires_at' => $currentExpiresAt->toIso8601String(), + 'expires_at' => $newExpiresAt->toIso8601String(), + 'capped' => $requestedExpiresAt->greaterThan( + $maximumExpiresAt + ), + ]; + }, 5); + if ($extended === null) { + return false; } - return $rows > 0; + $this->safeAudit( + 'reservation_extended', + 'resource_reservation', + $extended['id'], + [ + 'additional_minutes' => $additionalMinutes, + 'previous_expires_at' => $extended['previous_expires_at'], + 'expires_at' => $extended['expires_at'], + 'capped' => $extended['capped'], + 'node_id' => $extended['node_id'], + ] + ); + + return true; } - /** - * Get reservation by token - */ public function getByToken(string $token): ?object { return DB::table('ptero_resource_reservations') @@ -290,9 +1674,6 @@ public function getByToken(string $token): ?object ->first(); } - /** - * Get reservation by cart item - */ public function getByCartItem(int $cartItemId): ?object { return DB::table('ptero_resource_reservations') @@ -302,10 +1683,6 @@ public function getByCartItem(int $cartItemId): ?object } /** - * Get all reservations as a paginatable Eloquent builder (for admin API). - * - * Does NOT modify getAll() — callers of that method are unaffected. - * * @param array $filters * @return \Illuminate\Database\Eloquent\Builder */ @@ -313,25 +1690,22 @@ public function queryAll(array $filters = []): \Illuminate\Database\Eloquent\Bui { $query = ResourceReservation::query(); - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { $query->where('status', $filters['status']); } - if (!empty($filters['location_id'])) { + if (! empty($filters['location_id'])) { $query->where('location_id', (int) $filters['location_id']); } - if (!empty($filters['node_id'])) { + if (! empty($filters['node_id'])) { $query->where('node_id', (int) $filters['node_id']); } - if (!empty($filters['user_id'])) { + if (! empty($filters['user_id'])) { $query->where('user_id', (int) $filters['user_id']); } - return $query->orderBy('created_at', 'desc')->orderBy('id', 'desc'); + return $query->orderByDesc('created_at')->orderByDesc('id'); } - /** - * Get reservation statistics - */ public function getStatistics(string $period = '30d'): array { $startDate = match ($period) { @@ -349,12 +1723,40 @@ public function getStatistics(string $period = '30d'): array ->pluck('count', 'status') ->toArray(); - $revenue = DB::table('ptero_resource_reservations') + $revenueRows = DB::table('ptero_resource_reservations') ->where('created_at', '>=', $startDate) ->where('status', 'confirmed') - ->sum('calculated_price'); + ->selectRaw('currency_code, SUM(calculated_price) as total') + ->groupBy('currency_code') + ->orderBy('currency_code') + ->get(); + $revenueCents = []; + foreach ($revenueRows as $row) { + $currency = strtoupper(trim((string) $row->currency_code)); + $cents = $this->moneyCents($row->total); + if ($cents === null) { + throw new \RuntimeException( + 'Confirmed reservation revenue contains an invalid amount.' + ); + } + if (preg_match('/^[A-Z]{3}$/D', $currency) !== 1) { + $currency = 'UNSPECIFIED'; + } + $current = $revenueCents[$currency] ?? 0; + if ($current > PHP_INT_MAX - $cents) { + throw new \RuntimeException( + 'Confirmed reservation revenue exceeds the supported range.' + ); + } + $revenueCents[$currency] = $current + $cents; + } + ksort($revenueCents); + $revenueByCurrency = array_map( + fn (int $cents): string => $this->moneyFromCents($cents), + $revenueCents + ); - $avgResources = DB::table('ptero_resource_reservations') + $average = DB::table('ptero_resource_reservations') ->where('created_at', '>=', $startDate) ->where('status', 'confirmed') ->selectRaw('AVG(memory) as avg_memory, AVG(cpu) as avg_cpu, AVG(disk) as avg_disk') @@ -369,30 +1771,249 @@ public function getStatistics(string $period = '30d'): array 'period' => $period, 'total' => $total, 'by_status' => $stats, - 'confirmed_revenue' => $revenue, + 'confirmed_revenue_by_currency' => $revenueByCurrency, 'conversion_rate' => ($confirmed + $expired + $cancelled) > 0 ? round($confirmed / ($confirmed + $expired + $cancelled) * 100, 1) : 0, 'average_resources' => [ - 'memory' => round($avgResources->avg_memory ?? 0), - 'cpu' => round($avgResources->avg_cpu ?? 0), - 'disk' => round($avgResources->avg_disk ?? 0), + 'memory' => round($average->avg_memory ?? 0), + 'cpu' => round($average->avg_cpu ?? 0), + 'disk' => round($average->avg_disk ?? 0), ], ]; } - /** - * Cleanup expired reservations (called by scheduled job) - */ - public function cleanupExpired(): int + public function cleanupExpired(int $limit = 100): int { - $count = DB::table('ptero_resource_reservations') - ->where('status', 'pending') - ->where('expires_at', '<', now()) - ->update([ - 'status' => 'expired', - 'updated_at' => now(), - ]); + $limit = max(1, min($limit, 500)); + $count = app(SchedulerHealthService::class)->processEligibleRows( + SchedulerHealthService::TASK_EXPIRE_CHECKOUT, + 'resource_reservation', + $limit, + fn (): Builder => DB::table('ptero_resource_reservations') + ->where('purpose', 'checkout') + ->where('status', 'pending') + ->whereRaw( + 'COALESCE(guaranteed_until, expires_at) <= ?', + [now()] + ), + function (int $candidateId): bool { + return DB::transaction(function () use ($candidateId) { + $candidate = DB::table('ptero_resource_reservations')->where('id', $candidateId)->first(); + if ($candidate === null) { + return false; + } + + $invoice = $candidate->invoice_id !== null + ? Invoice::query()->whereKey($candidate->invoice_id)->lockForUpdate()->first() + : null; + $itemSnapshotIds = $invoice !== null + ? $invoice->items()->orderBy('id')->pluck('id') + : collect(); + $serviceIds = $invoice !== null + ? $invoice->items() + ->where('reference_type', Service::class) + ->pluck('reference_id') + ->merge( + DB::table('ptero_resource_reservations') + ->where('purpose', 'checkout') + ->where('invoice_id', $invoice->id) + ->pluck('service_id') + ) + ->filter() + ->map(fn ($id) => (int) $id) + ->unique() + ->sort() + ->values() + : collect(array_filter([(int) ($candidate->service_id ?? 0)])); + $services = Service::query() + ->whereKey($serviceIds->all()) + ->orderBy('id') + ->lockForUpdate() + ->get(); + $service = $services->firstWhere('id', (int) $candidate->service_id); + $reservations = DB::table('ptero_resource_reservations') + ->where('purpose', 'checkout') + ->when( + $invoice !== null, + fn (Builder $query) => $query->where( + 'invoice_id', + $invoice->id + ), + fn (Builder $query) => $query->where('id', $candidateId) + ) + ->orderBy('id') + ->lockForUpdate() + ->get(); + $reservation = $reservations->firstWhere('id', $candidateId); + $lockedItems = $invoice !== null + ? $invoice->items() + ->orderBy('id') + ->lockForUpdate() + ->get() + : collect(); + if ( + $invoice !== null + && $lockedItems->pluck('id')->all() + !== $itemSnapshotIds->all() + ) { + throw new \RuntimeException( + 'The capacity invoice obligations changed while expiry was acquiring its locks.' + ); + } + + if ( + $reservation === null + || $reservation->status !== ResourceReservation::STATUS_PENDING + || Carbon::parse( + $reservation->guaranteed_until ?? $reservation->expires_at + )->isFuture() + ) { + return false; + } + + if ($invoice?->status === Invoice::STATUS_PAID && $service !== null) { + $reason = 'A paid invoice was found after its capacity guarantee expired before fulfillment was committed.'; + $snapshot = [ + 'reservation_id' => (int) $reservation->id, + 'service_id' => (int) $service->id, + 'invoice_id' => (int) $invoice->id, + 'node_id' => (int) $reservation->node_id, + 'memory' => (int) $reservation->memory, + 'cpu' => (int) $reservation->cpu, + 'disk' => (int) $reservation->disk, + 'attempts' => (int) $reservation->provisioning_attempts, + 'error' => $reason, + ]; + $expiringReservations = $reservations->where( + 'status', + ResourceReservation::STATUS_PENDING + ); + foreach ($expiringReservations as $expiringReservation) { + DB::table('ptero_resource_reservations') + ->where('id', $expiringReservation->id) + ->update([ + 'status' => ResourceReservation::STATUS_EXPIRED, + 'last_provisioning_error' => $reason, + 'failure_alerted_at' => $expiringReservation->failure_alerted_at ?? now(), + 'admin_notes' => $reason, + 'updated_at' => now(), + ]); + $this->releaseAllocationClaims( + (int) $expiringReservation->id + ); + } + foreach ($services as $linkedService) { + if ($linkedService->status !== Service::STATUS_PENDING) { + continue; + } + $linkedReservation = $reservations->firstWhere( + 'service_id', + $linkedService->id + ); + if ($linkedReservation !== null) { + $this->releaseProductStockOnce( + $linkedReservation, + $linkedService + ); + } else { + app(ProductStockService::class)->release( + $linkedService + ); + } + $linkedService->status = Service::STATUS_PROVISIONING_FAILED; + $this->persistFulfillmentService($linkedService); + } + + if ($reservation->failure_alerted_at === null) { + DB::afterCommit(function () use ($snapshot, $reason) { + $alerts = app(AlertService::class); + if (method_exists($alerts, 'notifyShortfall')) { + $alerts->notifyShortfall( + $snapshot['service_id'], + $snapshot['invoice_id'], + $snapshot, + $reason + ); + } else { + $alerts->notifyProvisioningFailure($snapshot); + } + }); + } + + return true; + } + + if ( + $invoice?->status === Invoice::STATUS_PAID + || $service?->status === 'provisioning' + ) { + throw new \RuntimeException( + 'An expired hold cannot be reclaimed after payment or provisioning begins.' + ); + } + + $paymentAttention = $invoice !== null + && $invoice->status === Invoice::STATUS_PENDING + && app(CapacityInvoicePaymentService::class) + ->hasInFlightOrSucceededPayment($invoice); + + foreach ( + $reservations->where( + 'status', + ResourceReservation::STATUS_PENDING + ) as $expiringReservation + ) { + DB::table('ptero_resource_reservations') + ->where('id', $expiringReservation->id) + ->update([ + 'status' => ResourceReservation::STATUS_EXPIRED, + 'provisioning_started_at' => null, + 'provisioning_lease_id' => null, + 'updated_at' => now(), + ]); + $this->releaseAllocationClaims( + (int) $expiringReservation->id + ); + } + + if ($paymentAttention) { + app(CapacityInvoicePaymentService::class)->requireAttention( + $invoice, + 'The seven-day capacity guarantee expired after a partial or in-flight payment. Capacity was released; refund or account-credit review is required.' + ); + } elseif ($invoice !== null && $invoice->status === Invoice::STATUS_PENDING) { + app(CancelInvoiceService::class) + ->markCancelledAfterFulfillment($invoice); + } + foreach ($services as $linkedService) { + if ($linkedService->status !== Service::STATUS_PENDING) { + continue; + } + + $linkedService->status = $paymentAttention + ? Service::STATUS_PROVISIONING_FAILED + : Service::STATUS_CANCELLED; + $this->persistFulfillmentService($linkedService); + + $linkedReservation = $reservations->firstWhere( + 'service_id', + $linkedService->id + ); + if ($linkedReservation !== null) { + $this->releaseProductStockOnce( + $linkedReservation, + $linkedService + ); + } else { + app(ProductStockService::class)->release($linkedService); + } + } + + return true; + }, 5); + } + ); if ($count > 0) { $this->safeAudit('reservations_expired_batch', 'resource_reservation', 0, [ @@ -404,50 +2025,777 @@ public function cleanupExpired(): int return $count; } - private function presentReservation(object $reservation): array + /** + * Requeue durable paid commitments that are not currently leased. + * The extension schedule can call this independently of queue retry state. + */ + public function reconcileStalledPaidCommitments(int $limit = 100): int + { + $limit = max(1, min($limit, 500)); + $cutoff = now()->subMinutes(self::PROVISIONING_LEASE_MINUTES); + + return app(SchedulerHealthService::class)->processEligibleRows( + SchedulerHealthService::TASK_RECONCILE_CHECKOUT, + 'resource_reservation', + $limit, + fn (): Builder => DB::table('ptero_resource_reservations') + ->where( + 'status', + ResourceReservation::STATUS_PAID_COMMITTED + ) + ->whereNull('cancellation_requested_at') + ->where(function (Builder $query): void { + $query->whereNull('next_provisioning_attempt_at') + ->orWhere( + 'next_provisioning_attempt_at', + '<=', + now() + ); + }) + ->where(function (Builder $query) use ($cutoff): void { + $query->whereNull('provisioning_started_at') + ->orWhere('provisioning_started_at', '<=', $cutoff); + }), + function (int $reservationId) use ($cutoff): bool { + $service = DB::transaction( + function () use ($reservationId, $cutoff) { + $candidate = DB::table( + 'ptero_resource_reservations' + ) + ->where('id', $reservationId) + ->first(['service_id']); + if ($candidate?->service_id === null) { + return null; + } + + $service = Service::query() + ->whereKey($candidate->service_id) + ->lockForUpdate() + ->first(); + $reservation = DB::table( + 'ptero_resource_reservations' + ) + ->where('id', $reservationId) + ->lockForUpdate() + ->first(); + if ( + $service === null + || $service->status + !== Service::STATUS_PROVISIONING + || $reservation === null + || $reservation->status + !== ResourceReservation::STATUS_PAID_COMMITTED + || $reservation->cancellation_requested_at !== null + || ( + $reservation->next_provisioning_attempt_at + !== null + && Carbon::parse( + $reservation + ->next_provisioning_attempt_at + )->isFuture() + ) + || ( + $reservation->provisioning_started_at !== null + && Carbon::parse( + $reservation->provisioning_started_at + )->greaterThan($cutoff) + ) + ) { + return null; + } + + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'provisioning_started_at' => null, + 'provisioning_lease_id' => null, + 'next_provisioning_attempt_at' => now() + ->addMinutes(10), + 'updated_at' => now(), + ]); + + return $service; + }, + 5 + ); + + if ( + $service === null + || ! class_exists(ServiceJobDispatchService::class) + ) { + return false; + } + + app(ServiceJobDispatchService::class) + ->requestCreate($service); + + return true; + } + ); + } + + /** + * @param array $snapshot + */ + private function matchesSnapshot(object $reservation, array $snapshot): bool + { + $storedPayload = json_decode( + (string) $reservation->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + $payload = $this->configurationService->withPlacement( + $snapshot, + (int) $reservation->node_id, + (array) ($storedPayload['allocations'] ?? []) + ); + + return hash_equals( + (string) $reservation->configuration_fingerprint, + $this->configurationService->fingerprint($payload) + ); + } + + /** + * @return array{ + * reservation_id: int, + * panel_identity: string, + * node_id: int, + * location_id: int, + * memory: int, + * cpu: int, + * disk: int, + * provisioning_lease_id: string|null, + * already_consumed: bool, + * allocations: array>, + * nest_id: int, + * egg_id: int, + * user_external_id: string + * } + */ + private function provisioningContext( + object $reservation, + bool $alreadyConsumed, + ?string $leaseId = null + ): array { + try { + $payload = json_decode( + (string) $reservation->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (\JsonException $exception) { + throw new PermanentProvisioningException( + 'The capacity reservation provisioning identity is unreadable.', + previous: $exception + ); + } + $identity = is_array($payload) + ? (array) ($payload['provisioning_identity'] ?? []) + : []; + $nestId = StrictInteger::parse( + $identity['nest_id'] ?? null + ); + $eggId = StrictInteger::parse( + $identity['egg_id'] ?? null + ); + $userExternalId = $identity['user_external_id'] ?? null; + if ( + $nestId === null + || $nestId <= 0 + || $eggId === null + || $eggId <= 0 + || ! is_string($userExternalId) + || $userExternalId === '' + ) { + throw new PermanentProvisioningException( + 'The capacity reservation has no valid provisioning identity.' + ); + } + + $allocations = DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservation->id) + ->orderByDesc('is_primary') + ->orderBy('id') + ->get() + ->map(fn ($allocation) => [ + 'allocation_id' => (int) $allocation->allocation_id, + 'ip' => (string) ($allocation->ip ?? ''), + 'port' => (int) $allocation->port, + 'environment_key' => $allocation->environment_key, + 'is_primary' => (bool) $allocation->is_primary, + ]) + ->all(); + + return [ + 'reservation_id' => (int) $reservation->id, + 'panel_identity' => (string) $reservation->panel_identity, + 'node_id' => (int) $reservation->node_id, + 'location_id' => (int) $reservation->location_id, + 'memory' => (int) $reservation->memory, + 'cpu' => (int) $reservation->cpu, + 'disk' => (int) $reservation->disk, + 'provisioning_lease_id' => $leaseId, + 'already_consumed' => $alreadyConsumed, + 'allocations' => $allocations, + 'nest_id' => $nestId, + 'egg_id' => $eggId, + 'user_external_id' => $userExternalId, + ]; + } + + /** + * Apply the single authoritative predicate used by stock calculations. + */ + public function applyCapacityHoldingScope(Builder $query): Builder + { + return $query->where(function (Builder $query) { + $query->where( + 'status', + ResourceReservation::STATUS_PENDING + )->orWhere('status', ResourceReservation::STATUS_PAID_COMMITTED); + }); + } + + /** + * @param array> $available + * @param array> $requirements + * @return array> + */ + private function mapAllocationRequirements(array $available, array $requirements): array + { + $pool = array_values($available); + $mapped = []; + $requirements = $requirements !== [] ? array_values($requirements) : [[ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => null, + 'is_primary' => true, + ]]; + + // Claim explicit ports first. Otherwise an earlier wildcard (commonly + // SERVER_PORT) could consume an allocation needed by a later fixed + // egg-variable mapping even though the node passed the quote check. + $requirements = array_values(array_merge( + array_filter( + $requirements, + fn (array $requirement): bool => ($requirement['requested_port'] ?? null) !== null + ), + array_filter( + $requirements, + fn (array $requirement): bool => ($requirement['requested_port'] ?? null) === null + ) + )); + + foreach ($requirements as $requirement) { + $requestedPort = $requirement['requested_port'] ?? null; + $index = null; + if ($requestedPort !== null) { + foreach ($pool as $candidateIndex => $candidate) { + if ((int) ($candidate['port'] ?? 0) === (int) $requestedPort) { + $index = $candidateIndex; + break; + } + } + if ($index === null) { + throw new DisplayException( + "The selected node does not have requested port {$requestedPort} available." + ); + } + } + $index ??= array_key_first($pool); + if ($index === null) { + throw new DisplayException('The selected node no longer has enough free allocations.'); + } + + $allocation = $pool[$index]; + unset($pool[$index]); + $pool = array_values($pool); + $mapped[] = [ + 'allocation_id' => (int) ($allocation['allocation_id'] ?? $allocation['id'] ?? 0), + 'ip' => (string) ($allocation['ip'] ?? ''), + 'port' => (int) ($allocation['port'] ?? 0), + 'environment_key' => (string) ($requirement['environment_key'] ?? 'SERVER_PORT'), + 'is_primary' => (bool) ($requirement['is_primary'] ?? false), + ]; + } + + if (! collect($mapped)->contains('is_primary', true)) { + $mapped[0]['is_primary'] = true; + } + + foreach ($mapped as $allocation) { + if ($allocation['allocation_id'] <= 0 || $allocation['port'] <= 0) { + throw new DisplayException('Pterodactyl returned an invalid allocation.'); + } + } + + return $mapped; + } + + /** + * @param array $attributes + * @return array{ + * external_server_id: int, + * external_user_id: int, + * external_server_uuid: string, + * external_server_identifier: string + * } + */ + private function externalServerIdentity(array $attributes): array { - $expiresAt = $reservation->expires_at ? Carbon::parse($reservation->expires_at) : null; + $id = $attributes['id'] ?? null; + $userId = $attributes['user'] ?? null; + $uuid = $attributes['uuid'] ?? null; + $identifier = $attributes['identifier'] ?? null; + + if ( + ! is_numeric($id) + || (int) $id <= 0 + || StrictInteger::parse($userId) === null + || (int) $userId <= 0 + || ! is_string($uuid) + || ! Str::isUuid($uuid) + || ! is_string($identifier) + || trim($identifier) === '' + ) { + throw new \RuntimeException( + 'Pterodactyl did not return a complete external server identity.' + ); + } return [ - 'id' => $reservation->id, - 'token' => $reservation->token, - 'node_id' => $reservation->node_id, - 'node_name' => $reservation->node_name ?? null, - 'expires_at' => $expiresAt?->toIso8601String(), - 'ttl_minutes' => $expiresAt && $reservation->status === 'pending' - ? max(0, now()->diffInMinutes($expiresAt, false)) - : 0, - 'pricing' => [ - 'total' => (float) $reservation->calculated_price, - 'breakdown' => is_array($reservation->pricing_breakdown) - ? $reservation->pricing_breakdown - : json_decode($reservation->pricing_breakdown ?? '[]', true) ?? [], - 'model' => 'stored', - ], - 'status' => $reservation->status, + 'external_server_id' => (int) $id, + 'external_user_id' => (int) $userId, + 'external_server_uuid' => $uuid, + 'external_server_identifier' => $identifier, + ]; + } + + /** + * @return array + */ + private function buildCancellationReconciliationContext( + Service $service, + object $reservation, + bool $requireUnpinned + ): array { + if ( + $reservation->cancellation_requested_at === null + || $service->status !== Service::STATUS_CANCELLATION_PENDING + ) { + throw new PermanentProvisioningException( + 'The service has no durable cancellation request to reconcile.' + ); + } + if ( + $reservation->status + !== ResourceReservation::STATUS_PAID_COMMITTED + ) { + throw new PermanentProvisioningException( + 'Only an unconsumed paid commitment can use cancellation reconciliation.' + ); + } + + $storedIdentity = [ + $reservation->external_server_id, + $reservation->external_user_id, + $reservation->external_server_uuid, + $reservation->external_server_identifier, + ]; + if ( + $requireUnpinned + && collect($storedIdentity) + ->contains(fn ($value): bool => $value !== null) + ) { + throw new PermanentProvisioningException( + 'The cancellation already has a pinned Pterodactyl server identity.' + ); + } + + try { + $this->configurationService->assertServiceMatches( + $service, + $reservation + ); + $claims = DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservation->id) + ->lockForUpdate() + ->get(); + $payload = $this->configurationService + ->verifiedAllocationSnapshot($reservation, $claims); + } catch ( + InvalidStockConfigurationException|\RuntimeException $exception + ) { + throw new PermanentProvisioningException( + $exception->getMessage(), + previous: $exception + ); + } + + $identity = (array) ($payload['provisioning_identity'] ?? []); + $nestId = StrictInteger::parse($identity['nest_id'] ?? null); + $eggId = StrictInteger::parse($identity['egg_id'] ?? null); + $userExternalId = $identity['user_external_id'] ?? null; + if ( + $nestId === null + || $nestId <= 0 + || $eggId === null + || $eggId <= 0 + || ! is_string($userExternalId) + || $userExternalId === '' + ) { + throw new PermanentProvisioningException( + 'The cancellation checkout contract has no valid customer or image identity.' + ); + } + + $provisioningInFlight = + $reservation->provisioning_started_at !== null + && Carbon::parse($reservation->provisioning_started_at) + ->greaterThan( + now()->subMinutes(self::PROVISIONING_LEASE_MINUTES) + ); + + return [ + 'reservation_id' => (int) $reservation->id, + 'configuration_fingerprint' => (string) $reservation->configuration_fingerprint, + 'status' => (string) $reservation->status, + 'panel_identity' => (string) $reservation->panel_identity, + 'external_server_external_id' => (string) $service->id, + 'node_id' => (int) $reservation->node_id, + 'location_id' => (int) $reservation->location_id, + 'memory' => (int) $reservation->memory, + 'cpu' => (int) $reservation->cpu, + 'disk' => (int) $reservation->disk, + 'allocations' => array_values( + (array) ($payload['allocations'] ?? []) + ), + 'client_allocation_limit' => 0, + 'nest_id' => $nestId, + 'egg_id' => $eggId, + 'user_external_id' => $userExternalId, + 'user_email' => $identity['user_email'] ?? null, + 'provisioning_in_flight' => $provisioningInFlight, ]; } - private function expireStaleIdempotencyReservations(?int $userId, string $idempotencyKey): void + /** + * @param array $server + * @param array $context + */ + private function assertCancellationServerMatchesContext( + array $server, + array $context, + int $serviceId, + int $expectedExternalUserId + ): void { + $attributes = $server['attributes'] ?? $server; + if (! is_array($attributes)) { + throw new PermanentProvisioningException( + 'Pterodactyl returned an invalid cancellation server response.' + ); + } + $externalIdentity = $this->externalServerIdentity($attributes); + if ( + $expectedExternalUserId <= 0 + || $externalIdentity['external_user_id'] + !== $expectedExternalUserId + || ! is_string($attributes['external_id'] ?? null) + || ! hash_equals( + (string) $serviceId, + $attributes['external_id'] + ) + ) { + throw new PermanentProvisioningException( + 'The cancellation candidate does not belong to the reserved Paymenter customer and service.' + ); + } + + foreach ([ + 'node' => $context['node_id'], + 'nest' => $context['nest_id'], + 'egg' => $context['egg_id'], + ] as $field => $expected) { + if ( + StrictInteger::parse($attributes[$field] ?? null) === null + || (int) $attributes[$field] !== (int) $expected + ) { + throw new PermanentProvisioningException( + "The cancellation candidate does not match reserved {$field}." + ); + } + } + + $limits = (array) ($attributes['limits'] ?? []); + foreach (['memory', 'cpu', 'disk'] as $resource) { + if ( + StrictInteger::parse($limits[$resource] ?? null) === null + || (int) $limits[$resource] + !== (int) $context[$resource] + ) { + throw new PermanentProvisioningException( + "The cancellation candidate does not match reserved {$resource}." + ); + } + } + if ( + StrictInteger::parse( + data_get($attributes, 'feature_limits.allocations') + ) === null + || (int) data_get( + $attributes, + 'feature_limits.allocations' + ) !== 0 + ) { + throw new PermanentProvisioningException( + 'The cancellation candidate permits unreserved client allocation changes.' + ); + } + + $assigned = data_get( + $attributes, + 'relationships.allocations.data', + data_get($server, 'relationships.allocations.data') + ); + if (! is_array($assigned)) { + throw new PermanentProvisioningException( + 'The cancellation candidate has no verifiable allocation set.' + ); + } + $assignedIds = []; + foreach ($assigned as $allocation) { + $allocationId = is_array($allocation) + ? StrictInteger::parse( + data_get($allocation, 'attributes.id') + ?? ($allocation['id'] ?? null) + ) + : null; + if ($allocationId === null || $allocationId <= 0) { + throw new PermanentProvisioningException( + 'The cancellation candidate has an invalid assigned allocation.' + ); + } + $assignedIds[] = $allocationId; + } + sort($assignedIds); + if (count(array_unique($assignedIds)) !== count($assignedIds)) { + throw new PermanentProvisioningException( + 'The cancellation candidate has duplicate assigned allocations.' + ); + } + + $reserved = collect($context['allocations'] ?? []); + $reservedIds = $reserved + ->map(fn (array $allocation): int => (int) ($allocation['allocation_id'] ?? 0)) + ->sort() + ->values() + ->all(); + $primaryIds = $reserved + ->filter(fn (array $allocation): bool => (bool) ($allocation['is_primary'] ?? false)) + ->map(fn (array $allocation): int => (int) ($allocation['allocation_id'] ?? 0)) + ->values(); + if ( + $reservedIds === [] + || count(array_unique($reservedIds)) !== count($reservedIds) + || $primaryIds->count() !== 1 + || StrictInteger::parse($attributes['allocation'] ?? null) + !== (int) $primaryIds->first() + || $assignedIds !== $reservedIds + ) { + throw new PermanentProvisioningException( + 'The cancellation candidate allocation set does not exactly match the reservation.' + ); + } + } + + /** + * @param array{panel_identity: string, location_id: int} $scope + */ + private function capacityScopeKey(array $scope): string { - DB::table('ptero_resource_reservations') - ->when($userId === null, fn ($query) => $query->whereNull('user_id'), fn ($query) => $query->where('user_id', $userId)) - ->where('idempotency_key', $idempotencyKey) - ->where('status', 'pending') - ->where('expires_at', '<=', now()) + return $scope['panel_identity'].':'.$scope['location_id']; + } + + private function checkoutCommitmentQuery(int $serviceId): Builder + { + return DB::table('ptero_resource_reservations') + ->where('service_id', $serviceId) + ->where('purpose', 'checkout') + ->whereIn('status', [ + ResourceReservation::STATUS_PENDING, + ResourceReservation::STATUS_PAID_COMMITTED, + ResourceReservation::STATUS_CONFIRMED, + ]); + } + + private function assertInvoiceLineMatchesReservation( + Service $service, + Invoice $invoice, + object $reservation + ): void { + $lockedInvoice = Invoice::query() + ->whereKey($invoice->id) + ->lockForUpdate() + ->firstOrFail(); + $lines = $lockedInvoice->items() + ->where('reference_type', Service::class) + ->where('reference_id', $service->id) + ->orderBy('id') + ->lockForUpdate() + ->get(); + + if ($lines->count() !== 1) { + throw new \RuntimeException( + 'A dynamic capacity commitment requires exactly one immutable invoice line for its service.' + ); + } + + $line = $lines->first(); + $lineQuantity = (int) $line->quantity; + $lineUnitCents = $this->moneyCents($line->price); + $reservedAmountCents = $this->moneyCents( + $reservation->calculated_price + ); + $lineAmountCents = $lineUnitCents !== null + && $lineQuantity > 0 + && $lineUnitCents <= intdiv(PHP_INT_MAX, $lineQuantity) + ? $lineUnitCents * $lineQuantity + : null; + + if ( + $lineAmountCents === null + || $reservedAmountCents === null + || $lineQuantity !== (int) $reservation->quantity + || strtoupper((string) $lockedInvoice->currency_code) + !== strtoupper((string) $reservation->currency_code) + || $lineAmountCents !== $reservedAmountCents + ) { + throw new \RuntimeException( + 'The invoice line no longer matches the reserved quantity, currency, or pre-tax checkout price.' + ); + } + } + + private function moneyCents(mixed $value): ?int + { + if (is_float($value)) { + if ( + ! is_finite($value) + || $value < 0 + || abs($value - round($value, 2)) > 1e-8 + ) { + return null; + } + $value = number_format($value, 2, '.', ''); + } + + $text = is_int($value) + ? (string) $value + : (is_string($value) ? $value : ''); + if ( + preg_match('/^(0|[1-9]\d*)(?:\.(\d+))?$/D', $text, $matches) + !== 1 + ) { + return null; + } + + $whole = StrictInteger::parse($matches[1]); + $fraction = $matches[2] ?? ''; + if ( + $whole === null + || (strlen($fraction) > 2 + && trim(substr($fraction, 2), '0') !== '') + || $whole > intdiv(PHP_INT_MAX - 99, 100) + ) { + return null; + } + + return $whole * 100 + + (int) str_pad(substr($fraction, 0, 2), 2, '0'); + } + + private function moneyFromCents(int $cents): string + { + if ($cents < 0) { + throw new \RuntimeException( + 'Reservation revenue cannot be negative.' + ); + } + + return intdiv($cents, 100) + .'.' + .str_pad((string) ($cents % 100), 2, '0', STR_PAD_LEFT); + } + + /** + * Prove that the allocation claim rows are an exact materialization of the + * fingerprinted payload before any external request can use them. + */ + private function assertAllocationClaimsMatch(object $reservation): void + { + $claims = DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservation->id) + ->lockForUpdate() + ->get(); + + try { + $this->configurationService->verifiedAllocationSnapshot( + $reservation, + $claims + ); + } catch (InvalidStockConfigurationException $exception) { + throw new PermanentProvisioningException( + $exception->getMessage(), + previous: $exception + ); + } + } + + private function persistFulfillmentService(Service $service): void + { + FulfillmentStatusTransitionService::run( + $service, + fn () => $service->save() + ); + } + + protected function releaseAllocationClaims(int $reservationId): void + { + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->whereNull('released_at') ->update([ - 'status' => 'expired', + 'released_at' => now(), 'updated_at' => now(), ]); } - private function isActiveIdempotencyDuplicate(QueryException $exception, ?int $userId, ?string $idempotencyKey): bool - { - if ($userId === null || $idempotencyKey === null) { + /** + * Return Paymenter's product-level unit exactly once. + * + * Pending orders are released when their hold is cancelled or expires. + * Paid/confirmed services reach this method only after the provisioner has + * proved that the external server is absent. + */ + private function releaseProductStockOnce( + object $reservation, + Service $service + ): bool { + $claimed = DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->whereNull('product_stock_released_at') + ->update([ + 'product_stock_released_at' => now(), + 'updated_at' => now(), + ]); + if ($claimed !== 1) { return false; } - return str_contains($exception->getMessage(), 'ptero_reservations_active_idempotency_unique') - || ($exception->getCode() === '23000' && str_contains($exception->getMessage(), 'Duplicate entry')); + app(ProductStockService::class)->release($service); + + return true; } } diff --git a/Services/ResourceCalculationService.php b/Services/ResourceCalculationService.php index 696bf4d..58fd077 100644 --- a/Services/ResourceCalculationService.php +++ b/Services/ResourceCalculationService.php @@ -2,77 +2,66 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Services; -use App\Models\Extension; -use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Http; +use Paymenter\Extensions\Others\DynamicPterodactyl\Exceptions\InvalidStockConfigurationException; +use Paymenter\Extensions\Others\DynamicPterodactyl\Models\NodeCapacityPolicy; +use Paymenter\Extensions\Others\DynamicPterodactyl\Models\ResourceReservation; class ResourceCalculationService { - private string $apiUrl; - - private string $apiKey; - - public function __construct() - { - $config = $this->getExtensionConfig(); - $this->apiUrl = rtrim($config['pterodactyl_url'] ?? '', '/'); - $this->apiKey = $config['pterodactyl_api_key'] ?? ''; + private ?PterodactylInventoryService $inventory; + + private ReservationConfigurationService $configuration; + + private UpgradeReservationIntegrityService $upgradeIntegrity; + + public function __construct( + ?PterodactylInventoryService $inventory = null, + ?ReservationConfigurationService $configuration = null, + ?UpgradeReservationIntegrityService $upgradeIntegrity = null + ) { + $this->inventory = $inventory; + $this->configuration = $configuration + ?? new ReservationConfigurationService; + $this->upgradeIntegrity = $upgradeIntegrity + ?? new UpgradeReservationIntegrityService; } /** - * Get available resources for a location (real-time from Pterodactyl API) + * Build current stock for every node in a location. + * + * Memory and disk allocation use the greater of NodeTransformer's + * allocated_resources and the complete server index. This is deliberately + * conservative while Pterodactyl's independently-read node and server + * snapshots converge after a create or update. CPU comes from the server + * index and a local, explicit per-node capacity policy. Any missing or + * ambiguous inventory makes that node ineligible rather than optimistic. + * + * @return array */ - public function getLocationAvailability(int $locationId, ?string $excludeReservationToken = null): array - { - $nodes = $this->fetchNodesInLocation($locationId); - - $locationData = [ - 'location_id' => $locationId, - 'nodes' => [], - 'max_available' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - 'total_capacity' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - 'total_allocated' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - ]; - - foreach ($nodes as $node) { - $nodeAvailability = $this->calculateNodeAvailability($node, $excludeReservationToken); - $locationData['nodes'][] = $nodeAvailability; - - // Track maximum available across all nodes - $locationData['max_available']['memory'] = max( - $locationData['max_available']['memory'], - $nodeAvailability['available']['memory'] - ); - $locationData['max_available']['cpu'] = max( - $locationData['max_available']['cpu'], - $nodeAvailability['available']['cpu'] - ); - $locationData['max_available']['disk'] = max( - $locationData['max_available']['disk'], - $nodeAvailability['available']['disk'] - ); - - // Aggregate totals - $locationData['total_capacity']['memory'] += $nodeAvailability['total']['memory']; - $locationData['total_capacity']['cpu'] += $nodeAvailability['total']['cpu']; - $locationData['total_capacity']['disk'] += $nodeAvailability['total']['disk']; - - $locationData['total_allocated']['memory'] += $nodeAvailability['allocated']['memory']; - $locationData['total_allocated']['cpu'] += $nodeAvailability['allocated']['cpu']; - $locationData['total_allocated']['disk'] += $nodeAvailability['allocated']['disk']; - } - - return $locationData; + public function getLocationAvailability( + int $locationId, + ?string $excludeReservationToken = null + ): array { + $nodes = $this->inventory()->nodesInLocation($locationId); + + return $this->buildLocationAvailability( + $locationId, + $nodes, + $excludeReservationToken + ); } + /** + * @return array + */ public function buildClusterSnapshot(): array { $snapshot = $this->emptyClusterSnapshot(); try { - $locations = $this->fetchAllLocations(); - $nodes = $this->fetchClusterNodes(); + $locations = $this->inventory()->locations(); + $nodes = $this->inventory()->nodes(); } catch (\RuntimeException $exception) { if ($this->shouldReturnDegradedSnapshot($exception)) { return $this->degradedClusterSnapshot(); @@ -82,463 +71,1154 @@ public function buildClusterSnapshot(): array } $snapshot['locations'] = $locations; + $nodesByLocation = collect($nodes)->groupBy('location_id'); foreach ($locations as $location) { - $snapshot['by_location'][$location['id']] = [ - 'nodes' => [], - 'totals' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - 'allocated' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - 'available' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - ]; - } - - $pendingReservations = $this->getPendingReservationsForNodes(array_keys($nodes)); - - foreach ($nodes as $nodeId => $nodeData) { - $node = $nodeData['node']; - $locationId = $nodeData['location_id']; - $availability = $this->buildNodeAvailabilityFromServers( - $node, - $nodeData['servers'], - $pendingReservations[$nodeId] ?? ['memory' => 0, 'cpu' => 0, 'disk' => 0], + $locationId = (int) $location['id']; + $locationAvailability = $this->buildLocationAvailability( + $locationId, + $nodesByLocation->get($locationId, collect())->values()->all() ); - $snapshot['nodes'][$nodeId] = [ - 'node' => $node, - 'location_id' => $locationId, - 'servers' => $nodeData['servers'], - 'totals' => $availability['total'], - 'allocated' => $availability['allocated'], - 'available' => $availability['available'], - 'reserved' => $availability['reserved'], - 'server_count' => $availability['server_count'], - 'utilization' => $availability['utilization'], - 'node_availability' => $availability, + $snapshot['by_location'][$locationId] = [ + 'nodes' => array_column($locationAvailability['nodes'], 'node_id'), + 'totals' => $locationAvailability['total_capacity'], + 'allocated' => $locationAvailability['total_allocated'], + 'available' => $locationAvailability['total_available'], ]; - if (! array_key_exists($locationId, $snapshot['by_location'])) { - $snapshot['by_location'][$locationId] = [ - 'nodes' => [], - 'totals' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - 'allocated' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], - 'available' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], + foreach ($locationAvailability['nodes'] as $node) { + $snapshot['nodes'][$node['node_id']] = [ + 'node' => [ + 'id' => $node['node_id'], + 'uuid' => $node['node_uuid'], + 'name' => $node['name'], + 'fqdn' => $node['fqdn'], + 'public' => $node['public'], + 'maintenance_mode' => $node['maintenance_mode'], + 'location_id' => $locationId, + ], + 'location_id' => $locationId, + 'servers' => $node['servers'], + 'totals' => $node['total'], + 'allocated' => $node['allocated'], + 'available' => $node['available'], + 'reserved' => $node['reserved'], + 'server_count' => $node['server_count'], + 'utilization' => $node['utilization'], + 'node_availability' => $node, ]; } - - $snapshot['by_location'][$locationId]['nodes'][] = $nodeId; - $snapshot['by_location'][$locationId]['totals']['memory'] += $availability['total']['memory']; - $snapshot['by_location'][$locationId]['totals']['cpu'] += $availability['total']['cpu']; - $snapshot['by_location'][$locationId]['totals']['disk'] += $availability['total']['disk']; - $snapshot['by_location'][$locationId]['allocated']['memory'] += $availability['allocated']['memory']; - $snapshot['by_location'][$locationId]['allocated']['cpu'] += $availability['allocated']['cpu']; - $snapshot['by_location'][$locationId]['allocated']['disk'] += $availability['allocated']['disk']; - $snapshot['by_location'][$locationId]['available']['memory'] += $availability['available']['memory']; - $snapshot['by_location'][$locationId]['available']['cpu'] += $availability['available']['cpu']; - $snapshot['by_location'][$locationId]['available']['disk'] += $availability['available']['disk']; } return $snapshot; } /** - * Calculate available resources for a specific node + * Verify the complete resource vector against one currently eligible node. + * + * @param array{memory: int, cpu: int, disk: int} $requirements */ - private function calculateNodeAvailability(array $node, ?string $excludeReservationToken = null): array - { - $servers = $this->fetchServersOnNode($node['id']); - $pendingReservations = $this->getPendingReservations($node['id'], $excludeReservationToken); - - return $this->buildNodeAvailabilityFromServers($node, $servers, $pendingReservations); + public function verifyAvailability( + int $nodeId, + array $requirements, + ?string $excludeReservationToken = null + ): bool { + return $this->verifyNodeCapacity( + $nodeId, + $requirements, + 1, + $excludeReservationToken + ); } /** - * Test API connection + * Verify a capacity vector on a fixed node. Existing-server upgrades call + * this with allocationCount=0 because their primary port remains assigned. + * + * @param array{memory: int, cpu: int, disk: int} $requirements */ - // Does not use pterodactylGet() — admin-initiated diagnostic needs longer timeout and different error surfaces. - public function testConnection(): array - { - try { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$this->apiKey, - 'Accept' => 'application/json', - ])->timeout(10)->get("{$this->apiUrl}/api/application/nodes"); - - if ($response->successful()) { - $data = $response->json(); - - if (! is_array($data) || ! is_array($data['data'] ?? null)) { - return [ - 'success' => false, - 'message' => 'Connection succeeded but response body was not a valid Pterodactyl nodes payload.', - ]; - } + public function verifyNodeCapacity( + int $nodeId, + array $requirements, + int $allocationCount = 1, + ?string $excludeReservationToken = null + ): bool { + if ($allocationCount < 0) { + throw new \InvalidArgumentException('Allocation count cannot be negative.'); + } - return [ - 'success' => true, - 'message' => 'Connection successful', - 'node_count' => count($data['data']), - 'panel_version' => $response->header('X-Pterodactyl-Version'), - ]; + foreach (['memory', 'cpu', 'disk'] as $resource) { + if ( + ! array_key_exists($resource, $requirements) + || ! is_int($requirements[$resource]) + || $requirements[$resource] < 0 + ) { + throw new \InvalidArgumentException( + 'Resource requirements must be non-negative integers.' + ); } + } - return [ - 'success' => false, - 'message' => 'API returned error: '.$response->status(), - 'details' => $response->json('errors', []), - ]; - } catch (\Exception $e) { - return [ - 'success' => false, - 'message' => 'Connection failed: '.$e->getMessage(), - ]; + $availability = $this->getNodeAvailability( + $nodeId, + $excludeReservationToken + ); + if ($availability === null) { + return false; } + + $blockingReasons = array_values(array_filter( + $availability['ineligible_reasons'], + fn (string $reason): bool => $allocationCount > 0 + || $reason !== 'no_available_allocation' + )); + + return $blockingReasons === [] + && $availability['available']['memory'] >= $requirements['memory'] + && $availability['available']['cpu'] >= $requirements['cpu'] + && $availability['available']['disk'] >= $requirements['disk'] + && count($availability['available_allocations']) >= $allocationCount; } /** - * Verify resources are still available (called at payment time) + * Internal stock detail for fixed-node fulfillment and upgrade decisions. + * + * @return array|null */ - public function verifyAvailability(int $nodeId, array $requirements, ?string $excludeReservationToken = null): bool - { - $nodes = $this->fetchNodesInLocation($this->getNodeLocation($nodeId)); - $node = \collect($nodes)->firstWhere('id', $nodeId); - - if (! $node) { - return false; + public function getNodeAvailability( + int $nodeId, + ?string $excludeReservationToken = null + ): ?array { + $node = collect($this->inventory()->nodes())->firstWhere('id', $nodeId); + if (! is_array($node)) { + return null; } - $availability = $this->calculateNodeAvailability($node, $excludeReservationToken); + $location = $this->buildLocationAvailability( + (int) $node['location_id'], + [$node], + $excludeReservationToken + ); - return $availability['available']['memory'] >= $requirements['memory'] - && $availability['available']['cpu'] >= $requirements['cpu'] - && $availability['available']['disk'] >= $requirements['disk']; + return $location['nodes'][0] ?? null; } /** - * Get all locations from Pterodactyl + * @return list */ public function getLocations(): array { - return $this->fetchAllLocations(); + return $this->inventory()->locations(); } - // --- Private Methods --- - - private function fetchNodesInLocation(int $locationId): array + public function testConnection(): array { - $data = $this->pterodactylGet('/api/application/nodes', [ - 'filter[location_id]' => $locationId, - 'per_page' => 100, - ]); - - return \collect($data['data'] ?? []) - ->map(fn ($node) => $node['attributes']) - ->toArray(); + return $this->inventory()->testConnection(); } - private function fetchServersOnNode(int $nodeId): array - { - $data = $this->pterodactylGet("/api/application/nodes/{$nodeId}", ['include' => 'servers']); + /** + * @param list> $nodes + * @return array + */ + private function buildLocationAvailability( + int $locationId, + array $nodes, + ?string $excludeReservationToken = null + ): array { + $nodeIds = array_map(fn (array $node): int => (int) $node['id'], $nodes); + $servers = $this->inventory()->serversForNodes($nodeIds); + $reservations = $this->holdingReservations( + $nodeIds, + $servers, + $excludeReservationToken + ); + $reservedAllocationClaims = $this->reservedAllocationClaims( + $nodeIds, + $excludeReservationToken + ); + $policies = $this->cpuPolicies($nodes); + + $location = [ + 'location_id' => $locationId, + 'nodes' => [], + 'max_available' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], + 'total_capacity' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], + 'total_allocated' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], + 'total_available' => ['memory' => 0, 'cpu' => 0, 'disk' => 0], + 'cpu_capacity_enforced' => true, + ]; - return \collect($this->extractRelationshipData($data, 'servers')) - ->map(fn ($server) => $server['attributes']) - ->toArray(); - } + foreach ($nodes as $node) { + $nodeId = (int) $node['id']; + $nodeServers = $servers[$nodeId] ?? []; + $assignedServerAllocationIds = collect($nodeServers) + ->flatMap( + fn (array $server): array => $server['assigned_allocation_ids'] ?? [] + ) + ->map(fn ($id): int => (int) $id) + ->unique() + ->values() + ->all(); + $inventoryAllocations = + $this->inventory()->availableAllocationsForNode($nodeId); + $assignedServerIps = collect($inventoryAllocations) + ->filter( + fn (array $allocation): bool => in_array( + (int) $allocation['id'], + $assignedServerAllocationIds, + true + ) + ) + ->map( + fn (array $allocation): string => $this->canonicalIp((string) $allocation['ip']) + ) + ->unique() + ->values() + ->all(); + $nodeClaims = $reservedAllocationClaims[$nodeId] ?? [ + 'ids' => [], + 'ips' => [], + 'blocked_ips' => [], + ]; + $blockedAllocationIds = [ + ...$nodeClaims['ids'], + ...$assignedServerAllocationIds, + ]; + $availableAllocations = array_values(array_map( + fn (array $allocation): array => [ + ...$allocation, + 'ip_in_use' => (bool) ( + $allocation['ip_in_use'] ?? false + ) || in_array( + $this->canonicalIp((string) $allocation['ip']), + $nodeClaims['ips'], + true + ) || in_array( + $this->canonicalIp((string) $allocation['ip']), + $assignedServerIps, + true + ), + ], + array_filter( + $inventoryAllocations, + fn (array $allocation): bool => ! in_array( + (int) $allocation['id'], + $blockedAllocationIds, + true + ) + && ! in_array( + $this->canonicalIp((string) $allocation['ip']), + $nodeClaims['blocked_ips'], + true + ) + ) + )); + $nodeAvailability = $this->buildNodeAvailability( + $node, + $nodeServers, + $reservations[$nodeId] ?? $this->emptyResources(), + $availableAllocations, + $policies[(string) $node['uuid']] ?? null + ); - private function buildNodeAvailabilityFromServers(array $node, array $servers, array $pendingReservations): array - { - $allocated = ['memory' => 0, 'cpu' => 0, 'disk' => 0]; - foreach ($servers as $server) { - $allocated['memory'] += $server['limits']['memory'] ?? 0; - $allocated['cpu'] += $server['limits']['cpu'] ?? 0; - $allocated['disk'] += $server['limits']['disk'] ?? 0; + $location['nodes'][] = $nodeAvailability; + + foreach (['memory', 'cpu', 'disk'] as $resource) { + $location['total_capacity'][$resource] += $nodeAvailability['total'][$resource]; + $location['total_allocated'][$resource] += $nodeAvailability['allocated'][$resource]; + $location['total_available'][$resource] += $nodeAvailability['available'][$resource]; + + if ($nodeAvailability['eligible']) { + $location['max_available'][$resource] = max( + $location['max_available'][$resource], + $nodeAvailability['available'][$resource] + ); + } + } + + if (! $nodeAvailability['cpu_capacity_enforced']) { + $location['cpu_capacity_enforced'] = false; + } } - $effectiveMemory = $node['memory'] * (1 + ($node['memory_overallocate'] ?? 0) / 100); - $effectiveDisk = $node['disk'] * (1 + ($node['disk_overallocate'] ?? 0) / 100); - $effectiveCpu = ($node['cpu_threads'] ?? 4) * 100; + return $location; + } + /** + * @param list, + * allocation_headroom: int + * }> $servers + * @param array{memory: int, cpu: int, disk: int} $reserved + * @param list $availableAllocations + * @return array + */ + private function buildNodeAvailability( + array $node, + array $servers, + array $reserved, + array $availableAllocations, + ?NodeCapacityPolicy $cpuPolicy + ): array { + $memoryOverallocate = (int) $node['memory_overallocate']; + $diskOverallocate = (int) $node['disk_overallocate']; + $totalMemory = $this->effectiveCapacity( + (int) $node['memory'], + $memoryOverallocate + ); + $totalDisk = $this->effectiveCapacity( + (int) $node['disk'], + $diskOverallocate + ); + $cpuPolicyIdentityMatches = $cpuPolicy !== null + && (int) $cpuPolicy->node_id === (int) $node['id'] + && (int) $cpuPolicy->location_id + === (int) $node['location_id']; + $totalCpu = $cpuPolicyIdentityMatches + ? $cpuPolicy->effectiveCpuCapacity() + : 0; + $serverAllocatedMemory = $this->sumServerResource($servers, 'memory'); + $allocatedCpu = $this->sumServerResource($servers, 'cpu'); + $serverAllocatedDisk = $this->sumServerResource($servers, 'disk'); + + $allocated = [ + 'memory' => max( + (int) $node['allocated_resources']['memory'], + $serverAllocatedMemory + ), + 'cpu' => $allocatedCpu, + 'disk' => max( + (int) $node['allocated_resources']['disk'], + $serverAllocatedDisk + ), + ]; $available = [ - 'memory' => max(0, (int) $effectiveMemory - $allocated['memory'] - $pendingReservations['memory']), - 'cpu' => max(0, (int) $effectiveCpu - $allocated['cpu'] - $pendingReservations['cpu']), - 'disk' => max(0, (int) $effectiveDisk - $allocated['disk'] - $pendingReservations['disk']), + 'memory' => max(0, $totalMemory - $allocated['memory'] - $reserved['memory']), + 'cpu' => max(0, $totalCpu - $allocated['cpu'] - $reserved['cpu']), + 'disk' => max(0, $totalDisk - $allocated['disk'] - $reserved['disk']), ]; + $reasons = []; + if ($node['public'] !== true) { + $reasons[] = 'private_node'; + } + if ($node['maintenance_mode'] === true) { + $reasons[] = 'maintenance_mode'; + } + if ($memoryOverallocate < 0) { + // Pterodactyl uses -1 to disable its memory allocation limit. + // An unbounded panel cannot provide authoritative finite stock. + $reasons[] = 'unbounded_memory_overallocation'; + } + if ($diskOverallocate < 0) { + // Pterodactyl uses -1 to disable its disk allocation limit. + // An unbounded panel cannot provide authoritative finite stock. + $reasons[] = 'unbounded_disk_overallocation'; + } + if ($cpuPolicy !== null && ! $cpuPolicyIdentityMatches) { + $reasons[] = 'cpu_policy_identity_mismatch'; + } elseif ( + $cpuPolicy === null + || ! $cpuPolicy->enabled + || $totalCpu <= 0 + ) { + $reasons[] = 'cpu_policy_missing'; + } + if (collect($servers)->contains( + fn (array $server): bool => (int) $server['memory'] <= 0 + || (int) $server['cpu'] <= 0 + || (int) $server['disk'] <= 0 + )) { + $reasons[] = 'unlimited_existing_resource'; + } + if (collect($servers)->contains( + fn (array $server): bool => (int) ( + $server['allocation_limit'] ?? 0 + ) > 0 + )) { + // Exact port guarantees require Paymenter to be the only allocation + // authority. Any nonzero client allocation limit enables the + // customer-managed allocation workflow, even if the currently + // assigned set has no immediate headroom. + $reasons[] = 'customer_allocation_management'; + } + if ($availableAllocations === []) { + $reasons[] = 'no_available_allocation'; + } + return [ - 'node_id' => $node['id'], - 'name' => $node['name'], - 'fqdn' => $node['fqdn'], - 'maintenance_mode' => $node['maintenance_mode'] ?? false, + 'node_id' => (int) $node['id'], + 'node_uuid' => (string) $node['uuid'], + 'name' => (string) $node['name'], + 'fqdn' => (string) $node['fqdn'], + 'public' => (bool) $node['public'], + 'maintenance_mode' => (bool) $node['maintenance_mode'], + 'eligible' => $reasons === [], + 'ineligible_reasons' => $reasons, 'total' => [ - 'memory' => (int) $effectiveMemory, - 'cpu' => (int) $effectiveCpu, - 'disk' => (int) $effectiveDisk, + 'memory' => $totalMemory, + 'cpu' => $totalCpu, + 'disk' => $totalDisk, ], 'allocated' => $allocated, - 'reserved' => $pendingReservations, + 'reserved' => $reserved, 'available' => $available, + 'available_allocations' => $availableAllocations, 'server_count' => count($servers), + 'servers' => $servers, + 'cpu_capacity_enforced' => $cpuPolicy !== null + && $cpuPolicyIdentityMatches + && $cpuPolicy->enabled + && $totalCpu > 0 + && ! collect($servers)->contains( + fn (array $server): bool => (int) $server['cpu'] <= 0 + ), 'utilization' => [ - 'memory' => $effectiveMemory > 0 - ? round(($allocated['memory'] + $pendingReservations['memory']) / $effectiveMemory * 100, 1) - : 100, - 'disk' => $effectiveDisk > 0 - ? round(($allocated['disk'] + $pendingReservations['disk']) / $effectiveDisk * 100, 1) - : 100, + 'memory' => $this->utilization( + $allocated['memory'] + $reserved['memory'], + $totalMemory + ), + 'cpu' => $this->utilization( + $allocated['cpu'] + $reserved['cpu'], + $totalCpu + ), + 'disk' => $this->utilization( + $allocated['disk'] + $reserved['disk'], + $totalDisk + ), ], ]; } - private function fetchAllLocations(): array + private function effectiveCapacity(int $physical, int $overallocatePercent): int { - return \collect($this->pterodactylGetPaginatedData('/api/application/locations', ['per_page' => 100])) - ->map(fn ($location) => [ - 'id' => $location['attributes']['id'], - 'short' => $location['attributes']['short'], - 'long' => $location['attributes']['long'], - ]) - ->toArray(); - } + if ($overallocatePercent < 0) { + // Keep diagnostics finite while the ineligibility reason above + // prevents this node from participating in quotes or placement. + return max(0, $physical); + } - private function fetchClusterNodes(): array - { - try { - return $this->fetchClusterNodesWithIncludedServers(); - } catch (\RuntimeException $exception) { - if ($this->shouldReturnDegradedSnapshot($exception)) { - throw $exception; - } + if ($overallocatePercent > PHP_INT_MAX - 100) { + throw new \RuntimeException('Pterodactyl overallocation value is outside the supported range.'); + } - return $this->fetchClusterNodesFromServerIndex(); + $factor = max(0, 100 + $overallocatePercent); + if ($factor > 0 && $physical > intdiv(PHP_INT_MAX, $factor)) { + throw new \RuntimeException('Pterodactyl effective capacity exceeds the supported range.'); } + + return max(0, intdiv($physical * $factor, 100)); } - private function fetchClusterNodesWithIncludedServers(): array + private function utilization(int $used, int $total): float { - return \collect($this->pterodactylGetPaginatedData('/api/application/nodes', [ - 'include' => 'servers', - 'per_page' => 100, - ]))->mapWithKeys(function ($node) { - $attributes = $node['attributes'] ?? []; - - return [ - $attributes['id'] => [ - 'node' => $attributes, - 'location_id' => $attributes['location_id'], - 'servers' => \collect($this->extractRelationshipData($node, 'servers')) - ->map(fn ($server) => $server['attributes']) - ->toArray(), - ], - ]; - })->toArray(); + return $total > 0 ? round($used / $total * 100, 1) : 100.0; } - private function fetchClusterNodesFromServerIndex(): array + /** + * @param list> $servers + */ + private function sumServerResource(array $servers, string $resource): int { - $nodes = \collect($this->pterodactylGetPaginatedData('/api/application/nodes', ['per_page' => 100])) - ->mapWithKeys(fn ($node) => [ - $node['attributes']['id'] => [ - 'node' => $node['attributes'], - 'location_id' => $node['attributes']['location_id'], - 'servers' => [], - ], - ]) - ->toArray(); + $sum = 0; - foreach ($this->pterodactylGetPaginatedData('/api/application/servers', ['per_page' => 100]) as $server) { - $attributes = $server['attributes'] ?? []; - $nodeId = $attributes['node'] ?? null; - - if ($nodeId !== null && array_key_exists($nodeId, $nodes)) { - $nodes[$nodeId]['servers'][] = $attributes; + foreach ($servers as $server) { + $value = (int) ($server[$resource] ?? 0); + if ($value < 0 || $value > PHP_INT_MAX - $sum) { + throw new \RuntimeException( + "Pterodactyl {$resource} allocation exceeds the supported range." + ); } + $sum += $value; } - return $nodes; + return $sum; } - private function pterodactylGetPaginatedData(string $path, array $query = []): array + /** + * @param list> $nodes + * @return array + */ + private function cpuPolicies(array $nodes): array { - $page = 1; - $data = []; + $uuids = array_values(array_map( + fn (array $node): string => (string) $node['uuid'], + $nodes + )); - while (true) { - $payload = $this->pterodactylGet($path, array_merge($query, ['page' => $page])); - $data = array_merge($data, $payload['data'] ?? []); + if ($uuids === []) { + return []; + } - $pagination = $payload['meta']['pagination'] ?? null; - if (! is_array($pagination)) { - break; - } + return NodeCapacityPolicy::query() + ->forPanel($this->inventory()->panelIdentity()) + ->whereIn('node_uuid', $uuids) + ->get() + ->keyBy('node_uuid') + ->all(); + } + + /** + * @param list $nodeIds + * Confirmed commitments remain in the local overlay until the independently + * read server snapshot proves the exact target vector. This closes the + * handoff window where the local row is consumed before every Pterodactyl + * inventory endpoint reflects the create or update. + * @param array>> $servers + * @return array + */ + private function holdingReservations( + array $nodeIds, + array $servers, + ?string $excludeReservationToken + ): array { + if ($nodeIds === []) { + return []; + } + + $query = DB::table('ptero_resource_reservations as reservations') + ->leftJoin( + 'services', + 'services.id', + '=', + 'reservations.service_id' + ) + ->where( + 'reservations.panel_identity', + $this->inventory()->panelIdentity() + ) + ->whereIn('reservations.node_id', $nodeIds) + ->where(function ($query): void { + // A pending row keeps holding stock until cleanup atomically + // changes its status and releases its allocation claims. + $query->where( + 'reservations.status', + ResourceReservation::STATUS_PENDING + )->orWhere( + 'reservations.status', + ResourceReservation::STATUS_PAID_COMMITTED + )->orWhere(function ($query): void { + $query->where( + 'reservations.status', + ResourceReservation::STATUS_CONFIRMED + )->where(function ($query): void { + $query->whereNull('services.status') + ->orWhere('services.status', '!=', 'cancelled'); + }); + }); + }); + + if ($excludeReservationToken !== null) { + $query->where( + 'reservations.token', + '!=', + $excludeReservationToken + ); + } - $currentPage = (int) ($pagination['current_page'] ?? $page); - $totalPages = (int) ($pagination['total_pages'] ?? $currentPage); + $rows = $query->get([ + 'reservations.id', + 'reservations.node_id', + 'reservations.service_id', + 'reservations.service_upgrade_id', + 'reservations.purpose', + 'reservations.status', + 'reservations.external_server_id', + 'reservations.external_server_uuid', + 'reservations.external_server_identifier', + 'reservations.memory', + 'reservations.cpu', + 'reservations.disk', + 'reservations.reserved_memory', + 'reservations.reserved_cpu', + 'reservations.reserved_disk', + 'reservations.consumed_at', + 'reservations.created_at', + 'reservations.updated_at', + ]); + + $totals = []; + $confirmedByService = []; - if ($currentPage >= $totalPages || $totalPages === 0) { - break; + foreach ($rows as $reservation) { + if ($reservation->status === ResourceReservation::STATUS_CONFIRMED) { + $key = $reservation->service_id === null + ? 'reservation:'.(int) $reservation->id + : 'service:'.(int) $reservation->service_id; + $confirmedByService[$key][] = $reservation; + + continue; } - $page = $currentPage + 1; + $nodeId = (int) $reservation->node_id; + $isUpgrade = $reservation->purpose === 'upgrade'; + $this->addReservedResources($totals, $nodeId, [ + 'memory' => (int) ( + $isUpgrade + ? $reservation->reserved_memory + : $reservation->memory + ), + 'cpu' => (int) ( + $isUpgrade + ? $reservation->reserved_cpu + : $reservation->cpu + ), + 'disk' => (int) ( + $isUpgrade + ? $reservation->reserved_disk + : $reservation->disk + ), + ]); } - return $data; + foreach ($confirmedByService as $expectations) { + $target = $this->latestConfirmedTarget($expectations); + $nodeId = (int) $target->node_id; + $this->addReservedResources( + $totals, + $nodeId, + $this->confirmedExpectationOverlay( + $target, + $expectations, + $servers[$nodeId] ?? [] + ) + ); + } + + return $totals; } - private function extractRelationshipData(array $payload, string $relationship): array + /** + * A completed upgrade supersedes the checkout vector and every older + * upgrade for the same service. Upgrades are created serially under the + * one-active-upgrade guard, so the immutable ServiceUpgrade identity is + * authoritative. Reservation timestamps are operational metadata and + * must not be able to move an older target ahead of a newer one. + * + * @param list $expectations + */ + private function latestConfirmedTarget(array $expectations): object { - return $payload['attributes']['relationships'][$relationship]['data'] - ?? $payload['relationships'][$relationship]['data'] - ?? []; + $upgrades = array_values(array_filter( + $expectations, + fn (object $row): bool => $row->purpose === 'upgrade' + )); + $candidates = $upgrades === [] ? $expectations : $upgrades; + + usort( + $candidates, + fn (object $left, object $right): int => [ + (int) ($left->service_upgrade_id ?? 0), + (int) $left->id, + ] <=> [ + (int) ($right->service_upgrade_id ?? 0), + (int) $right->id, + ] + ); + + return $candidates[array_key_last($candidates)]; } - private function emptyClusterSnapshot(): array - { - return [ - 'locations' => [], - 'nodes' => [], - 'by_location' => [], - 'generated_at' => \now()->toIso8601String(), - ]; + /** + * The same server-list snapshot used by aggregate allocation must prove + * the pinned identity, node, and complete target vector. If a resize is + * visible but still converging, only the positive component deficit is + * overlaid. If identity is absent or ambiguous, the full target stays held. + * + * @param list $expectations + * @param list> $servers + * @return array{memory: int, cpu: int, disk: int} + */ + private function confirmedExpectationOverlay( + object $target, + array $expectations, + array $servers + ): array { + $identity = $this->confirmedExternalIdentity($expectations); + if ($identity['ambiguous'] || $identity['id'] <= 0) { + return $this->targetResources($target); + } + + $matches = array_values(array_filter( + $servers, + function (array $server) use ($identity, $target): bool { + return (int) ($server['id'] ?? 0) === $identity['id'] + && ( + $identity['uuid'] === null + || ($server['uuid'] ?? null) === $identity['uuid'] + ) + && ( + $identity['identifier'] === null + || ($server['identifier'] ?? null) + === $identity['identifier'] + ) + && ( + array_key_exists('external_id', $server) + && (string) ($server['external_id'] ?? '') + === (string) ($target->service_id ?? '') + ); + } + )); + if (count($matches) !== 1) { + return $this->targetResources($target); + } + + $overlay = []; + foreach (['memory', 'cpu', 'disk'] as $resource) { + $overlay[$resource] = max( + 0, + (int) $target->{$resource} + - (int) ($matches[0][$resource] ?? 0) + ); + } + + return $overlay; } - private function degradedClusterSnapshot(): array - { - return $this->emptyClusterSnapshot() + [ - 'error' => 'Pterodactyl unavailable', + /** + * @param list $expectations + * @return array{ + * id: int, + * uuid: ?string, + * identifier: ?string, + * ambiguous: bool + * } + */ + private function confirmedExternalIdentity( + array $expectations + ): array { + $ids = []; + $uuids = []; + $identifiers = []; + + foreach ($expectations as $expectation) { + // External server identity is materialized only after the + // Pterodactyl response is reconciled. Never infer it from mutable + // or unsigned JSON when calculating confirmed stock overlays. + $id = (int) ($expectation->external_server_id ?? 0); + if ($id > 0) { + $ids[$id] = true; + } + $uuid = $this->optionalIdentityString( + $expectation->external_server_uuid ?? null + ); + if ($uuid !== null) { + $uuids[$uuid] = true; + } + $identifier = $this->optionalIdentityString( + $expectation->external_server_identifier ?? null + ); + if ($identifier !== null) { + $identifiers[$identifier] = true; + } + } + + return [ + 'id' => count($ids) === 1 ? (int) array_key_first($ids) : 0, + 'uuid' => count($uuids) === 1 + ? (string) array_key_first($uuids) + : null, + 'identifier' => count($identifiers) === 1 + ? (string) array_key_first($identifiers) + : null, + 'ambiguous' => count($ids) > 1 + || count($uuids) > 1 + || count($identifiers) > 1, ]; } - private function shouldReturnDegradedSnapshot(\Throwable $exception): bool + private function optionalIdentityString(mixed $value): ?string { - if ($this->extractStatusCode($exception) >= 500) { - return true; - } - - return str_contains($exception->getMessage(), 'Pterodactyl API connection failed'); + return is_string($value) && trim($value) !== '' + ? trim($value) + : null; } - private function extractStatusCode(\Throwable $exception): int + /** + * @return array{memory: int, cpu: int, disk: int} + */ + private function targetResources(object $target): array { - preg_match('/\((\d{3})\)/', $exception->getMessage(), $matches); + return [ + 'memory' => (int) $target->memory, + 'cpu' => (int) $target->cpu, + 'disk' => (int) $target->disk, + ]; + } - return (int) ($matches[1] ?? 0); + /** + * @param array $totals + * @param array{memory: int, cpu: int, disk: int} $resources + */ + private function addReservedResources( + array &$totals, + int $nodeId, + array $resources + ): void { + $totals[$nodeId] ??= $this->emptyResources(); + + foreach ($resources as $resource => $value) { + if ( + $value < 0 + || $value > PHP_INT_MAX - $totals[$nodeId][$resource] + ) { + throw new \RuntimeException( + "Reserved {$resource} exceeds the supported range." + ); + } + $totals[$nodeId][$resource] += $value; + } } - private function getPendingReservationsForNodes(array $nodeIds): array - { + /** + * @param list $nodeIds + * @return array, + * ips: list, + * blocked_ips: list + * }> + */ + private function reservedAllocationClaims( + array $nodeIds, + ?string $excludeReservationToken + ): array { if ($nodeIds === []) { return []; } - return DB::table('ptero_resource_reservations') - ->whereIn('node_id', $nodeIds) - ->where('status', 'pending') - ->where('expires_at', '>', \now()) - ->select('node_id') - ->selectRaw('COALESCE(SUM(memory), 0) as memory') - ->selectRaw('COALESCE(SUM(cpu), 0) as cpu') - ->selectRaw('COALESCE(SUM(disk), 0) as disk') - ->groupBy('node_id') - ->get() - ->mapWithKeys(fn ($reservation) => [ - $reservation->node_id => [ - 'memory' => (int) $reservation->memory, - 'cpu' => (int) $reservation->cpu, - 'disk' => (int) $reservation->disk, - ], - ]) - ->toArray(); - } + // Start from commitments and join every claim by reservation identity + // only. Claim-side panel, node, and release fields are evidence to + // validate, never query filters. Validate all active commitments before + // narrowing to this panel/node so drift cannot move itself out of view. + $rows = DB::table('ptero_resource_reservations as reservations') + ->leftJoin( + 'services', + 'services.id', + '=', + 'reservations.service_id' + ) + ->leftJoin( + 'ptero_reservation_allocations as allocations', + 'allocations.reservation_id', + '=', + 'reservations.id' + ) + ->leftJoin( + 'service_upgrades as upgrades', + 'upgrades.id', + '=', + 'reservations.service_upgrade_id' + ) + ->leftJoin( + 'products as upgrade_products', + 'upgrade_products.id', + '=', + 'upgrades.product_id' + ) + ->leftJoin( + 'invoices as upgrade_invoices', + 'upgrade_invoices.id', + '=', + 'upgrades.invoice_id' + ) + ->where(function ($query): void { + // TTL expiry alone is not a state transition. Keep the row + // conservative until cleanup releases stock in one transaction. + $query->where( + 'reservations.status', + ResourceReservation::STATUS_PENDING + )->orWhere( + 'reservations.status', + ResourceReservation::STATUS_PAID_COMMITTED + )->orWhere(function ($query): void { + $query->where( + 'reservations.status', + ResourceReservation::STATUS_CONFIRMED + )->where(function ($query): void { + $query->whereNull('services.status') + ->orWhere( + 'services.status', + '!=', + 'cancelled' + ); + }); + })->orWhere(function ($query): void { + // Terminal rows must not retain a database allocation + // claim. Include them solely to fail closed until cleanup. + $query->whereNotNull('allocations.id') + ->whereNull('allocations.released_at'); + }); + }) + ->get([ + 'reservations.id as reservation_id', + 'reservations.token', + 'reservations.purpose', + 'reservations.panel_identity', + 'reservations.service_id', + 'reservations.service_upgrade_id', + 'reservations.upgrade_guard_id', + 'reservations.server_extension_id', + 'reservations.invoice_id', + 'reservations.user_id', + 'reservations.product_id', + 'reservations.plan_id', + 'reservations.quantity', + 'reservations.currency_code', + 'reservations.node_id', + 'reservations.location_id', + 'reservations.memory', + 'reservations.cpu', + 'reservations.disk', + 'reservations.reserved_memory', + 'reservations.reserved_cpu', + 'reservations.reserved_disk', + 'reservations.external_server_id', + 'reservations.external_user_id', + 'reservations.external_server_uuid', + 'reservations.external_server_identifier', + 'reservations.calculated_price', + 'reservations.pricing_version', + 'reservations.formula_version', + 'reservations.expires_at', + 'reservations.consumed_at', + 'reservations.status', + 'reservations.configuration_fingerprint', + 'reservations.configuration_payload', + 'services.status as service_status', + 'services.user_id as service_user_id', + 'services.product_id as service_product_id', + 'services.plan_id as service_plan_id', + 'services.quantity as service_quantity', + 'services.currency_code as service_currency_code', + 'allocations.id as claim_row_id', + 'allocations.panel_identity as claim_panel_identity', + 'allocations.node_id as claim_node_id', + 'allocations.allocation_id', + 'allocations.ip', + 'allocations.port', + 'allocations.environment_key', + 'allocations.is_primary', + 'allocations.released_at', + 'upgrades.id as upgrade_id', + 'upgrades.service_id as upgrade_service_id', + 'upgrades.product_id as upgrade_product_id', + 'upgrades.plan_id as upgrade_plan_id', + 'upgrades.invoice_id as upgrade_invoice_id', + 'upgrades.status as upgrade_status', + 'upgrades.active_service_guard_id as upgrade_active_service_guard_id', + 'upgrades.source_snapshot as upgrade_source_snapshot', + 'upgrades.target_snapshot as upgrade_target_snapshot', + 'upgrades.source_fingerprint as upgrade_source_fingerprint', + 'upgrades.target_fingerprint as upgrade_target_fingerprint', + 'upgrades.quoted_amount as upgrade_quoted_amount', + 'upgrades.currency_code as upgrade_currency_code', + 'upgrade_products.server_id as upgrade_product_server_id', + 'upgrade_invoices.status as upgrade_invoice_status', + 'upgrade_invoices.user_id as upgrade_invoice_user_id', + 'upgrade_invoices.currency_code as upgrade_invoice_currency_code', + ]); + + if ($rows->isEmpty()) { + return []; + } - private function pterodactylGet(string $path, array $query = []): array - { - try { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$this->apiKey, - 'Accept' => 'application/json', - ]) - ->timeout(3) // per-attempt; worst case: 2 × 3s + 250ms = ~6.25s - ->connectTimeout(2) - ->retry(2, 250, function ($exception) { - // Per-attempt timeout, not end-to-end. Retry on connection errors only. - // Do not retry 4xx (other than 429) or 5xx — Pterodactyl returns meaningful errors. - return $exception instanceof ConnectionException; - }, throw: false) - ->get($this->apiUrl.$path, $query); - } catch (ConnectionException $exception) { - // Full message may contain internal hostnames/ports; log for diagnostics, throw sanitized. - \report($exception); - throw new \RuntimeException('Pterodactyl API connection failed.', previous: $exception); - } - - if ($response->status() === 429) { - throw new \RuntimeException('Pterodactyl rate limit exceeded. Retry in a few seconds.'); - } - if ($response->failed()) { - // Log full upstream body for diagnostics, but do NOT leak it to callers — - // AvailabilityController surfaces exception messages to API clients. - \report(new \RuntimeException(sprintf( - 'Pterodactyl API error (%d) body: %s', - $response->status(), - $response->body() - ))); - - throw new \RuntimeException(sprintf('Pterodactyl API error (%d).', $response->status())); - } - - $payload = $response->json(); - - if (! is_array($payload)) { - throw new \RuntimeException(sprintf( - 'Pterodactyl API returned an invalid JSON payload (status %d).', - $response->status() - )); + $result = []; + + foreach ($rows->groupBy('reservation_id') as $reservationRows) { + $reservation = $reservationRows->first(); + $claims = $reservationRows + ->filter( + fn (object $row): bool => $row->claim_row_id !== null + ) + ->map(fn (object $row): object => (object) [ + 'reservation_id' => (int) $row->reservation_id, + 'panel_identity' => (string) $row->claim_panel_identity, + 'node_id' => (int) $row->claim_node_id, + 'allocation_id' => (int) $row->allocation_id, + 'ip' => $row->ip, + 'port' => (int) $row->port, + 'environment_key' => $row->environment_key, + 'is_primary' => (bool) $row->is_primary, + 'released_at' => $row->released_at, + ]) + ->values(); + $isActive = $reservation->status + === ResourceReservation::STATUS_PENDING + || $reservation->status + === ResourceReservation::STATUS_PAID_COMMITTED + || ( + $reservation->status + === ResourceReservation::STATUS_CONFIRMED + && $reservation->service_status !== 'cancelled' + ); + if (! $isActive) { + throw new InvalidStockConfigurationException( + 'A terminal capacity commitment still owns an unreleased allocation claim.' + ); + } + + $payload = null; + if ($reservation->purpose === 'checkout') { + $payload = $this->configuration + ->verifiedAllocationSnapshot($reservation, $claims); + } elseif ($reservation->purpose === 'upgrade') { + if ($claims->isNotEmpty()) { + throw new InvalidStockConfigurationException( + 'A resource upgrade unexpectedly owns checkout allocation claims.' + ); + } + $this->upgradeIntegrity->verifiedSnapshot( + (object) [ + 'id' => $reservation->upgrade_id, + 'service_id' => $reservation->upgrade_service_id, + 'product_id' => $reservation->upgrade_product_id, + 'plan_id' => $reservation->upgrade_plan_id, + 'invoice_id' => $reservation->upgrade_invoice_id, + 'status' => $reservation->upgrade_status, + 'active_service_guard_id' => $reservation + ->upgrade_active_service_guard_id, + 'source_snapshot' => $reservation->upgrade_source_snapshot, + 'target_snapshot' => $reservation->upgrade_target_snapshot, + 'source_fingerprint' => $reservation->upgrade_source_fingerprint, + 'target_fingerprint' => $reservation->upgrade_target_fingerprint, + 'quoted_amount' => $reservation->upgrade_quoted_amount, + 'currency_code' => $reservation->upgrade_currency_code, + 'service_user_id' => $reservation->service_user_id, + 'service_product_id' => $reservation->service_product_id, + 'service_plan_id' => $reservation->service_plan_id, + 'service_quantity' => $reservation->service_quantity, + 'service_currency_code' => $reservation->service_currency_code, + 'product_server_id' => $reservation->upgrade_product_server_id, + 'invoice_status' => $reservation->upgrade_invoice_status, + 'invoice_user_id' => $reservation->upgrade_invoice_user_id, + 'invoice_currency_code' => $reservation->upgrade_invoice_currency_code, + ], + $reservation + ); + } else { + throw new InvalidStockConfigurationException( + 'An active capacity commitment has an unknown purpose.' + ); + } + + if ( + ! hash_equals( + (string) $reservation->panel_identity, + $this->inventory()->panelIdentity() + ) + || ! in_array( + (int) $reservation->node_id, + $nodeIds, + true + ) + ) { + continue; + } + + // Self-exclusion changes arithmetic only. The excluded commitment + // must still pass integrity checks before the UI may reuse its + // capacity during a cart edit. + if ( + $excludeReservationToken !== null + && hash_equals( + (string) $reservation->token, + $excludeReservationToken + ) + ) { + continue; + } + if ($reservation->purpose !== 'checkout') { + continue; + } + + $nodeId = (int) $reservation->node_id; + $result[$nodeId] ??= [ + 'ids' => [], + 'ips' => [], + 'blocked_ips' => [], + ]; + $dedicated = data_get( + $payload, + 'allocation_requirements.dedicated_ip' + ) === true; + foreach ($claims as $claim) { + $allocationId = (int) $claim->allocation_id; + $ip = (string) ($claim->ip ?? ''); + $result[$nodeId]['ids'][$allocationId] = $allocationId; + if ($ip === '') { + continue; + } + $canonicalIp = $this->canonicalIp($ip); + $result[$nodeId]['ips'][$canonicalIp] = $canonicalIp; + if ($dedicated) { + $result[$nodeId]['blocked_ips'][$canonicalIp] + = $canonicalIp; + } + } + } + + foreach ($result as &$claims) { + $claims['ids'] = array_values($claims['ids']); + $claims['ips'] = array_values($claims['ips']); + $claims['blocked_ips'] = array_values( + $claims['blocked_ips'] + ); + sort($claims['ids'], SORT_NUMERIC); + sort($claims['ips'], SORT_STRING); + sort($claims['blocked_ips'], SORT_STRING); } + unset($claims); - return $payload; + return $result; } - private function getPendingReservations(int $nodeId, ?string $excludeReservationToken = null): array + private function canonicalIp(string $ip): string { - $query = DB::table('ptero_resource_reservations') - ->where('node_id', $nodeId) - ->where('status', 'pending') - ->where('expires_at', '>', \now()); + $packed = @inet_pton(trim($ip)); - if ($excludeReservationToken !== null) { - $query->where('token', '!=', $excludeReservationToken); - } + return $packed === false + ? strtolower(trim($ip)) + : bin2hex($packed); + } - $result = $query - ->selectRaw('COALESCE(SUM(memory), 0) as memory') - ->selectRaw('COALESCE(SUM(cpu), 0) as cpu') - ->selectRaw('COALESCE(SUM(disk), 0) as disk') - ->first(); + /** + * @return array{memory: int, cpu: int, disk: int} + */ + private function emptyResources(): array + { + return ['memory' => 0, 'cpu' => 0, 'disk' => 0]; + } + private function emptyClusterSnapshot(): array + { return [ - 'memory' => (int) $result->memory, - 'cpu' => (int) $result->cpu, - 'disk' => (int) $result->disk, + 'locations' => [], + 'nodes' => [], + 'by_location' => [], + 'generated_at' => now()->toIso8601String(), ]; } - private function getNodeLocation(int $nodeId): int + private function degradedClusterSnapshot(): array { - $data = $this->pterodactylGet("/api/application/nodes/{$nodeId}"); - - $locationId = $data['attributes']['location_id'] ?? null; - if (! is_int($locationId)) { - throw new \RuntimeException("Pterodactyl node {$nodeId} response is missing location_id."); - } + return $this->emptyClusterSnapshot() + ['error' => 'Pterodactyl unavailable']; + } - return $locationId; + private function shouldReturnDegradedSnapshot(\Throwable $exception): bool + { + return str_contains($exception->getMessage(), 'connection failed') + || preg_match('/inventory API error \\(5\\d\\d\\)/', $exception->getMessage()) === 1; } - private function getExtensionConfig(): array + private function inventory(): PterodactylInventoryService { - return Extension::where('extension', 'DynamicPterodactyl') - ->first() - ?->settings - ->pluck('value', 'key') - ->toArray() ?? []; + return $this->inventory ??= app(PterodactylInventoryService::class); } } diff --git a/Services/ResourceQuoteService.php b/Services/ResourceQuoteService.php new file mode 100644 index 0000000..de2e806 --- /dev/null +++ b/Services/ResourceQuoteService.php @@ -0,0 +1,269 @@ + $submittedOptions + * @return array{ + * available: true, + * adjusted: bool, + * selection: array{memory: int, cpu: int, disk: int}, + * bounds: array + * } + */ + public function quote( + Product $product, + array $submittedOptions, + ?string $excludeReservationToken = null + ): array { + $configuration = $this->configuration->forQuote($product, $submittedOptions); + $availability = $this->resources->getLocationAvailability( + $configuration['location_id'], + $excludeReservationToken + ); + $nodes = array_values(array_filter( + $availability['nodes'], + fn (array $node): bool => ($node['eligible'] ?? false) + && $this->allocations->select( + $node['available_allocations'] ?? [], + $configuration['allocation_count'], + $configuration['required_ports'] ?? [], + $configuration['allowed_port_ranges'] ?? [], + (bool) ($configuration['dedicated_ip'] ?? false) + ) !== null + )); + + if ( + $nodes === [] + && collect($availability['nodes'])->contains( + fn (array $node): bool => array_intersect( + $node['ineligible_reasons'] ?? [], + [ + 'cpu_policy_missing', + 'unlimited_existing_resource', + 'unbounded_memory_overallocation', + 'unbounded_disk_overallocation', + ] + ) !== [] + ) + ) { + throw new InvalidStockConfigurationException( + 'Eligible nodes are missing authoritative bounded resource inventory.' + ); + } + + if ($nodes === []) { + throw new StockUnavailableException( + 'No server currently has the required resource and port capacity.' + ); + } + + $requested = $configuration['resources']; + $selection = $this->vectorFitsAnyNode($requested, $nodes) + ? $requested + : $this->bestAdjustedVector($configuration, $nodes); + + if ($selection === null) { + throw new StockUnavailableException( + 'No server currently has enough stock for this product minimum.' + ); + } + + return [ + 'available' => true, + 'adjusted' => $selection !== $requested, + 'selection' => $selection, + 'bounds' => $this->conditionalBounds( + $configuration['sliders'], + $selection, + $nodes + ), + ]; + } + + /** + * @param array $configuration + * @param list> $nodes + * @return array{memory: int, cpu: int, disk: int}|null + */ + private function bestAdjustedVector(array $configuration, array $nodes): ?array + { + $candidates = []; + + foreach ($nodes as $node) { + $candidate = $configuration['resources']; + $valid = true; + + foreach (['memory', 'cpu', 'disk'] as $resource) { + if (! isset($configuration['sliders'][$resource])) { + if ($candidate[$resource] > $node['available'][$resource]) { + $valid = false; + break; + } + + continue; + } + + $slider = $configuration['sliders'][$resource]; + $nodeMaximum = min( + $slider['max'], + (int) $node['available'][$resource] + ); + $nodeMaximum = $this->snapDown( + $nodeMaximum, + $slider['min'], + $slider['step'] + ); + + if ($nodeMaximum < $slider['min']) { + $valid = false; + break; + } + + $candidate[$resource] = min( + $candidate[$resource], + $nodeMaximum + ); + } + + if (! $valid || ! $this->vectorFitsNode($candidate, $node)) { + continue; + } + + $retention = 0.0; + foreach ($configuration['sliders'] as $resource => $slider) { + $retention += $candidate[$resource] / max(1, $configuration['resources'][$resource]); + } + + $candidates[] = [ + 'selection' => $candidate, + 'retention' => $retention, + 'node_id' => (int) $node['node_id'], + ]; + } + + if ($candidates === []) { + return null; + } + + usort($candidates, function (array $left, array $right): int { + $retention = $right['retention'] <=> $left['retention']; + + return $retention !== 0 + ? $retention + : $left['node_id'] <=> $right['node_id']; + }); + + return $candidates[0]['selection']; + } + + /** + * Each bound is conditional on the other two selected resources. This + * prevents independently advertised maxima from coming from different + * Pterodactyl nodes. + * + * @param array> $sliders + * @param array{memory: int, cpu: int, disk: int} $selection + * @param list> $nodes + * @return array + */ + private function conditionalBounds( + array $sliders, + array $selection, + array $nodes + ): array { + $bounds = []; + + foreach ($sliders as $resource => $slider) { + $maximum = null; + + foreach ($nodes as $node) { + $otherResourcesFit = true; + foreach (['memory', 'cpu', 'disk'] as $otherResource) { + if ($otherResource === $resource) { + continue; + } + + if ($selection[$otherResource] > $node['available'][$otherResource]) { + $otherResourcesFit = false; + break; + } + } + + if (! $otherResourcesFit) { + continue; + } + + $nodeMaximum = $this->snapDown( + min($slider['max'], (int) $node['available'][$resource]), + $slider['min'], + $slider['step'] + ); + if ($nodeMaximum >= $slider['min']) { + $maximum = max($maximum ?? $slider['min'], $nodeMaximum); + } + } + + if ($maximum === null) { + throw new StockUnavailableException( + 'No server can satisfy the complete selected resource combination.' + ); + } + + $bounds[$resource] = [ + 'config_option_id' => $slider['config_option_id'], + 'min' => $slider['min'], + 'max' => $maximum, + 'configured_max' => $slider['max'], + 'step' => $slider['step'], + ]; + } + + return $bounds; + } + + /** + * @param array{memory: int, cpu: int, disk: int} $vector + * @param list> $nodes + */ + private function vectorFitsAnyNode(array $vector, array $nodes): bool + { + return collect($nodes)->contains( + fn (array $node): bool => $this->vectorFitsNode($vector, $node) + ); + } + + /** + * @param array{memory: int, cpu: int, disk: int} $vector + */ + private function vectorFitsNode(array $vector, array $node): bool + { + return $vector['memory'] <= $node['available']['memory'] + && $vector['cpu'] <= $node['available']['cpu'] + && $vector['disk'] <= $node['available']['disk']; + } + + private function snapDown(int $value, int $minimum, int $step): int + { + if ($value < $minimum) { + return $minimum - 1; + } + + return $minimum + intdiv($value - $minimum, $step) * $step; + } +} diff --git a/Services/SchedulerHealthService.php b/Services/SchedulerHealthService.php new file mode 100644 index 0000000..3301c88 --- /dev/null +++ b/Services/SchedulerHealthService.php @@ -0,0 +1,520 @@ + + */ + public static function taskDefinitions(): array + { + return [ + self::TASK_EXPIRE_CHECKOUT => [ + 'expected_interval_seconds' => 60, + 'lag_threshold_seconds' => 300, + ], + self::TASK_EXPIRE_UPGRADES => [ + 'expected_interval_seconds' => 60, + 'lag_threshold_seconds' => 300, + ], + self::TASK_RECONCILE_CHECKOUT => [ + 'expected_interval_seconds' => 600, + 'lag_threshold_seconds' => 1800, + ], + self::TASK_RECONCILE_UPGRADES => [ + 'expected_interval_seconds' => 600, + 'lag_threshold_seconds' => 1800, + ], + self::TASK_CAPACITY_ALERTS => [ + 'expected_interval_seconds' => 300, + 'lag_threshold_seconds' => 900, + ], + ]; + } + + /** + * Run one independently scheduled task and persist whether the entire + * invocation was healthy. A partial run never refreshes last_succeeded_at. + */ + public function run(string $taskName, callable $task): int + { + $this->startRun($taskName); + $processed = 0; + + try { + $result = $task(); + $processed = is_int($result) ? $result : 0; + } catch (\Throwable $exception) { + $this->recordFailure($taskName, [ + 'kind' => 'task_failure', + 'task' => $taskName, + 'entity_type' => 'scheduler_task', + 'entity_id' => $taskName, + 'exception' => $exception::class, + 'error' => $exception->getMessage(), + 'failed_at' => now()->toIso8601String(), + ], $exception); + $this->finishRun($taskName, $processed); + + throw $exception; + } + + $this->finishRun($taskName, $processed); + + return $processed; + } + + /** + * Process a bounded set of database identities without allowing one bad + * row to suppress later work. + */ + public function processRows( + string $taskName, + string $entityType, + iterable $entityIds, + callable $processor, + ): int { + $processed = 0; + + foreach ($entityIds as $entityId) { + try { + if ($processor($entityId)) { + $processed++; + } + } catch (\Throwable $exception) { + $this->recordRowFailure( + $taskName, + $entityType, + is_numeric($entityId) ? (int) $entityId : (string) $entityId, + $exception + ); + } + } + + return $processed; + } + + /** + * Select and process one fair, bounded cycle segment. The persisted cursor + * advances before every row attempt, so a deterministic failure cannot pin + * the first page forever. A short forward page wraps to the lowest eligible + * identities and eventually retries repaired rows. + * + * @param callable(): (EloquentBuilder|QueryBuilder) $eligibleQuery + */ + public function processEligibleRows( + string $taskName, + string $entityType, + int $limit, + callable $eligibleQuery, + callable $processor, + ): int { + $limit = max(1, $limit); + $this->ensureTask($taskName); + $cursor = (int) DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $taskName) + ->value('last_scanned_entity_id'); + $baseQuery = $eligibleQuery(); + if ( + ! $baseQuery instanceof EloquentBuilder + && ! $baseQuery instanceof QueryBuilder + ) { + throw new \InvalidArgumentException( + 'The scheduler eligible-query factory must return a database query builder.' + ); + } + + $entityIds = (clone $baseQuery) + ->where('id', '>', $cursor) + ->reorder() + ->orderBy('id') + ->limit($limit) + ->pluck('id') + ->map(fn ($entityId): int => (int) $entityId) + ->values(); + $remaining = $limit - $entityIds->count(); + if ($remaining > 0 && $cursor > 0) { + $wrappedIds = (clone $baseQuery) + ->where('id', '<=', $cursor) + ->reorder() + ->orderBy('id') + ->limit($remaining) + ->pluck('id') + ->map(fn ($entityId): int => (int) $entityId); + $entityIds = $entityIds + ->concat($wrappedIds) + ->values(); + } + + return $this->processRows( + $taskName, + $entityType, + $entityIds, + function (int $entityId) use ( + $taskName, + $processor + ): bool { + DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $taskName) + ->update([ + 'last_scanned_entity_id' => $entityId, + 'updated_at' => now(), + ]); + + return $processor($entityId); + } + ); + } + + public function recordRowFailure( + string $taskName, + string $entityType, + int|string $entityId, + \Throwable $exception, + ): void { + $this->recordFailure($taskName, [ + 'kind' => 'row_failure', + 'task' => $taskName, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'exception' => $exception::class, + 'error' => $exception->getMessage(), + 'failed_at' => now()->toIso8601String(), + ], $exception); + } + + /** + * Persist and alert for tasks that have not completed one fully healthy + * run within their configured threshold. + */ + public function checkForLag(): int + { + $lagging = 0; + + foreach (self::taskDefinitions() as $taskName => $definition) { + $context = null; + try { + $this->ensureTask($taskName); + $context = DB::transaction(function () use ( + $taskName, + $definition + ): ?array { + $heartbeat = DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $taskName) + ->lockForUpdate() + ->first(); + if ($heartbeat === null) { + return null; + } + + $reference = $heartbeat->last_succeeded_at + ?? $heartbeat->created_at; + $lagSeconds = max( + 0, + now()->getTimestamp() + - Carbon::parse($reference)->getTimestamp() + ); + $isLagging = $lagSeconds + > (int) $definition['lag_threshold_seconds']; + $shouldAlert = $isLagging + && ( + $heartbeat->last_alerted_at === null + || Carbon::parse($heartbeat->last_alerted_at) + ->lte(now()->subMinutes( + self::ALERT_COOLDOWN_MINUTES + )) + ); + + DB::table('ptero_scheduler_heartbeats') + ->where('id', $heartbeat->id) + ->update([ + 'last_lag_checked_at' => now(), + 'lag_detected_at' => $isLagging + ? ($heartbeat->lag_detected_at ?? now()) + : null, + 'last_alerted_at' => $shouldAlert + ? now() + : $heartbeat->last_alerted_at, + 'updated_at' => now(), + ]); + + if (! $isLagging) { + return null; + } + + return [ + 'kind' => 'scheduler_lag', + 'task' => $taskName, + 'lag_seconds' => $lagSeconds, + 'lag_threshold_seconds' => (int) $definition[ + 'lag_threshold_seconds' + ], + 'last_succeeded_at' => $heartbeat->last_succeeded_at, + 'error' => 'No fully successful run completed within the scheduler lag threshold.', + 'should_alert' => $shouldAlert, + ]; + }, 5); + } catch (\Throwable $exception) { + $this->safeLog('error', 'Scheduler heartbeat lag check failed', [ + 'task' => $taskName, + 'error' => $exception->getMessage(), + ]); + $this->reportThrowable($exception); + } + + if ($context === null) { + continue; + } + + $lagging++; + if ($context['should_alert']) { + unset($context['should_alert']); + $this->safeLog( + 'warning', + 'Dynamic Pterodactyl scheduled task is lagging', + $context + ); + $this->notifyOperators($context); + } + } + + return $lagging; + } + + private function startRun(string $taskName): void + { + try { + $this->ensureTask($taskName); + DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $taskName) + ->update([ + 'last_started_at' => now(), + 'last_failure_count' => 0, + 'updated_at' => now(), + ]); + } catch (\Throwable $exception) { + $this->safeLog('error', 'Scheduler heartbeat start write failed', [ + 'task' => $taskName, + 'error' => $exception->getMessage(), + ]); + $this->reportThrowable($exception); + } + } + + private function finishRun(string $taskName, int $processed): void + { + try { + DB::transaction(function () use ($taskName, $processed): void { + $heartbeat = DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $taskName) + ->lockForUpdate() + ->first(); + if ($heartbeat === null) { + return; + } + + $healthy = (int) $heartbeat->last_failure_count === 0; + $updates = [ + 'last_completed_at' => now(), + 'last_processed_count' => max(0, $processed), + 'consecutive_failures' => $healthy + ? 0 + : ((int) $heartbeat->consecutive_failures + 1), + 'updated_at' => now(), + ]; + if ($healthy) { + $updates += [ + 'last_succeeded_at' => now(), + 'lag_detected_at' => null, + 'last_error' => null, + 'last_failure_context' => null, + ]; + } + + DB::table('ptero_scheduler_heartbeats') + ->where('id', $heartbeat->id) + ->update($updates); + }, 5); + } catch (\Throwable $exception) { + $this->safeLog('error', 'Scheduler heartbeat completion write failed', [ + 'task' => $taskName, + 'error' => $exception->getMessage(), + ]); + $this->reportThrowable($exception); + } + } + + /** + * @param array $context + */ + private function recordFailure( + string $taskName, + array $context, + \Throwable $exception, + ): void { + $this->safeLog( + 'error', + 'Dynamic Pterodactyl scheduled work failed', + $context + ); + $this->reportThrowable($exception); + + $shouldAlert = false; + try { + $this->ensureTask($taskName); + $shouldAlert = DB::transaction(function () use ( + $taskName, + $context + ): bool { + $heartbeat = DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $taskName) + ->lockForUpdate() + ->first(); + if ($heartbeat === null) { + return false; + } + + $shouldAlert = $heartbeat->last_alerted_at === null + || Carbon::parse($heartbeat->last_alerted_at) + ->lte(now()->subMinutes( + self::ALERT_COOLDOWN_MINUTES + )); + + DB::table('ptero_scheduler_heartbeats') + ->where('id', $heartbeat->id) + ->update([ + 'last_failed_at' => now(), + 'last_failure_count' => DB::raw( + 'last_failure_count + 1' + ), + 'last_error' => substr( + (string) ($context['error'] ?? ''), + 0, + 4000 + ), + 'last_failure_context' => json_encode( + $context, + JSON_THROW_ON_ERROR + | JSON_INVALID_UTF8_SUBSTITUTE + ), + 'last_alerted_at' => $shouldAlert + ? now() + : $heartbeat->last_alerted_at, + 'updated_at' => now(), + ]); + + return $shouldAlert; + }, 5); + } catch (\Throwable $heartbeatException) { + $this->safeLog('error', 'Scheduler failure heartbeat write failed', [ + 'task' => $taskName, + 'entity_type' => $context['entity_type'] ?? null, + 'entity_id' => $context['entity_id'] ?? null, + 'error' => $heartbeatException->getMessage(), + ]); + $this->reportThrowable($heartbeatException); + } + + if ($shouldAlert) { + $this->notifyOperators($context); + } + } + + private function ensureTask(string $taskName): void + { + $definition = self::taskDefinitions()[$taskName] ?? [ + 'expected_interval_seconds' => 300, + 'lag_threshold_seconds' => 900, + ]; + $now = now(); + + DB::table('ptero_scheduler_heartbeats')->upsert([[ + 'task_name' => $taskName, + 'expected_interval_seconds' => $definition[ + 'expected_interval_seconds' + ], + 'lag_threshold_seconds' => $definition[ + 'lag_threshold_seconds' + ], + 'created_at' => $now, + 'updated_at' => $now, + ]], ['task_name'], [ + 'expected_interval_seconds', + 'lag_threshold_seconds', + 'updated_at', + ]); + } + + /** + * @param array $context + */ + private function notifyOperators(array $context): void + { + try { + $this->operatorAlerts->notify($context); + } catch (\Throwable $exception) { + $this->safeLog( + 'critical', + 'Scheduler failure operator alert could not be delivered', + [ + 'task' => $context['task'] ?? null, + 'entity_type' => $context['entity_type'] ?? null, + 'entity_id' => $context['entity_id'] ?? null, + 'error' => $exception->getMessage(), + ] + ); + $this->reportThrowable($exception); + } + } + + /** + * @param array $context + */ + private function safeLog( + string $level, + string $message, + array $context, + ): void { + try { + Log::{$level}($message, $context); + } catch (\Throwable) { + // Plain unit tests may not boot Laravel's logging facade. + } + } + + private function reportThrowable(\Throwable $throwable): void + { + try { + app(ExceptionHandler::class)->report($throwable); + } catch (\Throwable) { + // Heartbeat reporting must never suppress later lifecycle rows. + } + } +} diff --git a/Services/SchedulerOperatorAlertService.php b/Services/SchedulerOperatorAlertService.php new file mode 100644 index 0000000..13acbc5 --- /dev/null +++ b/Services/SchedulerOperatorAlertService.php @@ -0,0 +1,85 @@ + $context + */ + public function notify(array $context): void + { + try { + $recipients = User::whereNotNull('role_id')->get(); + } catch (\Throwable $exception) { + $this->safeLog( + 'critical', + 'Scheduler failure recipients could not be loaded', + $context + ['notification_error' => $exception->getMessage()] + ); + $this->reportThrowable($exception); + + return; + } + + if ($recipients->isEmpty()) { + $this->safeLog( + 'critical', + 'Dynamic Pterodactyl scheduler requires attention', + $context + ); + + return; + } + + foreach ($recipients as $recipient) { + try { + $recipient->notify( + new SchedulerTaskFailureNotification($context) + ); + } catch (\Throwable $exception) { + $this->safeLog( + 'error', + 'Failed to notify operator about scheduler health', + [ + 'task' => $context['task'] ?? null, + 'entity_type' => $context['entity_type'] ?? null, + 'entity_id' => $context['entity_id'] ?? null, + 'recipient_id' => $recipient->id ?? null, + 'error' => $exception->getMessage(), + ] + ); + $this->reportThrowable($exception); + } + } + } + + /** + * @param array $context + */ + private function safeLog( + string $level, + string $message, + array $context, + ): void { + try { + Log::{$level}($message, $context); + } catch (\Throwable) { + // Scheduler alerting must not interrupt later lifecycle rows. + } + } + + private function reportThrowable(\Throwable $throwable): void + { + try { + app(ExceptionHandler::class)->report($throwable); + } catch (\Throwable) { + // The application exception handler may itself be unavailable. + } + } +} diff --git a/Services/SliderConfigReaderService.php b/Services/SliderConfigReaderService.php deleted file mode 100644 index c50f470..0000000 --- a/Services/SliderConfigReaderService.php +++ /dev/null @@ -1,68 +0,0 @@ -getDynamicSliderOptions($productId); - - if ($options->isEmpty()) { - return [ - 'has_config' => false, - 'sliders' => [], - ]; - } - - $sliders = []; - - foreach ($options as $option) { - $metadata = $option->metadata; - if (! is_array($metadata)) { - $metadata = json_decode($metadata, true) ?? []; - } - - $resourceType = $metadata['resource_type'] ?? strtolower($option->name); - - $sliders[$resourceType] = [ - 'config_option_id' => $option->id, - 'name' => $option->name, - 'min' => $metadata['min'] ?? 0, - 'max' => $metadata['max'] ?? 0, - 'step' => $metadata['step'] ?? 1, - 'default' => $metadata['default'] ?? $metadata['min'] ?? 0, - 'unit' => $metadata['unit'] ?? '', - 'display_unit' => $metadata['display_unit'] ?? $metadata['unit'] ?? '', - 'display_divisor' => $metadata['display_divisor'] ?? 1, - 'pricing' => $metadata['pricing'] ?? ['model' => 'linear', 'rate_per_unit' => 0], - ]; - } - - return [ - 'has_config' => true, - 'sliders' => $sliders, - ]; - } - - /** - * Get dynamic_slider ConfigOptions for a product - * - * @return \Illuminate\Support\Collection - */ - private function getDynamicSliderOptions(int $productId) - { - return ConfigOption::whereHas('products', fn ($q) => $q->where('product_id', $productId)) - ->where('type', 'dynamic_slider') - ->whereNull('parent_id') - ->get(); - } - -} diff --git a/Services/UpgradeReservationIntegrityService.php b/Services/UpgradeReservationIntegrityService.php new file mode 100644 index 0000000..4080740 --- /dev/null +++ b/Services/UpgradeReservationIntegrityService.php @@ -0,0 +1,1048 @@ + $context + */ + public function fingerprint(object $upgrade, array $context): string + { + return hash('sha256', json_encode( + $this->canonicalizeSnapshot([ + 'upgrade_id' => (int) $upgrade->id, + 'source_fingerprint' => (string) $upgrade->source_fingerprint, + 'target_fingerprint' => (string) $upgrade->target_fingerprint, + 'panel_identity' => $context['panel_identity'], + 'node_id' => $context['node_id'], + 'location_id' => $context['location_id'], + 'external_server_id' => $context['external_server_id'], + 'external_server_uuid' => $context['external_server_uuid'], + 'external_server_identifier' => $context['external_server_identifier'], + 'external_server_external_id' => $context['external_server_external_id'], + 'external_user_id' => $context['external_user_id'], + 'user_external_id' => $context['user_external_id'], + 'user_email' => $context['user_email'], + 'nest_id' => $context['nest_id'], + 'egg_id' => $context['egg_id'], + 'preserved_build' => $context['preserved_build'], + 'allocation_id' => $context['allocation_id'], + 'assigned_allocation_ids' => $context['assigned_allocation_ids'], + 'source' => $context['source'], + 'target' => $context['target'], + 'delta' => $context['delta'], + 'quoted_amount' => $this->normalizedMoney( + $upgrade->quoted_amount + ), + 'currency_code' => strtoupper((string) $upgrade->currency_code), + ]), + JSON_THROW_ON_ERROR + | JSON_PRESERVE_ZERO_FRACTION + | JSON_UNESCAPED_SLASHES + )); + } + + public function pricingVersion(object $upgrade): string + { + return hash('sha256', json_encode([ + 'quoted_amount' => $this->normalizedMoney( + $upgrade->quoted_amount + ), + 'currency_code' => strtoupper((string) $upgrade->currency_code), + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } + + /** + * @return array + */ + public function verifiedSnapshot( + object $upgrade, + object $reservation + ): array { + return $this->verifySnapshot($upgrade, $reservation, null); + } + + /** + * Verify the one transient lifecycle pair that exists while the locked + * paid-invoice transaction atomically commits its capacity reservation. + * + * @return array + */ + public function verifiedSnapshotForPaidCommit( + object $upgrade, + object $reservation, + Invoice $paidInvoice + ): array { + $invoiceId = StrictInteger::parse($paidInvoice->id ?? null); + if ( + $invoiceId === null + || $invoiceId <= 0 + || $paidInvoice->status !== Invoice::STATUS_PAID + || StrictInteger::parse($upgrade->invoice_id ?? null) + !== $invoiceId + || StrictInteger::parse($reservation->invoice_id ?? null) + !== $invoiceId + ) { + throw new InvalidStockConfigurationException( + 'The paid upgrade invoice failed its atomic transition proof.' + ); + } + + return $this->verifySnapshot( + $upgrade, + $reservation, + $invoiceId + ); + } + + /** + * @return array + */ + private function verifySnapshot( + object $upgrade, + object $reservation, + ?int $atomicPaidInvoiceId + ): array { + $payload = $reservation->configuration_payload; + if (is_string($payload)) { + try { + $payload = json_decode( + $payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (\JsonException $exception) { + throw new InvalidStockConfigurationException( + 'The upgrade capacity snapshot is unreadable.', + previous: $exception + ); + } + } + if (! is_array($payload)) { + throw new InvalidStockConfigurationException( + 'The upgrade capacity snapshot is unreadable.' + ); + } + + try { + $fingerprint = $this->fingerprint($upgrade, $payload); + $pricingVersion = $this->pricingVersion($upgrade); + $quotedAmount = $this->normalizedMoney( + $upgrade->quoted_amount ?? null + ); + $reservedAmount = $this->normalizedMoney( + $reservation->calculated_price ?? null + ); + } catch (\Throwable $exception) { + throw new InvalidStockConfigurationException( + 'The upgrade capacity snapshot failed its immutable integrity check.', + previous: $exception + ); + } + + if ($upgrade instanceof ServiceUpgrade) { + $this->assertLiveTargetMatches($upgrade); + } + + $source = $this->resourceVector($payload['source'] ?? null); + $target = $this->resourceVector($payload['target'] ?? null); + $delta = $this->resourceVector($payload['delta'] ?? null); + $rowTarget = $this->resourceVector([ + 'memory' => $reservation->memory ?? null, + 'cpu' => $reservation->cpu ?? null, + 'disk' => $reservation->disk ?? null, + ]); + $rowDelta = $this->resourceVector([ + 'memory' => $reservation->reserved_memory ?? null, + 'cpu' => $reservation->reserved_cpu ?? null, + 'disk' => $reservation->reserved_disk ?? null, + ]); + $upgradeId = $this->positiveInteger($upgrade->id ?? null); + $upgradeServiceId = $this->positiveInteger( + $upgrade->service_id ?? null + ); + $reservationServiceId = $this->positiveInteger( + $reservation->service_id ?? null + ); + $reservationUpgradeId = $this->positiveInteger( + $reservation->service_upgrade_id ?? null + ); + $payloadUpgradeId = $this->positiveInteger( + $payload['service_upgrade_id'] ?? null + ); + $nodeId = $this->positiveInteger($reservation->node_id ?? null); + $payloadNodeId = $this->positiveInteger( + $payload['node_id'] ?? null + ); + $locationId = $this->positiveInteger( + $reservation->location_id ?? null + ); + $payloadLocationId = $this->positiveInteger( + $payload['location_id'] ?? null + ); + $externalServerId = $this->positiveInteger( + $reservation->external_server_id ?? null + ); + $payloadExternalServerId = $this->positiveInteger( + $payload['external_server_id'] ?? null + ); + $externalUserId = $this->positiveInteger( + $reservation->external_user_id ?? null + ); + $payloadExternalUserId = $this->positiveInteger( + $payload['external_user_id'] ?? null + ); + $nestId = $this->positiveInteger($payload['nest_id'] ?? null); + $eggId = $this->positiveInteger($payload['egg_id'] ?? null); + $allocationId = $this->positiveInteger( + $payload['allocation_id'] ?? null + ); + $assignedAllocationIds = $this->positiveIntegerList( + $payload['assigned_allocation_ids'] ?? null + ); + $preservedBuild = $this->preservedBuild( + $payload['preserved_build'] ?? null + ); + $panelIdentity = $payload['panel_identity'] ?? null; + $externalServerUuid = $payload['external_server_uuid'] ?? null; + $externalServerIdentifier = + $payload['external_server_identifier'] ?? null; + $externalServerExternalId = + $payload['external_server_external_id'] ?? null; + $userExternalId = $payload['user_external_id'] ?? null; + $userEmail = $payload['user_email'] ?? null; + $sourceSnapshot = $this->decodedArray( + $upgrade->source_snapshot ?? null + ); + $targetSnapshot = $this->decodedArray( + $upgrade->target_snapshot ?? null + ); + $sourceProperties = $this->snapshotProperties($sourceSnapshot); + $targetProperties = $this->snapshotProperties($targetSnapshot); + $sourceSnapshotHash = $this->snapshotFingerprint( + $sourceSnapshot + ); + $targetSnapshotHash = $this->snapshotFingerprint( + $targetSnapshot + ); + $snapshotSource = $this->resourceVectorFromProperties( + $sourceProperties + ); + $snapshotTarget = $this->resourceVectorFromProperties( + $targetProperties + ); + $snapshotLocation = $this->locationFromProperties( + $targetProperties + ); + $targetRecurring = StrictDecimal::parseNonNegative( + $targetSnapshot['recurring_price'] ?? null + ); + $sourceFingerprint = $upgrade->source_fingerprint ?? null; + $targetFingerprint = $upgrade->target_fingerprint ?? null; + $service = $upgrade->service ?? null; + $product = $upgrade->product ?? null; + $serviceUserId = $this->positiveInteger( + $upgrade->service_user_id + ?? ($service?->user_id ?? null) + ); + $serviceProductId = $this->positiveInteger( + $upgrade->service_product_id + ?? ($service?->product_id ?? null) + ); + $servicePlanId = $this->positiveInteger( + $upgrade->service_plan_id + ?? ($service?->plan_id ?? null) + ); + $serviceQuantity = StrictInteger::parse( + $upgrade->service_quantity + ?? ($service?->quantity ?? null) + ); + $serviceCurrency = strtoupper((string) ( + $upgrade->service_currency_code + ?? ($service?->currency_code ?? '') + )); + $upgradeProductId = $this->positiveInteger( + $upgrade->product_id ?? null + ); + $upgradePlanId = $this->positiveInteger( + $upgrade->plan_id ?? null + ); + $upgradeInvoiceId = $this->positiveInteger( + $upgrade->invoice_id ?? null + ); + $invoice = $upgrade->invoice ?? null; + $invoiceStatus = (string) ( + $upgrade->invoice_status + ?? ($invoice?->status ?? '') + ); + $invoiceUserId = $this->positiveInteger( + $upgrade->invoice_user_id + ?? ($invoice?->user_id ?? null) + ); + $invoiceCurrency = strtoupper((string) ( + $upgrade->invoice_currency_code + ?? ($invoice?->currency_code ?? '') + )); + $productServerId = $this->positiveInteger( + $upgrade->product_server_id + ?? ($product?->server_id ?? null) + ); + $reservationUserId = $this->positiveInteger( + $reservation->user_id ?? null + ); + $reservationProductId = $this->positiveInteger( + $reservation->product_id ?? null + ); + $reservationPlanId = $this->positiveInteger( + $reservation->plan_id ?? null + ); + $reservationInvoiceId = $this->positiveInteger( + $reservation->invoice_id ?? null + ); + $reservationServerId = $this->positiveInteger( + $reservation->server_extension_id ?? null + ); + $reservationQuantity = StrictInteger::parse( + $reservation->quantity ?? null + ); + $upgradeCurrency = strtoupper( + (string) ($upgrade->currency_code ?? '') + ); + $reservationCurrency = strtoupper( + (string) ($reservation->currency_code ?? '') + ); + $reservationGuard = $this->positiveInteger( + $reservation->upgrade_guard_id ?? null + ); + $upgradeGuard = $this->positiveInteger( + $upgrade->active_service_guard_id ?? null + ); + $reservationStatus = (string) ( + $reservation->status ?? '' + ); + $upgradeStatus = (string) ($upgrade->status ?? ''); + if ( + $source === null + || $target === null + || $delta === null + || $rowTarget === null + || $rowDelta === null + || $sourceSnapshot === null + || $targetSnapshot === null + || $sourceProperties === null + || $targetProperties === null + || $snapshotSource === null + || $snapshotTarget === null + || $snapshotLocation === null + || $targetRecurring === null + || $sourceSnapshotHash === null + || $targetSnapshotHash === null + || ! is_string($sourceFingerprint) + || preg_match( + '/^[a-f0-9]{64}$/D', + $sourceFingerprint + ) !== 1 + || ! is_string($targetFingerprint) + || preg_match( + '/^[a-f0-9]{64}$/D', + $targetFingerprint + ) !== 1 + || ! hash_equals( + $sourceFingerprint, + $sourceSnapshotHash + ) + || ! hash_equals( + $targetFingerprint, + $targetSnapshotHash + ) + || $source !== $snapshotSource + || $target !== $snapshotTarget + || $source === $target + || collect($target)->contains( + fn (int $value): bool => $value <= 0 + ) + || $target['disk'] < $source['disk'] + || ! $this->onlyResourcesChanged( + $sourceProperties, + $targetProperties + ) + || $delta !== [ + 'memory' => max(0, $target['memory'] - $source['memory']), + 'cpu' => max(0, $target['cpu'] - $source['cpu']), + 'disk' => max(0, $target['disk'] - $source['disk']), + ] + || ($reservation->purpose ?? null) !== 'upgrade' + || $upgradeId === null + || $upgradeServiceId === null + || $reservationServiceId !== $upgradeServiceId + || $reservationUpgradeId !== $upgradeId + || $payloadUpgradeId !== $upgradeId + || $serviceUserId === null + || $reservationUserId !== $serviceUserId + || $serviceProductId === null + || $upgradeProductId !== $serviceProductId + || $reservationProductId !== $serviceProductId + || $servicePlanId === null + || $upgradePlanId !== $servicePlanId + || $reservationPlanId !== $servicePlanId + || $serviceQuantity !== 1 + || $reservationQuantity !== 1 + || $serviceCurrency === '' + || $upgradeCurrency !== $serviceCurrency + || $reservationCurrency !== $serviceCurrency + || ! $this->snapshotIdentityMatches( + $sourceSnapshot, + $targetSnapshot, + $upgradeServiceId, + $serviceProductId, + $servicePlanId, + $serviceCurrency + ) + || ( + ($upgrade->invoice_id ?? null) !== null + && $upgradeInvoiceId === null + ) + || ( + ($reservation->invoice_id ?? null) !== null + && $reservationInvoiceId === null + ) + || $reservationInvoiceId !== $upgradeInvoiceId + || ( + $this->moneyIsPositive($quotedAmount) + ? $upgradeInvoiceId === null + : $upgradeInvoiceId !== null + ) + || ! $this->invoiceLifecycleMatches( + $reservationStatus, + $this->moneyIsPositive($quotedAmount), + $upgradeInvoiceId, + $invoiceStatus, + $invoiceUserId, + $invoiceCurrency, + $serviceUserId, + $serviceCurrency, + $atomicPaidInvoiceId + ) + || $productServerId === null + || $reservationServerId !== $productServerId + || ( + ($reservation->upgrade_guard_id ?? null) !== null + && $reservationGuard === null + ) + || ( + ($upgrade->active_service_guard_id ?? null) !== null + && $upgradeGuard === null + ) + || $quotedAmount !== $reservedAmount + || (string) ($reservation->pricing_version ?? '') + !== $pricingVersion + || (string) ($reservation->formula_version ?? '') + !== 'dynamic-upgrade-v1' + || ! $this->lifecycleMatches( + $reservationStatus, + $upgradeStatus, + $reservationGuard, + $upgradeGuard, + $upgradeId, + $upgradeServiceId, + $reservation->consumed_at ?? null + ) + || ! is_string($reservation->configuration_fingerprint) + || preg_match( + '/^[a-f0-9]{64}$/D', + $reservation->configuration_fingerprint + ) !== 1 + || ! hash_equals( + $reservation->configuration_fingerprint, + $fingerprint + ) + || (string) ($payload['source_fingerprint'] ?? '') + !== (string) $upgrade->source_fingerprint + || (string) ($payload['target_fingerprint'] ?? '') + !== (string) $upgrade->target_fingerprint + || ! is_string($panelIdentity) + || preg_match('/^[a-f0-9]{64}$/D', $panelIdentity) !== 1 + || ! is_string($reservation->panel_identity ?? null) + || ! hash_equals( + (string) $reservation->panel_identity, + $panelIdentity + ) + || $nodeId === null + || $payloadNodeId !== $nodeId + || $locationId === null + || $payloadLocationId !== $locationId + || $payloadLocationId !== $snapshotLocation + || $externalServerId === null + || $payloadExternalServerId !== $externalServerId + || $externalUserId === null + || $payloadExternalUserId !== $externalUserId + || ! is_string($externalServerUuid) + || ! Str::isUuid($externalServerUuid) + || ! is_string($reservation->external_server_uuid ?? null) + || ! hash_equals( + (string) $reservation->external_server_uuid, + $externalServerUuid + ) + || ! is_string($externalServerIdentifier) + || trim($externalServerIdentifier) === '' + || ! is_string( + $reservation->external_server_identifier ?? null + ) + || ! hash_equals( + (string) $reservation->external_server_identifier, + $externalServerIdentifier + ) + || ! is_string($externalServerExternalId) + || ! hash_equals( + (string) $upgradeServiceId, + $externalServerExternalId + ) + || ! is_string($userExternalId) + || ! hash_equals( + "paymenter-user-{$serviceUserId}", + $userExternalId + ) + || ! is_string($userEmail) + || trim($userEmail) === '' + || $nestId === null + || $eggId === null + || $allocationId === null + || $assignedAllocationIds === null + || ! in_array( + $allocationId, + $assignedAllocationIds, + true + ) + || $preservedBuild === null + || $target !== $rowTarget + || $delta !== $rowDelta + ) { + throw new InvalidStockConfigurationException( + 'The upgrade capacity snapshot failed its immutable integrity check.' + ); + } + + return $payload; + } + + /** + * The signed target snapshot is provisioning authority, while core applies + * ServiceUpgrade::configs after the remote resize. Prove those mutable rows + * still materialize the exact signed target so the two systems cannot + * diverge after quote creation. + */ + public function assertLiveTargetMatches(ServiceUpgrade $upgrade): void + { + try { + $upgrade->load([ + 'service.product.settings', + 'service.configs.configOption', + 'service.configs.configValue', + 'product.settings', + 'configs.configOption', + 'configs.configValue', + ]); + $targetSnapshot = $this->decodedArray( + $upgrade->target_snapshot + ); + $signedProperties = $this->snapshotProperties( + $targetSnapshot + ); + $liveProperties = $this->snapshotProperties([ + 'properties' => $upgrade->targetProperties(), + ]); + } catch (\Throwable $exception) { + throw new InvalidStockConfigurationException( + 'The live upgrade target cannot be reconstructed.', + previous: $exception + ); + } + + if ( + $targetSnapshot === null + || $signedProperties === null + || $liveProperties === null + || $signedProperties !== $liveProperties + || ! array_key_exists('billing_anchor', $targetSnapshot) + ) { + throw new InvalidStockConfigurationException( + 'The live upgrade target no longer matches its signed snapshot.' + ); + } + } + + /** + * @return array|null + */ + private function decodedArray(mixed $value): ?array + { + if (is_array($value)) { + return $value; + } + if (! is_string($value) || trim($value) === '') { + return null; + } + + try { + $decoded = json_decode( + $value, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (\JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + private function snapshotFingerprint(?array $snapshot): ?string + { + if ($snapshot === null) { + return null; + } + + try { + return hash('sha256', json_encode( + $this->canonicalizeSnapshot($snapshot), + JSON_THROW_ON_ERROR + | JSON_PRESERVE_ZERO_FRACTION + | JSON_UNESCAPED_SLASHES + )); + } catch (\JsonException) { + return null; + } + } + + /** + * ServiceUpgrade uses a distinct canonicalizer that preserves numeric + * representation. Do not reuse the checkout canonicalizer here. + * + * @param array $value + * @return array + */ + private function canonicalizeSnapshot(array $value): array + { + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = $this->canonicalizeSnapshot($item); + } + } + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } + + /** + * @param array|null $snapshot + * @return array|null + */ + private function snapshotProperties(?array $snapshot): ?array + { + $properties = $snapshot['properties'] ?? null; + if (! is_array($properties) || array_is_list($properties)) { + return null; + } + + $normalized = []; + foreach ($properties as $key => $value) { + $key = strtolower((string) $key); + if ($key === '' || array_key_exists($key, $normalized)) { + return null; + } + $normalized[$key] = $value; + } + ksort($normalized); + + return $normalized; + } + + /** + * @param array|null $properties + * @return array{memory: int, cpu: int, disk: int}|null + */ + private function resourceVectorFromProperties( + ?array $properties + ): ?array { + if ($properties === null) { + return null; + } + + $vector = []; + foreach (['memory', 'cpu', 'disk'] as $resource) { + $parsed = StrictInteger::parse( + $properties[$resource] ?? null + ) ?? StrictInteger::parseStoredDecimal( + $properties[$resource] ?? null + ); + if ($parsed === null || $parsed < 0) { + return null; + } + $vector[$resource] = $parsed; + } + + return $vector; + } + + /** + * @param array|null $properties + */ + private function locationFromProperties( + ?array $properties + ): ?int { + if ($properties === null) { + return null; + } + + $location = StrictInteger::parse( + $properties['location'] ?? null + ) ?? StrictInteger::parseStoredDecimal( + $properties['location'] ?? null + ); + if ($location !== null) { + return $location > 0 ? $location : null; + } + + $locationIds = $properties['location_ids'] ?? null; + if (is_string($locationIds)) { + $decoded = json_decode($locationIds, true); + $locationIds = json_last_error() === JSON_ERROR_NONE + && is_array($decoded) + ? $decoded + : [$locationIds]; + } elseif (! is_array($locationIds)) { + $locationIds = [$locationIds]; + } + if (! array_is_list($locationIds) || count($locationIds) !== 1) { + return null; + } + + $location = StrictInteger::parse($locationIds[0]) + ?? StrictInteger::parseStoredDecimal($locationIds[0]); + + return $location !== null && $location > 0 + ? $location + : null; + } + + /** + * @param array $source + * @param array $target + */ + private function onlyResourcesChanged( + array $source, + array $target + ): bool { + $keys = array_unique(array_merge( + array_keys($source), + array_keys($target) + )); + foreach ($keys as $key) { + if (in_array($key, ['memory', 'cpu', 'disk'], true)) { + continue; + } + if ( + ! array_key_exists($key, $source) + || ! array_key_exists($key, $target) + || $source[$key] !== $target[$key] + ) { + return false; + } + } + + return true; + } + + /** + * @param array $source + * @param array $target + */ + private function snapshotIdentityMatches( + array $source, + array $target, + int $serviceId, + int $productId, + int $planId, + string $currencyCode + ): bool { + return StrictInteger::parse($source['service_id'] ?? null) + === $serviceId + && StrictInteger::parse($target['service_id'] ?? null) + === $serviceId + && StrictInteger::parse($source['product_id'] ?? null) + === $productId + && StrictInteger::parse($target['product_id'] ?? null) + === $productId + && StrictInteger::parse($source['plan_id'] ?? null) + === $planId + && StrictInteger::parse($target['plan_id'] ?? null) + === $planId + && StrictInteger::parse($source['quantity'] ?? null) === 1 + && StrictInteger::parse($target['quantity'] ?? null) === 1 + && strtoupper((string) ($source['currency_code'] ?? '')) + === $currencyCode + && strtoupper((string) ($target['currency_code'] ?? '')) + === $currencyCode + && array_key_exists('billing_anchor', $source) + && array_key_exists('billing_anchor', $target) + && $source['billing_anchor'] === $target['billing_anchor']; + } + + private function lifecycleMatches( + string $reservationStatus, + string $upgradeStatus, + ?int $reservationGuard, + ?int $upgradeGuard, + int $upgradeId, + int $serviceId, + mixed $consumedAt + ): bool { + if ($reservationStatus === 'pending') { + return in_array( + $upgradeStatus, + ['pending', 'awaiting_payment'], + true + ) + && $reservationGuard === $upgradeId + && $upgradeGuard === $serviceId + && $consumedAt === null; + } + if ($reservationStatus === 'paid_committed') { + return in_array( + $upgradeStatus, + [ + 'paid_committed', + 'provisioning', + 'retryable_failed', + 'needs_attention', + ], + true + ) + && $reservationGuard === $upgradeId + && $upgradeGuard === $serviceId + && $consumedAt === null; + } + if ($reservationStatus !== 'confirmed' || $consumedAt === null) { + return false; + } + + return $upgradeStatus === 'completed' + && $reservationGuard === null + && $upgradeGuard === null; + } + + /** + * @return array{memory: int, cpu: int, disk: int}|null + */ + private function resourceVector(mixed $value): ?array + { + if (! is_array($value)) { + return null; + } + + $resources = []; + foreach (['memory', 'cpu', 'disk'] as $resource) { + $parsed = StrictInteger::parse($value[$resource] ?? null); + if ($parsed === null || $parsed < 0) { + return null; + } + $resources[$resource] = $parsed; + } + + return $resources; + } + + private function positiveInteger(mixed $value): ?int + { + $parsed = StrictInteger::parse($value); + + return $parsed !== null && $parsed > 0 ? $parsed : null; + } + + /** + * @return list|null + */ + private function positiveIntegerList(mixed $value): ?array + { + if (! is_array($value) || ! array_is_list($value)) { + return null; + } + + $normalized = []; + foreach ($value as $item) { + $parsed = $this->positiveInteger($item); + if ($parsed === null) { + return null; + } + $normalized[] = $parsed; + } + sort($normalized, SORT_NUMERIC); + + return $normalized !== [] + && count(array_unique($normalized)) === count($normalized) + ? $normalized + : null; + } + + /** + * @return array{ + * swap: int, + * io: int, + * threads: string|null, + * databases: int, + * allocations: int, + * backups: int + * }|null + */ + private function preservedBuild(mixed $value): ?array + { + if (! is_array($value)) { + return null; + } + + $threads = $value['threads'] ?? null; + $swap = StrictInteger::parse($value['swap'] ?? null); + $io = StrictInteger::parse($value['io'] ?? null); + $databases = StrictInteger::parse( + $value['databases'] ?? null + ); + $allocations = StrictInteger::parse( + $value['allocations'] ?? null + ); + $backups = StrictInteger::parse($value['backups'] ?? null); + if ( + ! array_key_exists('threads', $value) + || ($threads !== null && ! is_string($threads)) + || $swap === null + || $swap < 0 + || $io === null + || $io < 0 + || $databases === null + || $databases < 0 + || $allocations !== 0 + || $backups === null + || $backups < 0 + ) { + return null; + } + + return [ + 'swap' => $swap, + 'io' => $io, + 'threads' => $threads, + 'databases' => $databases, + 'allocations' => $allocations, + 'backups' => $backups, + ]; + } + + /** + * Database drivers do not agree on how to materialize DECIMAL(17, 2): + * the same value can arrive as "100.00", 100, or 9.9. Fingerprints must + * use one exact representation without accepting precision beyond cents. + */ + private function normalizedMoney(mixed $value): string + { + if (is_int($value)) { + $text = (string) $value; + } elseif (is_float($value) && is_finite($value)) { + $text = number_format($value, 2, '.', ''); + } elseif (is_string($value)) { + $text = $value; + } else { + throw new InvalidStockConfigurationException( + 'The upgrade quote amount is invalid.' + ); + } + + if ( + preg_match( + '/^(-?)(0|[1-9]\d*)(?:\.(\d+))?$/D', + $text, + $matches + ) !== 1 + ) { + throw new InvalidStockConfigurationException( + 'The upgrade quote amount is invalid.' + ); + } + + $fraction = $matches[3] ?? ''; + if ( + strlen($fraction) > 2 + && trim(substr($fraction, 2), '0') !== '' + ) { + throw new InvalidStockConfigurationException( + 'The upgrade quote amount exceeds cent precision.' + ); + } + + $fraction = str_pad(substr($fraction, 0, 2), 2, '0'); + $sign = ($matches[1] ?? '') === '-' + && ($matches[2] !== '0' || $fraction !== '00') + ? '-' + : ''; + + return $sign.$matches[2].'.'.$fraction; + } + + private function moneyIsPositive(string $amount): bool + { + return $amount[0] !== '-' + && $amount !== '0.00'; + } + + private function invoiceLifecycleMatches( + string $reservationStatus, + bool $positiveQuote, + ?int $invoiceId, + string $invoiceStatus, + ?int $invoiceUserId, + string $invoiceCurrency, + ?int $serviceUserId, + string $serviceCurrency, + ?int $atomicPaidInvoiceId + ): bool { + if (! $positiveQuote) { + return $invoiceId === null; + } + if ( + $invoiceId === null + || $invoiceUserId === null + || $serviceUserId === null + || $invoiceUserId !== $serviceUserId + || $invoiceCurrency !== $serviceCurrency + ) { + return false; + } + + if ($reservationStatus === 'pending') { + return $invoiceStatus === 'pending' + || ( + $invoiceStatus === 'paid' + && $atomicPaidInvoiceId !== null + && $invoiceId === $atomicPaidInvoiceId + ); + } + + return $invoiceStatus === 'paid'; + } +} diff --git a/Services/UpgradeReservationService.php b/Services/UpgradeReservationService.php new file mode 100644 index 0000000..baa7311 --- /dev/null +++ b/Services/UpgradeReservationService.php @@ -0,0 +1,1634 @@ +integrity = $integrity + ?? new UpgradeReservationIntegrityService; + } + + /** + * Reserve only the positive resource delta while retaining the immutable + * target vector for provisioning proof. + */ + public function reserveForUpgrade( + ServiceUpgrade $upgrade, + CarbonInterface $guaranteedUntil + ): UpgradeReservation { + $this->inventory->assertExclusiveProvisioningControl(); + $this->assertServiceAcceptsUpgrade($upgrade->service); + $context = $this->upgradeContext($upgrade, requireSourceMatch: true); + $fingerprint = $this->reservationFingerprint($upgrade, $context); + + return DB::transaction(function () use ( + $upgrade, + $guaranteedUntil, + $context, + $fingerprint + ): UpgradeReservation { + $this->lockCapacityScope( + $context['panel_identity'], + $context['location_id'] + ); + + DB::table('ptero_resource_reservations') + ->where('panel_identity', $context['panel_identity']) + ->where('location_id', $context['location_id']) + ->where(function (Builder $query): void { + $query->where(function (Builder $query): void { + $query->where('status', 'pending') + ->where('expires_at', '>', now()); + })->orWhere('status', 'paid_committed'); + }) + ->lockForUpdate() + ->get(); + + $existing = UpgradeReservation::query() + ->where('purpose', 'upgrade') + ->where('service_upgrade_id', $upgrade->id) + ->whereIn('status', ['pending', 'paid_committed']) + ->lockForUpdate() + ->first(); + + if ($existing?->status === 'paid_committed') { + if (! hash_equals( + (string) $existing->configuration_fingerprint, + $fingerprint + )) { + throw new DisplayException( + 'The paid upgrade commitment does not match this configuration.' + ); + } + $this->integrity->verifiedSnapshot($upgrade, $existing); + + return $existing; + } + + $excludeToken = $existing?->token; + if (! $this->resources->verifyNodeCapacity( + $context['node_id'], + $context['delta'], + 0, + $excludeToken + )) { + throw new StockUnavailableException( + 'The current node does not have enough stock for this resource upgrade.' + ); + } + + $payload = [ + 'service_upgrade_id' => (int) $upgrade->id, + 'source_fingerprint' => (string) $upgrade->source_fingerprint, + 'target_fingerprint' => (string) $upgrade->target_fingerprint, + 'panel_identity' => $context['panel_identity'], + 'node_id' => $context['node_id'], + 'location_id' => $context['location_id'], + 'external_server_id' => $context['external_server_id'], + 'external_server_uuid' => $context['external_server_uuid'], + 'external_server_identifier' => $context['external_server_identifier'], + 'external_server_external_id' => $context['external_server_external_id'], + 'external_user_id' => $context['external_user_id'], + 'user_external_id' => $context['user_external_id'], + 'user_email' => $context['user_email'], + 'nest_id' => $context['nest_id'], + 'egg_id' => $context['egg_id'], + 'preserved_build' => $context['preserved_build'], + 'allocation_id' => $context['allocation_id'], + 'assigned_allocation_ids' => $context['assigned_allocation_ids'], + 'source' => $context['source'], + 'target' => $context['target'], + 'delta' => $context['delta'], + ]; + $values = [ + 'purpose' => 'upgrade', + 'idempotency_key' => hash( + 'sha256', + "dynamic-upgrade:{$upgrade->id}:{$fingerprint}" + ), + 'cart_item_id' => null, + 'cart_item_guard_id' => null, + 'cart_id' => null, + 'server_extension_id' => $upgrade->product->server_id, + 'panel_identity' => $context['panel_identity'], + 'node_id' => $context['node_id'], + 'location_id' => $context['location_id'], + 'service_id' => $upgrade->service_id, + 'service_upgrade_id' => $upgrade->id, + 'upgrade_guard_id' => $upgrade->id, + 'invoice_id' => $upgrade->invoice_id, + 'user_id' => $upgrade->service->user_id, + 'product_id' => $upgrade->product_id, + 'plan_id' => $upgrade->plan_id, + 'quantity' => 1, + 'currency_code' => strtoupper((string) $upgrade->currency_code), + 'configuration_fingerprint' => $fingerprint, + 'configuration_payload' => $payload, + 'pricing_version' => $this->integrity->pricingVersion($upgrade), + 'formula_version' => 'dynamic-upgrade-v1', + // Full target values are provisioning truth. + 'memory' => $context['target']['memory'], + 'cpu' => $context['target']['cpu'], + 'disk' => $context['target']['disk'], + // Capacity accounting uses only positive deltas. + 'reserved_memory' => $context['delta']['memory'], + 'reserved_cpu' => $context['delta']['cpu'], + 'reserved_disk' => $context['delta']['disk'], + 'calculated_price' => (float) $upgrade->quoted_amount, + 'external_server_id' => $context['external_server_id'], + 'external_user_id' => $context['external_user_id'], + 'external_server_uuid' => $context['external_server_uuid'], + 'external_server_identifier' => $context['external_server_identifier'], + 'pricing_breakdown' => [ + 'kind' => 'resource_upgrade', + 'source' => $context['source'], + 'target' => $context['target'], + 'delta' => $context['delta'], + ], + 'status' => 'pending', + 'expires_at' => $guaranteedUntil, + 'guaranteed_until' => $guaranteedUntil, + 'admin_notes' => 'Capacity held for an immutable service upgrade.', + ]; + + if ($existing !== null) { + $existing->fill($values); + $existing->save(); + $saved = $existing->fresh(); + } else { + $saved = UpgradeReservation::create(array_merge($values, [ + 'token' => Str::random(64), + ]))->fresh(); + } + + $this->integrity->verifiedSnapshot($upgrade, $saved); + + return $saved; + }, 5); + } + + /** + * Fulfillment-owned invoice processing calls this for every upgrade while + * this extension is installed, so non-dynamic upgrades deliberately + * delegate to the core lifecycle. + */ + public function commitPaidUpgrade( + ServiceUpgrade $upgrade, + ?Invoice $invoice + ): bool { + $upgrade->loadMissing(['service.product.configOptions', 'product.configOptions']); + if (! $this->usesDynamicCapacity($upgrade)) { + app(ServiceUpgradeService::class)->markPaidCommitted($upgrade); + + return true; + } + + DB::transaction(function () use ($upgrade, $invoice): void { + $lockedInvoice = $invoice !== null + ? Invoice::query() + ->whereKey($invoice->id) + ->lockForUpdate() + ->firstOrFail() + : null; + // Serialize the final billing-anchor proof with renewal, + // cancellation, and any other service mutation. The earlier + // preflight is advisory unless the service is locked again inside + // the transaction that commits the paid upgrade. + Service::query() + ->whereKey($upgrade->service_id) + ->lockForUpdate() + ->firstOrFail(); + $lockedUpgrade = ServiceUpgrade::query() + ->with(['service.product.configOptions', 'product.configOptions']) + ->lockForUpdate() + ->findOrFail($upgrade->id); + $reservation = UpgradeReservation::query() + ->where('purpose', 'upgrade') + ->where('service_upgrade_id', $lockedUpgrade->id) + ->lockForUpdate() + ->firstOrFail(); + + $alreadyPaid = $reservation->status === 'paid_committed'; + if (! $alreadyPaid && $reservation->status !== 'pending') { + throw new DisplayException( + 'The upgrade capacity commitment is no longer payable.' + ); + } + if ( + ! $alreadyPaid + && $reservation->guaranteed_until?->isPast() + ) { + throw new DisplayException( + 'The upgrade capacity guarantee has expired.' + ); + } + if ( + $lockedInvoice !== null + && $lockedUpgrade->invoice_id !== null + && (int) $lockedUpgrade->invoice_id + !== (int) $lockedInvoice->id + ) { + throw new \RuntimeException( + 'The paid invoice does not belong to this service upgrade.' + ); + } + if ( + $lockedInvoice !== null + && $lockedInvoice->status !== Invoice::STATUS_PAID + ) { + throw new \RuntimeException( + 'The upgrade invoice is not paid.' + ); + } + if ( + $lockedInvoice === null + && ((float) $lockedUpgrade->quoted_amount > 0 + || $lockedUpgrade->invoice_id !== null) + ) { + throw new \RuntimeException( + 'Only an unbilled zero-value or downgrade can be committed without an invoice.' + ); + } + $this->assertServiceAcceptsUpgrade($lockedUpgrade->service); + if ($lockedInvoice !== null) { + $invoiceMismatch = $this->invoiceMismatch( + $lockedUpgrade, + $lockedInvoice + ); + if ($invoiceMismatch !== null) { + throw new \RuntimeException($invoiceMismatch); + } + } + if ( + ($integrityFailure = $this->reservationIntegrityError( + $lockedUpgrade, + $reservation, + ! $alreadyPaid ? $lockedInvoice : null + )) !== null + ) { + throw new PermanentProvisioningException($integrityFailure); + } + + if (! $alreadyPaid) { + $reservation->forceFill([ + 'status' => 'paid_committed', + 'invoice_id' => $lockedInvoice?->id, + 'paid_committed_at' => now(), + 'last_provisioning_error' => null, + ])->save(); + } + + app(ServiceUpgradeService::class)->markPaidCommitted($lockedUpgrade); + }, 5); + + return true; + } + + /** + * Run while the invoice coordinator holds its paid transaction open. + * Invalid commitments are persisted and surfaced without throwing so + * external payment evidence and the needs-attention state commit together. + */ + public function preflightPaidUpgrade( + ServiceUpgrade $upgrade, + Invoice $invoice + ): ?string { + return DB::transaction(function () use ($upgrade, $invoice): ?string { + $lockedInvoice = Invoice::query() + ->whereKey($invoice->id) + ->lockForUpdate() + ->firstOrFail(); + Service::query() + ->whereKey($upgrade->service_id) + ->lockForUpdate() + ->firstOrFail(); + $lockedUpgrade = ServiceUpgrade::query() + ->with([ + 'service.product.settings', + 'service.configs.configOption', + 'service.configs.configValue', + 'product.configOptions', + ]) + ->lockForUpdate() + ->findOrFail($upgrade->id); + + if (! $this->usesDynamicCapacity($lockedUpgrade)) { + return null; + } + + $reservation = UpgradeReservation::query() + ->where('purpose', 'upgrade') + ->where('service_upgrade_id', $lockedUpgrade->id) + ->lockForUpdate() + ->first(); + $reason = null; + + if ( + $lockedInvoice->status !== Invoice::STATUS_PENDING + || (int) $lockedUpgrade->invoice_id !== (int) $lockedInvoice->id + ) { + $reason = 'The upgrade invoice is no longer payable.'; + } elseif ( + $lockedUpgrade->service->status !== Service::STATUS_ACTIVE + || $lockedUpgrade->service->cancellation()->exists() + ) { + $reason = 'The service is no longer eligible for this resource upgrade.'; + } elseif ( + ($invoiceMismatch = $this->invoiceMismatch( + $lockedUpgrade, + $lockedInvoice + )) !== null + ) { + $reason = $invoiceMismatch; + } elseif ( + $reservation === null + || $reservation->status !== 'pending' + || $reservation->guaranteed_until?->isPast() + ) { + $reason = 'The upgrade capacity guarantee has expired.'; + } elseif (! $lockedUpgrade->sourceStillMatches()) { + $reason = 'The service changed after the upgrade was quoted.'; + } elseif ( + ($integrityFailure = $this->reservationIntegrityError( + $lockedUpgrade, + $reservation + )) !== null + ) { + $reason = $integrityFailure; + } + + if ($reason === null) { + return null; + } + + $paymentAttention = app( + CapacityInvoicePaymentService::class + )->hasInFlightOrSucceededPayment($lockedInvoice); + $lockedUpgrade->forceFill([ + 'status' => $paymentAttention + ? ServiceUpgrade::STATUS_NEEDS_ATTENTION + : ServiceUpgrade::STATUS_CANCELLED, + 'active_service_guard_id' => $paymentAttention + ? $lockedUpgrade->service_id + : null, + 'last_error' => $paymentAttention + ? "{$reason} External payment evidence exists; refund or account-credit review is required." + : $reason, + 'failed_at' => now(), + ]); + ServiceUpgradeMutationCoordinator::save($lockedUpgrade); + if ($reservation !== null && $reservation->status === 'pending') { + $reservation->forceFill([ + 'status' => $reservation->guaranteed_until?->isPast() + ? 'expired' + : 'cancelled', + 'upgrade_guard_id' => null, + 'admin_notes' => $reason, + ])->save(); + } + if ( + ! $paymentAttention + && $lockedInvoice->status === Invoice::STATUS_PENDING + ) { + app(CancelInvoiceService::class) + ->markCancelledAfterFulfillment($lockedInvoice); + } + + return $reason; + }, 5); + } + + public function expireUnpaidUpgrades(int $limit = 100): int + { + $limit = max(1, min($limit, 500)); + + return app(SchedulerHealthService::class)->processEligibleRows( + SchedulerHealthService::TASK_EXPIRE_UPGRADES, + 'resource_reservation', + $limit, + fn () => UpgradeReservation::query() + ->where('purpose', 'upgrade') + ->where('status', 'pending') + ->where('guaranteed_until', '<=', now()), + function (int $reservationId): bool { + return DB::transaction(function () use ( + $reservationId + ): bool { + $candidate = UpgradeReservation::query() + ->find($reservationId); + if ($candidate === null) { + return false; + } + + $invoice = $candidate->invoice_id !== null + ? Invoice::query() + ->whereKey($candidate->invoice_id) + ->lockForUpdate() + ->first() + : null; + $serviceId = ServiceUpgrade::query() + ->whereKey($candidate->service_upgrade_id) + ->value('service_id'); + if ($serviceId !== null) { + Service::query() + ->whereKey($serviceId) + ->lockForUpdate() + ->firstOrFail(); + } + $upgrade = ServiceUpgrade::query() + ->whereKey($candidate->service_upgrade_id) + ->lockForUpdate() + ->first(); + $reservation = UpgradeReservation::query() + ->whereKey($reservationId) + ->lockForUpdate() + ->first(); + if ($invoice !== null) { + $invoice->items() + ->orderBy('id') + ->lockForUpdate() + ->get(); + } + + if ( + $reservation === null + || $reservation->status !== 'pending' + || $reservation->guaranteed_until?->isFuture() + || $invoice?->status === Invoice::STATUS_PAID + ) { + return false; + } + + $reason = 'The unpaid upgrade capacity guarantee expired.'; + $paymentAttention = $invoice !== null + && $invoice->status === Invoice::STATUS_PENDING + && app(CapacityInvoicePaymentService::class) + ->hasInFlightOrSucceededPayment($invoice); + $reservation->forceFill([ + 'status' => 'expired', + 'upgrade_guard_id' => null, + 'admin_notes' => $reason, + ])->save(); + if ($upgrade !== null && in_array($upgrade->status, [ + ServiceUpgrade::STATUS_PENDING, + ServiceUpgrade::STATUS_AWAITING_PAYMENT, + ], true)) { + $upgrade->forceFill([ + 'status' => $paymentAttention + ? ServiceUpgrade::STATUS_NEEDS_ATTENTION + : ServiceUpgrade::STATUS_CANCELLED, + 'active_service_guard_id' => $paymentAttention + ? $upgrade->service_id + : null, + 'last_error' => $paymentAttention + ? 'The capacity guarantee expired with payment activity; refund or account-credit review is required.' + : $reason, + 'failed_at' => now(), + ]); + ServiceUpgradeMutationCoordinator::save($upgrade); + } + if ($paymentAttention) { + app(CapacityInvoicePaymentService::class) + ->requireAttention( + $invoice, + 'The seven-day upgrade capacity guarantee expired after a partial or in-flight payment. Capacity was released; refund or account-credit review is required.' + ); + } elseif ( + $invoice?->status === Invoice::STATUS_PENDING + ) { + app(CancelInvoiceService::class) + ->markCancelledAfterFulfillment($invoice); + } + + return true; + }, 5); + } + ); + } + + public function reconcileStalledUpgrades(int $limit = 100): int + { + $limit = max(1, min($limit, 500)); + + return app(SchedulerHealthService::class)->processEligibleRows( + SchedulerHealthService::TASK_RECONCILE_UPGRADES, + 'service_upgrade', + $limit, + fn () => ServiceUpgrade::query() + ->where('status', ServiceUpgrade::STATUS_PROVISIONING) + ->where( + 'provisioning_started_at', + '<=', + now()->subMinutes(10) + ), + function (int $upgradeId): bool { + return DB::transaction(function () use ( + $upgradeId + ): bool { + $upgrade = ServiceUpgrade::query() + ->whereKey($upgradeId) + ->lockForUpdate() + ->first(); + $reservation = UpgradeReservation::query() + ->where('purpose', 'upgrade') + ->where('service_upgrade_id', $upgradeId) + ->lockForUpdate() + ->first(); + + if ( + $upgrade === null + || $reservation === null + || $upgrade->status + !== ServiceUpgrade::STATUS_PROVISIONING + || $upgrade->provisioning_started_at?->isAfter( + now()->subMinutes(10) + ) + || $reservation->status !== 'paid_committed' + ) { + return false; + } + + $message = 'A stale upgrade worker lease was recovered for retry.'; + $reservation->forceFill([ + 'provisioning_lease_id' => null, + 'provisioning_started_at' => null, + 'last_provisioning_error' => $message, + ])->save(); + $upgrade->forceFill([ + 'status' => ServiceUpgrade::STATUS_RETRYABLE_FAILED, + 'last_error' => $message, + 'failed_at' => now(), + ]); + ServiceUpgradeMutationCoordinator::save($upgrade); + + DB::afterCommit( + fn () => app( + ServiceUpgradeDispatchRecoveryService::class + )->dispatchById($upgradeId) + ); + + return true; + }, 5); + } + ); + } + + /** + * Acquire a retry-safe provisioning lease and return the exact contract + * passed to the built-in Pterodactyl extension. + * + * @return array + */ + public function beginProvisioning(ServiceUpgrade $upgrade): array + { + return DB::transaction(function () use ($upgrade): array { + $service = Service::query() + ->whereKey($upgrade->service_id) + ->lockForUpdate() + ->firstOrFail(); + $lockedUpgrade = ServiceUpgrade::query() + ->whereKey($upgrade->id) + ->lockForUpdate() + ->firstOrFail(); + if ((int) $lockedUpgrade->service_id !== (int) $service->id) { + throw new PermanentProvisioningException( + 'The paid upgrade no longer belongs to its locked service.' + ); + } + if (! $this->serviceAcceptsUpgrade($service)) { + throw new PermanentProvisioningException( + 'The service was cancelled before its paid upgrade could be applied.' + ); + } + $reservation = UpgradeReservation::query() + ->where('purpose', 'upgrade') + ->where('service_upgrade_id', $lockedUpgrade->id) + ->lockForUpdate() + ->firstOrFail(); + + if ($reservation->status !== 'paid_committed') { + throw new PermanentProvisioningException( + 'The upgrade does not have a paid capacity commitment.' + ); + } + + if ( + $reservation->provisioning_lease_id !== null + && $reservation->provisioning_started_at?->gt(now()->subMinutes(10)) + ) { + throw new \RuntimeException( + 'Another worker currently owns this upgrade provisioning lease.' + ); + } + + $payload = (array) $reservation->configuration_payload; + if ( + ($integrityFailure = $this->reservationIntegrityError( + $lockedUpgrade, + $reservation + )) !== null + ) { + throw new PermanentProvisioningException( + $integrityFailure + ); + } + + $leaseId = (string) Str::uuid(); + $reservation->forceFill([ + 'provisioning_lease_id' => $leaseId, + 'provisioning_started_at' => now(), + 'provisioning_attempts' => (int) $reservation->provisioning_attempts + 1, + 'last_provisioning_attempt_at' => now(), + 'last_provisioning_error' => null, + ])->save(); + + return [ + 'reservation_id' => (int) $reservation->id, + 'provisioning_lease_id' => $leaseId, + 'panel_identity' => (string) $reservation->panel_identity, + 'node_id' => (int) $reservation->node_id, + 'location_id' => (int) $reservation->location_id, + 'source' => (array) ($payload['source'] ?? []), + 'target' => (array) ($payload['target'] ?? []), + 'external_server_id' => (int) ($payload['external_server_id'] ?? 0), + 'external_server_uuid' => (string) ($payload['external_server_uuid'] ?? ''), + 'external_server_identifier' => (string) ($payload['external_server_identifier'] ?? ''), + 'external_server_external_id' => (string) ($payload['external_server_external_id'] ?? ''), + 'external_user_id' => (int) ($payload['external_user_id'] ?? 0), + 'user_external_id' => (string) ($payload['user_external_id'] ?? ''), + 'user_email' => (string) ($payload['user_email'] ?? ''), + 'nest_id' => (int) ($payload['nest_id'] ?? 0), + 'egg_id' => (int) ($payload['egg_id'] ?? 0), + 'preserved_build' => (array) ( + $payload['preserved_build'] ?? [] + ), + 'allocation_id' => (int) ($payload['allocation_id'] ?? 0), + 'assigned_allocation_ids' => array_values(array_map( + 'intval', + (array) ($payload['assigned_allocation_ids'] ?? []) + )), + ]; + }, 5); + } + + public function completeProvisioning( + ServiceUpgrade $upgrade, + ?string $leaseId + ): void { + if (DB::transactionLevel() === 0) { + throw new \RuntimeException( + 'Upgrade completion requires the core completion transaction.' + ); + } + if ($leaseId === null || $leaseId === '') { + throw new \RuntimeException('An upgrade provisioning lease is required.'); + } + + $reservation = UpgradeReservation::query() + ->where('purpose', 'upgrade') + ->where('service_upgrade_id', $upgrade->id) + ->lockForUpdate() + ->firstOrFail(); + + if ($reservation->status === 'confirmed') { + if ($upgrade->status !== ServiceUpgrade::STATUS_COMPLETED) { + throw new \RuntimeException( + 'The confirmed capacity commitment has no completed core upgrade.' + ); + } + + return; + } + if ( + $upgrade->status !== ServiceUpgrade::STATUS_PROVISIONING + || + $reservation->status !== 'paid_committed' + || ! hash_equals((string) $reservation->provisioning_lease_id, $leaseId) + ) { + throw new \RuntimeException( + 'The upgrade provisioning lease is stale or invalid.' + ); + } + if ( + ($integrityFailure = $this->reservationIntegrityError( + $upgrade, + $reservation + )) !== null + ) { + throw new \RuntimeException($integrityFailure); + } + + $reservation->forceFill([ + 'status' => 'confirmed', + 'upgrade_guard_id' => null, + 'provisioning_lease_id' => null, + 'consumed_at' => now(), + 'last_provisioning_error' => null, + ])->save(); + } + + public function failProvisioning( + ServiceUpgrade $upgrade, + \Throwable $exception, + ?string $leaseId = null + ): bool { + $reservation = UpgradeReservation::query() + ->where('purpose', 'upgrade') + ->where('service_upgrade_id', $upgrade->id) + ->lockForUpdate() + ->first(); + if ($reservation === null || $reservation->status !== 'paid_committed') { + return false; + } + if ($leaseId === null) { + if ($reservation->provisioning_lease_id !== null) { + return false; + } + } elseif ( + $reservation->provisioning_lease_id === null + || ! hash_equals( + (string) $reservation->provisioning_lease_id, + $leaseId + ) + ) { + return false; + } + + $reservation->forceFill([ + 'provisioning_lease_id' => null, + 'provisioning_started_at' => null, + 'last_provisioning_error' => mb_substr($exception->getMessage(), 0, 65535), + ])->save(); + + return true; + } + + public function cancelUpgrade(ServiceUpgrade $upgrade, string $reason): void + { + $reservation = UpgradeReservation::query() + ->where('purpose', 'upgrade') + ->where('service_upgrade_id', $upgrade->id) + ->lockForUpdate() + ->first(); + if ($reservation === null || $reservation->status === 'cancelled') { + return; + } + if ($reservation->status !== 'pending') { + throw new \RuntimeException( + 'Only an unpaid upgrade capacity hold can be cancelled.' + ); + } + + $reservation->forceFill([ + 'status' => 'cancelled', + 'upgrade_guard_id' => null, + 'admin_notes' => $reason, + ])->save(); + } + + /** + * Authenticated, customer-safe bounds for an existing server's fixed node. + * + * @param array $submittedOptions + * @return array + */ + public function quoteForService(Service $service, array $submittedOptions): array + { + if (DB::transactionLevel() === 0) { + return DB::transaction( + fn (): array => $this->quoteForService( + $service, + $submittedOptions + ), + 5 + ); + } + + $lockedProduct = app( + CapacityConfigurationLockService::class + )->lockProduct((int) $service->product_id); + $service->setRelation('product', $lockedProduct); + $this->inventory->assertExclusiveProvisioningControl(); + $service->loadMissing([ + 'product.upgradableConfigOptions', + 'product.server.settings', + 'configs.configOption', + ]); + $this->assertServiceAcceptsUpgrade($service); + + if (! $service->product->usesDynamicResources()) { + throw new InvalidStockConfigurationException( + 'This service does not use dynamic resources.' + ); + } + + $this->assertProductPanel($service); + $server = $this->inventory->serverByExternalId($service->id); + $checkoutIdentity = $this->checkoutServerIdentity($service); + $this->assertExternalServerMatchesCheckout( + $server, + $checkoutIdentity, + (int) $service->id + ); + $node = collect($this->inventory->nodes())->firstWhere('id', $server['node']); + if (! is_array($node)) { + throw new InvalidStockConfigurationException( + 'The service node is missing from Pterodactyl inventory.' + ); + } + + $allOptions = $service->product->upgradableConfigOptions->keyBy('id'); + $options = $allOptions + ->filter(fn ($option): bool => $option->isDynamicSlider()); + $submitted = collect($submittedOptions) + ->mapWithKeys(fn ($value, $key): array => [(int) $key => $value]); + $unknown = $submitted->keys()->diff($allOptions->keys()); + if ($unknown->isNotEmpty()) { + throw new InvalidResourceSelectionException( + 'The resource upgrade contains an unknown option.' + ); + } + + $selection = [ + 'memory' => $server['memory'], + 'cpu' => $server['cpu'], + 'disk' => $server['disk'], + ]; + $sliders = []; + foreach ($options as $option) { + $resource = strtolower((string) $option->getMetadata('resource_type', '')); + if (! in_array($resource, ['memory', 'cpu', 'disk'], true)) { + continue; + } + if (array_key_exists($resource, $sliders)) { + throw new InvalidStockConfigurationException( + "Multiple active {$resource} sliders are attached to this product." + ); + } + + $minimum = $this->metadataInteger($option, 'min'); + $maximum = $this->metadataInteger($option, 'max'); + $step = $this->metadataInteger($option, 'step'); + $value = $submitted->has($option->id) + ? $option->normalizeDynamicSliderValue($submitted->get($option->id)) + : $selection[$resource]; + $selection[$resource] = $value; + $sliders[$resource] = [ + 'config_option_id' => (int) $option->id, + 'min' => $minimum, + 'max' => $maximum, + 'step' => $step, + ]; + } + + if ($sliders === []) { + throw new InvalidStockConfigurationException( + 'No dynamic resource is enabled for upgrades.' + ); + } + + $availability = $this->resources->getNodeAvailability($server['node']); + if ($availability === null) { + throw new StockUnavailableException( + 'The current server node is not available.' + ); + } + + $bounds = []; + $adjusted = false; + foreach ($sliders as $resource => $slider) { + $minimum = $resource === 'disk' + ? $this->snapUp( + max($slider['min'], $server['disk']), + $slider['min'], + $slider['step'] + ) + : $slider['min']; + $maximum = $this->snapDown( + min( + $slider['max'], + $server[$resource] + (int) $availability['available'][$resource] + ), + $slider['min'], + $slider['step'] + ); + + if ($maximum < $minimum) { + throw new StockUnavailableException( + "The current node cannot satisfy the {$resource} upgrade minimum." + ); + } + + $candidate = min($maximum, max($minimum, $selection[$resource])); + $candidate = $this->snapDown( + $candidate, + $slider['min'], + $slider['step'] + ); + if ($candidate !== $selection[$resource]) { + $selection[$resource] = $candidate; + $adjusted = true; + } + + $bounds[$resource] = [ + 'config_option_id' => $slider['config_option_id'], + 'min' => $minimum, + 'max' => $maximum, + 'configured_max' => $slider['max'], + 'step' => $slider['step'], + ]; + } + + $delta = $this->positiveDelta($server, $selection); + if (! $this->resources->verifyNodeCapacity($server['node'], $delta, 0)) { + throw new StockUnavailableException( + 'The current node cannot satisfy the complete resource upgrade.' + ); + } + + return [ + 'available' => true, + 'adjusted' => $adjusted, + 'selection' => $selection, + 'bounds' => $bounds, + ]; + } + + /** + * @return array + */ + private function upgradeContext( + ServiceUpgrade $upgrade, + bool $requireSourceMatch + ): array { + $upgrade->loadMissing([ + 'service.product.server.settings', + 'service.product.configOptions', + 'service.user', + 'product.server.settings', + 'product.configOptions', + ]); + $this->assertServiceAcceptsUpgrade($upgrade->service); + if (! $this->usesDynamicCapacity($upgrade)) { + throw new InvalidStockConfigurationException( + 'This upgrade does not use dynamic capacity.' + ); + } + if ((int) $upgrade->product_id !== (int) $upgrade->service->product_id) { + throw new InvalidResourceSelectionException( + 'Dynamic resource upgrades cannot change products.' + ); + } + if ((int) $upgrade->plan_id !== (int) $upgrade->service->plan_id) { + throw new InvalidResourceSelectionException( + 'Dynamic resource upgrades cannot change billing plans.' + ); + } + $this->assertOnlyResourcePropertiesChanged($upgrade); + + $this->assertProductPanel($upgrade->service); + $server = $this->inventory->serverByExternalId($upgrade->service_id); + $checkoutIdentity = $this->checkoutServerIdentity( + $upgrade->service + ); + $node = collect($this->inventory->nodes())->firstWhere('id', $server['node']); + if (! is_array($node)) { + throw new InvalidStockConfigurationException( + 'The existing Pterodactyl node is unavailable.' + ); + } + + $this->assertExternalServerMatchesCheckout( + $server, + $checkoutIdentity, + (int) $upgrade->service_id + ); + $sourceProperties = (array) data_get( + $upgrade->source_snapshot, + 'properties', + [] + ); + $source = $this->resourceVector($sourceProperties, 'source'); + $targetResources = $upgrade->targetResources(); + $targetLocation = $targetResources['location']; + $target = $targetResources; + unset($target['location']); + if ($source === $target) { + throw new InvalidResourceSelectionException( + 'A dynamic upgrade must change at least one of RAM, CPU, or disk.' + ); + } + + if ($requireSourceMatch) { + foreach (['memory', 'cpu', 'disk'] as $resource) { + if ((int) $server[$resource] !== $source[$resource]) { + throw new InvalidResourceSelectionException( + "Pterodactyl {$resource} no longer matches the service being upgraded." + ); + } + } + } + + if ( + $targetLocation <= 0 + || $targetLocation !== (int) $node['location_id'] + ) { + throw new InvalidResourceSelectionException( + 'Dynamic upgrades must remain in the existing Pterodactyl location.' + ); + } + if ($target['disk'] < $server['disk']) { + throw new InvalidResourceSelectionException( + 'Pterodactyl disk cannot be reduced by this upgrade workflow.' + ); + } + + return [ + 'panel_identity' => $checkoutIdentity['panel_identity'], + 'node_id' => (int) $server['node'], + 'location_id' => (int) $node['location_id'], + 'external_server_id' => $checkoutIdentity['id'], + 'external_server_uuid' => $checkoutIdentity['uuid'], + 'external_server_identifier' => $checkoutIdentity['identifier'], + 'external_server_external_id' => (string) $upgrade->service_id, + 'external_user_id' => $checkoutIdentity['external_user_id'], + 'user_external_id' => $checkoutIdentity['user_external_id'], + 'user_email' => $checkoutIdentity['user_email'], + 'nest_id' => $checkoutIdentity['nest_id'], + 'egg_id' => $checkoutIdentity['egg_id'], + 'preserved_build' => $this->preservedBuild($server), + 'allocation_id' => (int) $server['allocation'], + 'assigned_allocation_ids' => $this->assignedAllocationIds($server), + 'source' => $source, + 'target' => $target, + 'delta' => $this->positiveDelta($server, $target), + ]; + } + + /** + * The original checkout commitment is the durable proof that this exact + * external server—not merely any server reusing the same external ID—is + * eligible for an in-place resource upgrade. + * + * @return array{ + * id: int, + * uuid: string, + * identifier: string, + * panel_identity: string, + * external_user_id: int, + * nest_id: int, + * egg_id: int, + * user_external_id: string, + * user_email: string + * } + */ + private function checkoutServerIdentity(Service $service): array + { + $reservation = DB::table('ptero_resource_reservations') + ->where('purpose', 'checkout') + ->where('service_id', $service->id) + ->where('status', 'confirmed') + ->orderByDesc('id') + ->first(); + $id = StrictInteger::parse($reservation?->external_server_id); + $externalUserId = StrictInteger::parse( + $reservation?->external_user_id + ); + $uuid = $reservation?->external_server_uuid; + $identifier = $reservation?->external_server_identifier; + try { + $payload = json_decode( + (string) ($reservation?->configuration_payload ?? ''), + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (\JsonException $exception) { + throw new InvalidStockConfigurationException( + 'The confirmed checkout commitment is unreadable.', + previous: $exception + ); + } + $configuration = new ReservationConfigurationService; + $identity = is_array($payload) + ? (array) ($payload['provisioning_identity'] ?? []) + : []; + $panelIdentity = is_array($payload) + ? (string) ($payload['panel_identity'] ?? '') + : ''; + $nestId = StrictInteger::parse($identity['nest_id'] ?? null); + $eggId = StrictInteger::parse($identity['egg_id'] ?? null); + $userExternalId = trim((string) ( + $identity['user_external_id'] ?? '' + )); + $userEmail = strtolower(trim((string) ( + $identity['user_email'] ?? '' + ))); + $expectedUserExternalId = "paymenter-user-{$service->user_id}"; + if ( + $reservation === null + || $id === null + || $id <= 0 + || $externalUserId === null + || $externalUserId <= 0 + || ! is_string($uuid) + || ! Str::isUuid($uuid) + || ! is_string($identifier) + || trim($identifier) === '' + || ! is_array($payload) + || ! hash_equals( + (string) $reservation->configuration_fingerprint, + $configuration->fingerprint($payload) + ) + || (int) $reservation->user_id !== (int) $service->user_id + || (int) $reservation->product_id !== (int) $service->product_id + || (int) $reservation->plan_id !== (int) $service->plan_id + || (int) ($payload['customer_id'] ?? 0) + !== (int) $reservation->user_id + || (int) ($payload['product_id'] ?? 0) + !== (int) $reservation->product_id + || (int) ($payload['plan_id'] ?? 0) + !== (int) $reservation->plan_id + || ! is_string($reservation->panel_identity) + || preg_match('/^[a-f0-9]{64}$/D', $panelIdentity) !== 1 + || ! hash_equals( + (string) $reservation->panel_identity, + $panelIdentity + ) + || ! hash_equals( + $this->inventory->panelIdentity(), + $panelIdentity + ) + || $nestId === null + || $nestId <= 0 + || $eggId === null + || $eggId <= 0 + || $userEmail === '' + || ! hash_equals($expectedUserExternalId, $userExternalId) + ) { + throw new InvalidStockConfigurationException( + 'Dynamic upgrades require the exact external server identity from a confirmed checkout commitment.' + ); + } + + return [ + 'id' => $id, + 'uuid' => $uuid, + 'identifier' => trim($identifier), + 'panel_identity' => $panelIdentity, + 'external_user_id' => $externalUserId, + 'nest_id' => $nestId, + 'egg_id' => $eggId, + 'user_external_id' => $userExternalId, + 'user_email' => $userEmail, + ]; + } + + /** + * @param array $server + * @param array $checkoutIdentity + */ + private function assertExternalServerMatchesCheckout( + array $server, + array $checkoutIdentity, + int $serviceId + ): void { + if ( + (int) ($server['id'] ?? 0) !== $checkoutIdentity['id'] + || ! hash_equals( + $checkoutIdentity['uuid'], + (string) ($server['uuid'] ?? '') + ) + || ! hash_equals( + $checkoutIdentity['identifier'], + (string) ($server['identifier'] ?? '') + ) + || ! hash_equals( + (string) $serviceId, + (string) ($server['external_id'] ?? '') + ) + || StrictInteger::parse($server['user_id'] ?? null) === null + || (int) $server['user_id'] + !== $checkoutIdentity['external_user_id'] + || ! hash_equals( + $checkoutIdentity['user_external_id'], + (string) ($server['user_external_id'] ?? '') + ) + || (int) ($server['nest_id'] ?? 0) + !== $checkoutIdentity['nest_id'] + || (int) ($server['egg_id'] ?? 0) + !== $checkoutIdentity['egg_id'] + ) { + throw new InvalidResourceSelectionException( + 'The Pterodactyl server identity no longer matches the immutable checkout commitment.' + ); + } + } + + private function assertProductPanel(Service $service): void + { + $server = $service->product->server; + if ($server === null || $server->extension !== 'Pterodactyl') { + throw new InvalidStockConfigurationException( + 'Dynamic resources require the Pterodactyl server extension.' + ); + } + + $settings = ExtensionHelper::settingsToArray($server->settings); + $host = trim((string) ($settings['host'] ?? '')); + try { + $panelIdentity = PanelEndpointIdentity::hash($host); + } catch (\InvalidArgumentException) { + $panelIdentity = ''; + } + if ( + $panelIdentity === '' + || ! hash_equals( + $this->inventory->panelIdentity(), + $panelIdentity + ) + ) { + throw new InvalidStockConfigurationException( + 'The stock service and provisioner target different Pterodactyl panels.' + ); + } + } + + private function assertOnlyResourcePropertiesChanged( + ServiceUpgrade $upgrade + ): void { + $source = collect((array) data_get( + $upgrade->source_snapshot, + 'properties', + [] + ))->mapWithKeys(fn ($value, $key): array => [ + strtolower((string) $key) => $value, + ])->all(); + $target = collect((array) data_get( + $upgrade->target_snapshot, + 'properties', + [] + ))->mapWithKeys(fn ($value, $key): array => [ + strtolower((string) $key) => $value, + ])->all(); + $keys = array_unique(array_merge( + array_keys($source), + array_keys($target) + )); + + foreach ($keys as $key) { + if (in_array($key, ['memory', 'cpu', 'disk'], true)) { + continue; + } + if ( + ! array_key_exists($key, $source) + || ! array_key_exists($key, $target) + || $source[$key] !== $target[$key] + ) { + throw new InvalidResourceSelectionException( + 'Dynamic upgrades may change only RAM, CPU, and disk.' + ); + } + } + } + + private function lockCapacityScope(string $panelIdentity, int $locationId): void + { + DB::table('ptero_capacity_scopes')->insertOrIgnore([ + 'panel_identity' => $panelIdentity, + 'location_id' => $locationId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('ptero_capacity_scopes') + ->where('panel_identity', $panelIdentity) + ->where('location_id', $locationId) + ->lockForUpdate() + ->first(); + } + + /** + * @param array $properties + * @return array{memory: int, cpu: int, disk: int} + */ + private function resourceVector(array $properties, string $label): array + { + $properties = collect($properties) + ->mapWithKeys(fn ($value, $key): array => [ + strtolower((string) $key) => $value, + ]); + $vector = []; + foreach (['memory', 'cpu', 'disk'] as $resource) { + $vector[$resource] = $this->wholeNumber( + $properties->get($resource), + "{$label} {$resource}" + ); + } + + return $vector; + } + + /** + * @param array $source + * @param array $target + * @return array{memory: int, cpu: int, disk: int} + */ + private function positiveDelta(array $source, array $target): array + { + return [ + 'memory' => max(0, (int) $target['memory'] - (int) $source['memory']), + 'cpu' => max(0, (int) $target['cpu'] - (int) $source['cpu']), + 'disk' => max(0, (int) $target['disk'] - (int) $source['disk']), + ]; + } + + private function wholeNumber(mixed $value, string $label): int + { + $numeric = StrictInteger::parse($value) + ?? StrictInteger::parseStoredDecimal($value); + if ($numeric === null || $numeric <= 0) { + throw new InvalidStockConfigurationException( + "The {$label} value must be a positive whole number." + ); + } + + return $numeric; + } + + private function metadataInteger(object $option, string $key): int + { + $value = $this->wholeNumber( + $option->getMetadata($key), + "{$option->name} {$key}" + ); + + return $value; + } + + private function reservationFingerprint( + ServiceUpgrade $upgrade, + array $context + ): string { + return $this->integrity->fingerprint($upgrade, $context); + } + + private function reservationIntegrityError( + ServiceUpgrade $upgrade, + UpgradeReservation $reservation, + ?Invoice $paidCommitInvoice = null + ): ?string { + try { + $upgrade->load([ + 'service.product.server.settings', + 'service.product.settings', + 'service.configs.configOption', + 'service.configs.configValue', + 'product.server.settings', + 'product.settings', + 'configs.configOption', + 'configs.configValue', + ]); + $this->assertProductPanel($upgrade->service); + if ($paidCommitInvoice !== null) { + $this->integrity->verifiedSnapshotForPaidCommit( + $upgrade, + $reservation, + $paidCommitInvoice + ); + } else { + $this->integrity->verifiedSnapshot($upgrade, $reservation); + } + } catch (\Throwable) { + return 'The paid upgrade reservation failed its immutable integrity check.'; + } + + return null; + } + + private function usesDynamicCapacity(ServiceUpgrade $upgrade): bool + { + return app(CapacityUpgradeReservationIdentity::class) + ->requiresCoordinator($upgrade); + } + + /** + * Freeze every remote build field that a RAM/CPU/disk-only upgrade must + * preserve across queue retries. + * + * @param array $server + * @return array{ + * swap: int, + * io: int, + * threads: string|null, + * databases: int, + * allocations: int, + * backups: int + * } + */ + private function preservedBuild(array $server): array + { + $threads = $server['threads'] ?? null; + $values = [ + 'swap' => StrictInteger::parse($server['swap'] ?? null), + 'io' => StrictInteger::parse($server['io'] ?? null), + 'databases' => StrictInteger::parse( + $server['database_limit'] ?? null + ), + 'allocations' => StrictInteger::parse( + $server['allocation_limit'] ?? null + ), + 'backups' => StrictInteger::parse( + $server['backup_limit'] ?? null + ), + ]; + if ( + ($threads !== null && ! is_string($threads)) + || $values['swap'] === null + || $values['swap'] < 0 + || $values['io'] === null + || $values['io'] < 0 + || $values['databases'] === null + || $values['databases'] < 0 + || $values['allocations'] !== 0 + || $values['backups'] === null + || $values['backups'] < 0 + ) { + throw new InvalidStockConfigurationException( + 'Resource-only upgrades require complete remote build limits and a zero client allocation limit.' + ); + } + + return [ + 'swap' => $values['swap'], + 'io' => $values['io'], + 'threads' => $threads, + 'databases' => $values['databases'], + 'allocations' => $values['allocations'], + 'backups' => $values['backups'], + ]; + } + + /** + * @param array $server + * @return list + */ + private function assignedAllocationIds(array $server): array + { + $ids = $server['assigned_allocation_ids'] ?? null; + if (! is_array($ids) || ! array_is_list($ids)) { + throw new InvalidStockConfigurationException( + 'Pterodactyl did not return the server allocation set.' + ); + } + + $normalized = []; + foreach ($ids as $id) { + $parsed = StrictInteger::parse($id); + if ($parsed === null || $parsed <= 0) { + throw new InvalidStockConfigurationException( + 'Pterodactyl returned an invalid server allocation set.' + ); + } + $normalized[] = $parsed; + } + sort($normalized, SORT_NUMERIC); + if ( + $normalized === [] + || count(array_unique($normalized)) !== count($normalized) + || ! in_array((int) ($server['allocation'] ?? 0), $normalized, true) + ) { + throw new InvalidStockConfigurationException( + 'Pterodactyl returned an inconsistent server allocation set.' + ); + } + + return $normalized; + } + + private function assertServiceAcceptsUpgrade(Service $service): void + { + if (! $this->serviceAcceptsUpgrade($service)) { + throw new InvalidResourceSelectionException( + 'The service is not active or already has a cancellation request.' + ); + } + } + + private function serviceAcceptsUpgrade(Service $service): bool + { + return $service->status === Service::STATUS_ACTIVE + && ! $service->cancellation()->exists(); + } + + private function invoiceMismatch( + ServiceUpgrade $upgrade, + Invoice $invoice + ): ?string { + $items = $invoice->items() + ->orderBy('id') + ->lockForUpdate() + ->get(); + if ( + (int) $invoice->user_id !== (int) $upgrade->service->user_id + || strtoupper((string) $invoice->currency_code) + !== strtoupper((string) $upgrade->currency_code) + || $items->count() !== 1 + ) { + return 'The upgrade invoice identity no longer matches its immutable quote.'; + } + + $item = $items->first(); + $actualCents = $this->moneyCents($item->price); + $expectedCents = $this->moneyCents($upgrade->quoted_amount); + if ( + $item->reference_type !== ServiceUpgrade::class + || (int) $item->reference_id !== (int) $upgrade->id + || (int) $item->quantity !== 1 + || $expectedCents === null + || $expectedCents <= 0 + || $actualCents !== $expectedCents + ) { + return 'The upgrade invoice line no longer matches its immutable quote.'; + } + + return null; + } + + private function moneyCents(mixed $value): ?int + { + $text = is_int($value) + ? (string) $value + : (is_string($value) ? $value : ''); + if ( + preg_match('/^(-?)(0|[1-9]\d*)(?:\.(\d+))?$/D', $text, $matches) + !== 1 + ) { + return null; + } + + $whole = StrictInteger::parse( + ($matches[1] ?? '').$matches[2] + ); + $fraction = $matches[3] ?? ''; + if ( + $whole === null + || (strlen($fraction) > 2 + && trim(substr($fraction, 2), '0') !== '') + || abs($whole) > intdiv(PHP_INT_MAX - 99, 100) + ) { + return null; + } + + $fraction = str_pad(substr($fraction, 0, 2), 2, '0'); + $cents = abs($whole) * 100 + (int) $fraction; + + return ($matches[1] ?? '') === '-' ? -$cents : $cents; + } + + private function snapDown(int $value, int $minimum, int $step): int + { + return $value < $minimum + ? $minimum - 1 + : $minimum + intdiv($value - $minimum, $step) * $step; + } + + private function snapUp(int $value, int $minimum, int $step): int + { + if ($value <= $minimum) { + return $minimum; + } + + return $minimum + (int) ceil(($value - $minimum) / $step) * $step; + } +} diff --git a/database/migrations/2026_07_25_000001_add_checkout_identity_to_ptero_resource_reservations.php b/database/migrations/2026_07_25_000001_add_checkout_identity_to_ptero_resource_reservations.php new file mode 100644 index 0000000..02ec52e --- /dev/null +++ b/database/migrations/2026_07_25_000001_add_checkout_identity_to_ptero_resource_reservations.php @@ -0,0 +1,94 @@ +unsignedBigInteger('cart_item_guard_id')->nullable()->after('cart_item_id'); + $table->unsignedBigInteger('cart_id')->nullable()->after('cart_item_id'); + $table->unsignedBigInteger('server_extension_id')->nullable()->after('cart_id'); + $table->string('panel_identity', 64)->nullable()->after('server_extension_id'); + $table->unsignedBigInteger('product_id')->nullable()->after('user_id'); + $table->unsignedBigInteger('plan_id')->nullable()->after('product_id'); + $table->unsignedInteger('quantity')->default(1)->after('plan_id'); + $table->string('currency_code', 3)->nullable()->after('quantity'); + $table->string('configuration_fingerprint', 64)->nullable()->after('currency_code'); + $table->json('configuration_payload')->nullable()->after('configuration_fingerprint'); + $table->string('pricing_version', 64)->nullable()->after('configuration_payload'); + $table->string('formula_version', 64)->nullable()->after('pricing_version'); + $table->timestamp('provisioning_started_at')->nullable()->after('expires_at'); + $table->string('provisioning_lease_id', 64)->nullable()->after('provisioning_started_at'); + $table->timestamp('consumed_at')->nullable()->after('provisioning_lease_id'); + $table->text('last_provisioning_error')->nullable()->after('consumed_at'); + + $table->unsignedBigInteger('active_cart_item_id') + ->nullable() + ->storedAs("CASE WHEN status = 'pending' THEN cart_item_guard_id ELSE NULL END") + ->after('cart_item_guard_id'); + }); + + // Unbound pre-migration holds came from superseded token flows and lack + // immutable identity. A service-bound row may already back an invoice; + // never retire that commitment before the readiness gate can stop the + // upgrade and require explicit operator reconciliation. + DB::table('ptero_resource_reservations') + ->where('status', 'pending') + ->whereNull('service_id') + ->whereNull('configuration_fingerprint') + ->update([ + 'status' => 'cancelled', + 'admin_notes' => 'Retired during migration to server-owned reservations.', + 'updated_at' => now(), + ]); + + Schema::table('ptero_resource_reservations', function (Blueprint $table) { + $table->unique('active_cart_item_id', 'ptero_reservations_active_cart_item_unique'); + // Legacy releases could attach both browser and listener holds to one + // service. Keep this non-unique so the migration is deployable; the + // new bind path locks the cart hold and prevents new duplicates. + $table->index('service_id', 'ptero_reservations_service_idx'); + $table->index(['cart_id', 'status'], 'ptero_reservations_cart_status_idx'); + $table->index('server_extension_id', 'ptero_reservations_server_extension_idx'); + $table->index('configuration_fingerprint', 'ptero_reservations_fingerprint_idx'); + }); + } + + public function down(): void + { + Schema::table('ptero_resource_reservations', function (Blueprint $table) { + $table->dropUnique('ptero_reservations_active_cart_item_unique'); + $table->dropIndex('ptero_reservations_service_idx'); + $table->dropIndex('ptero_reservations_cart_status_idx'); + $table->dropIndex('ptero_reservations_server_extension_idx'); + $table->dropIndex('ptero_reservations_fingerprint_idx'); + $table->dropColumn([ + 'active_cart_item_id', + 'cart_item_guard_id', + 'cart_id', + 'server_extension_id', + 'panel_identity', + 'product_id', + 'plan_id', + 'quantity', + 'currency_code', + 'configuration_fingerprint', + 'configuration_payload', + 'pricing_version', + 'formula_version', + 'provisioning_started_at', + 'provisioning_lease_id', + 'consumed_at', + 'last_provisioning_error', + ]); + }); + } +}; diff --git a/database/migrations/2026_07_25_000002_normalize_dynamic_option_keys.php b/database/migrations/2026_07_25_000002_normalize_dynamic_option_keys.php new file mode 100644 index 0000000..69c0b8f --- /dev/null +++ b/database/migrations/2026_07_25_000002_normalize_dynamic_option_keys.php @@ -0,0 +1,308 @@ +join( + 'config_option_products', + 'config_option_products.config_option_id', + '=', + 'config_options.id' + ) + ->join( + 'products', + 'products.id', + '=', + 'config_option_products.product_id' + ) + ->join( + 'extensions', + 'extensions.id', + '=', + 'products.server_id' + ) + ->where('extensions.type', 'server') + ->where('extensions.extension', 'Pterodactyl') + ->whereNull('config_options.parent_id') + ->whereIn( + 'config_options.type', + ['dynamic_slider', 'select'] + ) + ->distinct() + ->pluck('config_options.id'); + $options = DB::table('config_options') + ->whereIn('id', $optionIds) + ->orderBy('id') + ->lockForUpdate() + ->get([ + 'id', + 'name', + 'type', + 'env_variable', + 'metadata', + ]); + $optionUpdates = []; + $serviceIdsByResource = [ + 'memory' => [], + 'cpu' => [], + 'disk' => [], + ]; + + foreach ($options as $option) { + try { + $metadata = is_string($option->metadata) + ? json_decode( + $option->metadata, + true, + 512, + JSON_THROW_ON_ERROR + ) + : (array) $option->metadata; + } catch (JsonException $exception) { + throw new RuntimeException( + "Dynamic option {$option->id} has invalid metadata.", + previous: $exception + ); + } + if ( + $option->type === 'dynamic_slider' + && ( + ! is_array($metadata) + || array_is_list($metadata) + ) + ) { + throw new RuntimeException( + "Dynamic option {$option->id} has invalid metadata." + ); + } + $resourceType = strtolower( + (string) ($metadata['resource_type'] ?? '') + ); + $isResource = $option->type === 'dynamic_slider' + && array_key_exists( + $resourceType, + $serviceIdsByResource + ); + $isLocation = strtolower((string) $option->name) + === 'location'; + + if (! $isResource && ! $isLocation) { + continue; + } + + $normalizedKey = $isResource + ? $resourceType + : 'location'; + $optionUpdates[(int) $option->id] = $normalizedKey; + + if (! $isResource) { + continue; + } + + $productIds = DB::table('config_option_products') + ->join( + 'products', + 'products.id', + '=', + 'config_option_products.product_id' + ) + ->join( + 'extensions', + 'extensions.id', + '=', + 'products.server_id' + ) + ->where( + 'config_option_products.config_option_id', + $option->id + ) + ->where('extensions.type', 'server') + ->where('extensions.extension', 'Pterodactyl') + ->pluck('config_option_products.product_id'); + $serviceIds = DB::table('services') + ->whereIn('product_id', $productIds) + ->pluck('id'); + + foreach ($serviceIds as $serviceId) { + $serviceIdsByResource[$normalizedKey][ + (int) $serviceId + ] = true; + } + } + + $propertyMutations = $this->preflightPropertyMutations( + $serviceIdsByResource + ); + + foreach ($optionUpdates as $optionId => $normalizedKey) { + DB::table('config_options') + ->where('id', $optionId) + ->update(['env_variable' => $normalizedKey]); + } + + foreach ($propertyMutations as $mutation) { + if ($mutation['action'] === 'delete') { + DB::table('properties') + ->where('id', $mutation['id']) + ->delete(); + + continue; + } + + DB::table('properties') + ->where('id', $mutation['id']) + ->update(['key' => $mutation['key']]); + } + }); + } + + /** + * @param array> $serviceIdsByResource + * @return array + */ + private function preflightPropertyMutations( + array $serviceIdsByResource + ): array { + $allServiceIds = []; + foreach ($serviceIdsByResource as $serviceIds) { + foreach (array_keys($serviceIds) as $serviceId) { + $allServiceIds[$serviceId] = true; + } + } + + if ($allServiceIds === []) { + return []; + } + + $propertiesByService = []; + $properties = DB::table('properties') + ->where('model_type', Service::class) + ->whereIn('model_id', array_keys($allServiceIds)) + ->orderBy('id') + ->lockForUpdate() + ->get(['id', 'model_id', 'key', 'value']); + + foreach ($properties as $property) { + $propertiesByService[(int) $property->model_id][] = + $property; + } + + $mutations = []; + foreach ($serviceIdsByResource as $normalizedKey => $serviceIds) { + foreach (array_keys($serviceIds) as $serviceId) { + $matching = array_values(array_filter( + $propertiesByService[$serviceId] ?? [], + static fn (object $property): bool => strtolower(trim((string) $property->key)) + === $normalizedKey + )); + + $lower = null; + $upper = null; + foreach ($matching as $property) { + $storedKey = (string) $property->key; + if ($storedKey === $normalizedKey) { + if ($lower !== null) { + throw new RuntimeException( + "Service {$serviceId} has duplicate " + ."[{$normalizedKey}] properties." + ); + } + $lower = $property; + } elseif ( + $storedKey === strtoupper($normalizedKey) + ) { + if ($upper !== null) { + throw new RuntimeException( + "Service {$serviceId} has duplicate " + ."[{$normalizedKey}] properties." + ); + } + $upper = $property; + } else { + throw new RuntimeException( + "Service {$serviceId} has unsupported " + ."resource key casing [{$storedKey}]." + ); + } + } + + $lowerValue = $this->strictPropertyValue( + $lower, + $serviceId, + $normalizedKey + ); + $upperValue = $this->strictPropertyValue( + $upper, + $serviceId, + $normalizedKey + ); + + if ($lower !== null && $upper !== null) { + if ($lowerValue !== $upperValue) { + throw new RuntimeException( + "Service {$serviceId} has conflicting " + ."[{$normalizedKey}] property values." + ); + } + + $mutations[(int) $upper->id] = [ + 'action' => 'delete', + 'id' => (int) $upper->id, + ]; + } elseif ($upper !== null) { + $mutations[(int) $upper->id] = [ + 'action' => 'rename', + 'id' => (int) $upper->id, + 'key' => $normalizedKey, + ]; + } + } + } + + ksort($mutations); + + return array_values($mutations); + } + + private function strictPropertyValue( + ?object $property, + int $serviceId, + string $normalizedKey + ): ?int { + if ($property === null) { + return null; + } + + $value = StrictInteger::parseStoredDecimal($property->value); + if ($value === null) { + throw new RuntimeException( + "Service {$serviceId} has a non-integer " + ."[{$normalizedKey}] property value." + ); + } + + return $value; + } + + public function down(): void + { + // Key normalization is intentionally irreversible. Reintroducing + // uppercase keys would make Pterodactyl ignore slider values again. + } +}; diff --git a/database/migrations/2026_07_26_000001_add_durable_fulfillment_to_ptero_reservations.php b/database/migrations/2026_07_26_000001_add_durable_fulfillment_to_ptero_reservations.php new file mode 100644 index 0000000..9c4614f --- /dev/null +++ b/database/migrations/2026_07_26_000001_add_durable_fulfillment_to_ptero_reservations.php @@ -0,0 +1,195 @@ + $table + ->enum('status', $statuses) + ->default('pending') + ->change() + ); + } + + Schema::table('ptero_resource_reservations', function (Blueprint $table) { + $table->unsignedBigInteger('invoice_id')->nullable()->after('service_id'); + $table->timestamp('guaranteed_until')->nullable()->after('expires_at'); + $table->timestamp('paid_committed_at')->nullable()->after('guaranteed_until'); + $table->unsignedInteger('provisioning_attempts')->default(0)->after('provisioning_started_at'); + $table->timestamp('last_provisioning_attempt_at')->nullable()->after('provisioning_attempts'); + $table->timestamp('next_provisioning_attempt_at')->nullable()->after('last_provisioning_attempt_at'); + $table->timestamp('failure_alerted_at')->nullable()->after('last_provisioning_error'); + $table->timestamp('cancellation_requested_at')->nullable()->after('failure_alerted_at'); + $table->text('last_cancellation_error')->nullable()->after('cancellation_requested_at'); + $table->timestamp('cancellation_failure_alerted_at')->nullable()->after('last_cancellation_error'); + $table->unsignedBigInteger('external_server_id')->nullable()->after('cancellation_failure_alerted_at'); + $table->unsignedBigInteger('external_user_id')->nullable()->after('external_server_id'); + $table->uuid('external_server_uuid')->nullable()->after('external_user_id'); + $table->string('external_server_identifier', 64)->nullable()->after('external_server_uuid'); + $table->timestamp('last_reconciled_at')->nullable()->after('external_server_identifier'); + $table->timestamp('customer_notified_at')->nullable()->after('last_reconciled_at'); + $table->timestamp('product_stock_released_at')->nullable()->after('customer_notified_at'); + + $table->foreign('invoice_id') + ->references('id') + ->on('invoices') + ->nullOnDelete(); + $table->index(['invoice_id', 'status'], 'ptero_reservations_invoice_status_idx'); + $table->index( + ['status', 'next_provisioning_attempt_at'], + 'ptero_reservations_retry_idx' + ); + }); + + DB::table('ptero_resource_reservations') + ->whereNull('guaranteed_until') + ->update(['guaranteed_until' => DB::raw('expires_at')]); + + // Legacy terminal reservations were already handled by Paymenter's + // pre-durable cancellation paths. Mark them released so a repeated + // cancellation cannot return the same product unit twice. + DB::table('ptero_resource_reservations') + ->whereIn('status', ['expired', 'cancelled']) + ->whereNull('product_stock_released_at') + ->update(['product_stock_released_at' => now()]); + + Schema::create('ptero_capacity_scopes', function (Blueprint $table) { + $table->id(); + $table->string('panel_identity', 64); + $table->unsignedInteger('location_id'); + $table->timestamps(); + + $table->unique( + ['panel_identity', 'location_id'], + 'ptero_capacity_scopes_panel_location_unique' + ); + }); + + Schema::create('ptero_reservation_allocations', function (Blueprint $table) use ($driver) { + $table->id(); + $table->foreignId('reservation_id') + ->constrained('ptero_resource_reservations') + ->cascadeOnDelete(); + $table->string('panel_identity', 64); + $table->unsignedInteger('node_id'); + $table->unsignedBigInteger('allocation_id'); + $table->string('ip', 64)->nullable(); + $table->unsignedInteger('port'); + $table->string('environment_key', 191)->nullable(); + $table->boolean('is_primary')->default(false); + $table->timestamp('released_at')->nullable(); + $table->timestamps(); + + if ($driver !== 'sqlite') { + $table->unsignedBigInteger('active_allocation_id') + ->nullable() + ->storedAs('CASE WHEN released_at IS NULL THEN allocation_id ELSE NULL END'); + $table->unique( + ['panel_identity', 'active_allocation_id'], + 'ptero_reservation_allocations_active_unique' + ); + } + $table->index( + ['reservation_id', 'released_at'], + 'ptero_reservation_allocations_reservation_idx' + ); + $table->index( + ['node_id', 'released_at'], + 'ptero_reservation_allocations_node_idx' + ); + }); + + if ($driver === 'sqlite') { + // Partial indexes provide the same one-active-claim invariant + // without requiring SQLite generated-column support. + DB::statement( + 'CREATE UNIQUE INDEX ptero_reservation_allocations_active_unique ' + .'ON ptero_reservation_allocations (panel_identity, allocation_id) ' + .'WHERE released_at IS NULL' + ); + } + } + + public function down(): void + { + Schema::dropIfExists('ptero_reservation_allocations'); + Schema::dropIfExists('ptero_capacity_scopes'); + + DB::table('ptero_resource_reservations') + ->where('status', 'paid_committed') + ->update(['status' => 'pending']); + + Schema::table('ptero_resource_reservations', function (Blueprint $table) { + $table->dropForeign(['invoice_id']); + $table->dropIndex('ptero_reservations_invoice_status_idx'); + $table->dropIndex('ptero_reservations_retry_idx'); + $table->dropColumn([ + 'invoice_id', + 'guaranteed_until', + 'paid_committed_at', + 'provisioning_attempts', + 'last_provisioning_attempt_at', + 'next_provisioning_attempt_at', + 'failure_alerted_at', + 'cancellation_requested_at', + 'last_cancellation_error', + 'cancellation_failure_alerted_at', + 'external_server_id', + 'external_user_id', + 'external_server_uuid', + 'external_server_identifier', + 'last_reconciled_at', + 'customer_notified_at', + 'product_stock_released_at', + ]); + }); + + $driver = DB::getDriverName(); + if (in_array($driver, ['mysql', 'mariadb'], true)) { + DB::statement( + 'ALTER TABLE ptero_resource_reservations MODIFY status ' + ."ENUM('pending','confirmed','expired','cancelled') " + ."NOT NULL DEFAULT 'pending'" + ); + } elseif ($driver === 'sqlite') { + Schema::table( + 'ptero_resource_reservations', + fn (Blueprint $table) => $table + ->enum('status', [ + 'pending', + 'confirmed', + 'expired', + 'cancelled', + ]) + ->default('pending') + ->change() + ); + } + } +}; diff --git a/database/migrations/2026_07_26_000002_create_stock_foundation_tables.php b/database/migrations/2026_07_26_000002_create_stock_foundation_tables.php new file mode 100644 index 0000000..97684e5 --- /dev/null +++ b/database/migrations/2026_07_26_000002_create_stock_foundation_tables.php @@ -0,0 +1,42 @@ +id(); + $table->string('panel_identity', 64); + $table->string('node_uuid', 36); + $table->unsignedBigInteger('node_id'); + $table->unsignedInteger('location_id'); + $table->unsignedInteger('cpu_capacity_percent'); + $table->unsignedInteger('cpu_overcommit_bps')->default(10000); + $table->boolean('enabled')->default(true); + $table->timestamps(); + + $table->unique( + ['panel_identity', 'node_uuid'], + 'ptero_node_capacity_panel_uuid_unique' + ); + $table->index( + ['panel_identity', 'node_id'], + 'ptero_node_capacity_panel_id_index' + ); + $table->index( + ['panel_identity', 'location_id'], + 'ptero_node_capacity_panel_location_index' + ); + }); + + } + + public function down(): void + { + Schema::dropIfExists('ptero_node_capacity_policies'); + } +}; diff --git a/database/migrations/2026_07_26_000003_add_cpu_alert_thresholds.php b/database/migrations/2026_07_26_000003_add_cpu_alert_thresholds.php new file mode 100644 index 0000000..a18d19d --- /dev/null +++ b/database/migrations/2026_07_26_000003_add_cpu_alert_thresholds.php @@ -0,0 +1,63 @@ +unsignedTinyInteger('cpu_warning_threshold') + ->default(80); + } + if ($addCritical) { + $table->unsignedTinyInteger('cpu_critical_threshold') + ->default(95); + } + } + ); + } + + public function down(): void + { + if (! Schema::hasTable('ptero_alert_configs')) { + return; + } + + $columns = array_values(array_filter([ + Schema::hasColumn( + 'ptero_alert_configs', + 'cpu_warning_threshold' + ) ? 'cpu_warning_threshold' : null, + Schema::hasColumn( + 'ptero_alert_configs', + 'cpu_critical_threshold' + ) ? 'cpu_critical_threshold' : null, + ])); + Schema::table( + 'ptero_alert_configs', + function (Blueprint $table) use ($columns): void { + if ($columns !== []) { + $table->dropColumn($columns); + } + } + ); + } +}; diff --git a/database/migrations/2026_07_26_000005_widen_reservation_price.php b/database/migrations/2026_07_26_000005_widen_reservation_price.php new file mode 100644 index 0000000..8f07f63 --- /dev/null +++ b/database/migrations/2026_07_26_000005_widen_reservation_price.php @@ -0,0 +1,34 @@ +decimal('calculated_price', 17, 2)->change(); + }); + } + + public function down(): void + { + if ( + (float) DB::table('ptero_resource_reservations') + ->max('calculated_price') > 99_999_999.99 + ) { + throw new RuntimeException( + 'Reservation prices no longer fit the legacy DECIMAL(10,2) column.' + ); + } + + Schema::table('ptero_resource_reservations', function (Blueprint $table) { + $table->decimal('calculated_price', 10, 2)->change(); + }); + } +}; diff --git a/database/migrations/2026_07_26_000010_encrypt_dynamic_pterodactyl_api_key.php b/database/migrations/2026_07_26_000010_encrypt_dynamic_pterodactyl_api_key.php new file mode 100644 index 0000000..9e8cbb9 --- /dev/null +++ b/database/migrations/2026_07_26_000010_encrypt_dynamic_pterodactyl_api_key.php @@ -0,0 +1,40 @@ +join('extensions', function ($join) { + $join->on('extensions.id', '=', 'settings.settingable_id') + ->where('settings.settingable_type', '=', Extension::class); + }) + ->where('extensions.extension', 'DynamicPterodactyl') + ->where('settings.key', 'pterodactyl_api_key') + ->where('settings.encrypted', false) + ->get(['settings.id', 'settings.value']); + + foreach ($settings as $setting) { + DB::table('settings') + ->where('id', $setting->id) + ->update([ + 'value' => $setting->value === null || $setting->value === '' + ? $setting->value + : Crypt::encryptString((string) $setting->value), + 'encrypted' => true, + 'updated_at' => now(), + ]); + } + } + + public function down(): void + { + // Intentionally irreversible: a rollback must never restore an API key + // to plaintext storage. + } +}; diff --git a/database/migrations/2026_07_26_000020_add_upgrade_identity_to_ptero_reservations.php b/database/migrations/2026_07_26_000020_add_upgrade_identity_to_ptero_reservations.php new file mode 100644 index 0000000..c4d8f28 --- /dev/null +++ b/database/migrations/2026_07_26_000020_add_upgrade_identity_to_ptero_reservations.php @@ -0,0 +1,97 @@ +string('purpose', 24)->default('checkout')->after('id'); + $table->unsignedInteger('reserved_memory')->default(0)->after('memory'); + $table->unsignedInteger('reserved_cpu')->default(0)->after('cpu'); + $table->unsignedBigInteger('reserved_disk')->default(0)->after('disk'); + $table->unsignedBigInteger('service_upgrade_id')->nullable()->after('service_id'); + // Keep the partial-unique source separate from the nullable FK. + // MariaDB rejects generated columns derived from SET NULL FKs. + $table->unsignedBigInteger('upgrade_guard_id')->nullable()->after('service_upgrade_id'); + if ($driver !== 'sqlite') { + $table->unsignedBigInteger('active_upgrade_id') + ->nullable() + ->storedAs( + "CASE WHEN purpose = 'upgrade' AND " + ."(status = 'pending' OR status = 'paid_committed') " + .'THEN upgrade_guard_id ELSE NULL END' + ) + ->after('upgrade_guard_id'); + } + + $table->foreign('service_upgrade_id') + ->references('id') + ->on('service_upgrades') + ->restrictOnDelete(); + if ($driver !== 'sqlite') { + $table->unique( + 'active_upgrade_id', + 'ptero_reservations_active_upgrade_unique' + ); + } + $table->index( + ['purpose', 'status'], + 'ptero_reservations_purpose_status_idx' + ); + $table->index( + 'service_upgrade_id', + 'ptero_reservations_service_upgrade_idx' + ); + }); + + if ($driver === 'sqlite') { + DB::statement( + 'CREATE UNIQUE INDEX ptero_reservations_active_upgrade_unique ' + .'ON ptero_resource_reservations (upgrade_guard_id) ' + ."WHERE purpose = 'upgrade' " + ."AND status IN ('pending', 'paid_committed') " + .'AND upgrade_guard_id IS NOT NULL' + ); + } + } + + public function down(): void + { + $driver = DB::getDriverName(); + if ($driver === 'sqlite') { + DB::statement( + 'DROP INDEX IF EXISTS ptero_reservations_active_upgrade_unique' + ); + } + + Schema::table('ptero_resource_reservations', function (Blueprint $table) use ($driver) { + $table->dropForeign(['service_upgrade_id']); + if ($driver !== 'sqlite') { + $table->dropUnique( + 'ptero_reservations_active_upgrade_unique' + ); + } + $table->dropIndex('ptero_reservations_purpose_status_idx'); + $table->dropIndex('ptero_reservations_service_upgrade_idx'); + $columns = [ + 'upgrade_guard_id', + 'service_upgrade_id', + 'reserved_memory', + 'reserved_cpu', + 'reserved_disk', + 'purpose', + ]; + if ($driver !== 'sqlite') { + array_unshift($columns, 'active_upgrade_id'); + } + $table->dropColumn($columns); + }); + } +}; diff --git a/database/migrations/2026_07_26_000030_enforce_one_checkout_commitment_per_service.php b/database/migrations/2026_07_26_000030_enforce_one_checkout_commitment_per_service.php new file mode 100644 index 0000000..627ebd4 --- /dev/null +++ b/database/migrations/2026_07_26_000030_enforce_one_checkout_commitment_per_service.php @@ -0,0 +1,146 @@ +assertNoDuplicateCheckoutCommitments(); + + Schema::table('ptero_resource_reservations', function (Blueprint $table) { + // service_id is SET NULL, so MariaDB cannot use it as the source of + // a generated partial-unique column. Preserve an immutable guard. + $table->unsignedBigInteger('service_guard_id')->nullable()->after('service_id'); + }); + + DB::table('ptero_resource_reservations') + ->whereNotNull('service_id') + ->whereNull('service_guard_id') + ->update(['service_guard_id' => DB::raw('service_id')]); + + if ($driver === 'sqlite') { + DB::statement( + 'CREATE UNIQUE INDEX ptero_reservations_active_checkout_service_unique ' + .'ON ptero_resource_reservations (service_guard_id) ' + ."WHERE purpose = 'checkout' " + ."AND status IN ('pending', 'paid_committed', 'confirmed') " + .'AND service_guard_id IS NOT NULL' + ); + } else { + Schema::table('ptero_resource_reservations', function (Blueprint $table) { + $table + ->unsignedBigInteger('active_checkout_service_id') + ->nullable() + ->storedAs( + "CASE WHEN purpose = 'checkout' AND " + ."(status = 'pending' OR status = 'paid_committed' OR status = 'confirmed') " + .'THEN service_guard_id ELSE NULL END' + ) + ->after('service_guard_id'); + $table->unique( + 'active_checkout_service_id', + 'ptero_reservations_active_checkout_service_unique' + ); + }); + } + } + + public function down(): void + { + $driver = DB::getDriverName(); + if ($driver === 'sqlite') { + DB::statement( + 'DROP INDEX IF EXISTS ptero_reservations_active_checkout_service_unique' + ); + Schema::table( + 'ptero_resource_reservations', + fn (Blueprint $table) => $table->dropColumn('service_guard_id') + ); + } else { + Schema::table('ptero_resource_reservations', function (Blueprint $table) { + $table->dropUnique('ptero_reservations_active_checkout_service_unique'); + $table->dropColumn([ + 'active_checkout_service_id', + 'service_guard_id', + ]); + }); + } + } + + /** + * Fail before adding/backfilling the guard column. This keeps a rejected + * migration retryable on databases where DDL auto-commits. + */ + private function assertNoDuplicateCheckoutCommitments(): void + { + $duplicateGroups = DB::table('ptero_resource_reservations') + ->where('purpose', 'checkout') + ->whereNotNull('service_id') + ->whereIn('status', [ + 'pending', + 'paid_committed', + 'confirmed', + ]) + ->orderBy('service_id') + ->orderBy('id') + ->get([ + 'id', + 'service_id', + 'status', + 'invoice_id', + 'configuration_fingerprint', + 'external_server_id', + 'external_server_uuid', + ]) + ->groupBy('service_id') + ->filter(fn ($rows) => $rows->count() > 1); + if ($duplicateGroups->isEmpty()) { + return; + } + + $details = $duplicateGroups + ->map(function ($rows, $serviceId): string { + $commitments = $rows + ->map(fn ($row): string => sprintf( + '#%d(status=%s, invoice=%s, fingerprint=%s, ' + .'external_server=%s, external_uuid=%s)', + (int) $row->id, + (string) $row->status, + $row->invoice_id !== null + ? (string) $row->invoice_id + : 'none', + is_string($row->configuration_fingerprint) + ? $row->configuration_fingerprint + : 'none', + $row->external_server_id !== null + ? (string) $row->external_server_id + : 'none', + is_string($row->external_server_uuid) + ? $row->external_server_uuid + : 'none' + )) + ->implode(', '); + + return "service {$serviceId}: {$commitments}"; + }) + ->implode('; '); + + throw new RuntimeException( + 'Dynamic Pterodactyl found duplicate active checkout ' + .'commitments and refused to retire or release any obligation. ' + .'Reconcile every invoice, signed configuration, allocation, and ' + ."external server before rerunning the migration: {$details}" + ); + } +}; diff --git a/database/migrations/2026_07_26_000040_restrict_durable_reservation_parent_deletes.php b/database/migrations/2026_07_26_000040_restrict_durable_reservation_parent_deletes.php new file mode 100644 index 0000000..9a3a4ca --- /dev/null +++ b/database/migrations/2026_07_26_000040_restrict_durable_reservation_parent_deletes.php @@ -0,0 +1,87 @@ +dropForeign(['service_id']); + $table->dropForeign(['user_id']); + + $table->foreign('service_id') + ->references('id') + ->on('services') + ->restrictOnDelete(); + $table->foreign('user_id') + ->references('id') + ->on('users') + ->restrictOnDelete(); + } + ); + $this->restoreSqlitePartialIndexes(); + } + + public function down(): void + { + Schema::table( + 'ptero_resource_reservations', + function (Blueprint $table): void { + $table->dropForeign(['service_id']); + $table->dropForeign(['user_id']); + + $table->foreign('service_id') + ->references('id') + ->on('services') + ->nullOnDelete(); + $table->foreign('user_id') + ->references('id') + ->on('users') + ->nullOnDelete(); + } + ); + $this->restoreSqlitePartialIndexes(); + } + + /** + * Laravel rebuilds SQLite tables to alter foreign keys. Recreate the two + * conditional uniqueness guards because SQLite schema introspection does + * not retain partial-index predicates during that rebuild. + */ + private function restoreSqlitePartialIndexes(): void + { + if (DB::getDriverName() !== 'sqlite') { + return; + } + + DB::statement( + 'DROP INDEX IF EXISTS ' + .'ptero_reservations_active_upgrade_unique' + ); + DB::statement( + 'CREATE UNIQUE INDEX ptero_reservations_active_upgrade_unique ' + .'ON ptero_resource_reservations (upgrade_guard_id) ' + ."WHERE purpose = 'upgrade' " + ."AND status IN ('pending', 'paid_committed') " + .'AND upgrade_guard_id IS NOT NULL' + ); + DB::statement( + 'DROP INDEX IF EXISTS ' + .'ptero_reservations_active_checkout_service_unique' + ); + DB::statement( + 'CREATE UNIQUE INDEX ' + .'ptero_reservations_active_checkout_service_unique ' + .'ON ptero_resource_reservations (service_guard_id) ' + ."WHERE purpose = 'checkout' " + ."AND status IN ('pending', 'paid_committed', 'confirmed') " + .'AND service_guard_id IS NOT NULL' + ); + } +}; diff --git a/database/migrations/2026_07_26_000050_create_scheduler_heartbeats_table.php b/database/migrations/2026_07_26_000050_create_scheduler_heartbeats_table.php new file mode 100644 index 0000000..878ac59 --- /dev/null +++ b/database/migrations/2026_07_26_000050_create_scheduler_heartbeats_table.php @@ -0,0 +1,79 @@ +id(); + $table->string('task_name')->unique(); + $table->unsignedInteger('expected_interval_seconds'); + $table->unsignedInteger('lag_threshold_seconds'); + $table->timestamp('last_started_at')->nullable(); + $table->timestamp('last_completed_at')->nullable(); + $table->timestamp('last_succeeded_at')->nullable(); + $table->timestamp('last_failed_at')->nullable(); + $table->timestamp('last_lag_checked_at')->nullable(); + $table->timestamp('lag_detected_at')->nullable(); + $table->timestamp('last_alerted_at')->nullable(); + $table->unsignedInteger('last_processed_count')->default(0); + $table->unsignedInteger('last_failure_count')->default(0); + $table->unsignedInteger('consecutive_failures')->default(0); + $table->text('last_error')->nullable(); + $table->json('last_failure_context')->nullable(); + $table->timestamps(); + + $table->index('last_succeeded_at'); + $table->index('lag_detected_at'); + }); + + $now = now(); + DB::table('ptero_scheduler_heartbeats')->insert([ + [ + 'task_name' => 'expire_checkout_reservations', + 'expected_interval_seconds' => 60, + 'lag_threshold_seconds' => 300, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'task_name' => 'expire_unpaid_upgrades', + 'expected_interval_seconds' => 60, + 'lag_threshold_seconds' => 300, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'task_name' => 'reconcile_paid_checkout_commitments', + 'expected_interval_seconds' => 600, + 'lag_threshold_seconds' => 1800, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'task_name' => 'reconcile_stalled_upgrades', + 'expected_interval_seconds' => 600, + 'lag_threshold_seconds' => 1800, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'task_name' => 'check_capacity_alerts', + 'expected_interval_seconds' => 300, + 'lag_threshold_seconds' => 900, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + } + + public function down(): void + { + Schema::dropIfExists('ptero_scheduler_heartbeats'); + } +}; diff --git a/database/migrations/2026_07_26_000060_allow_system_audit_entries.php b/database/migrations/2026_07_26_000060_allow_system_audit_entries.php new file mode 100644 index 0000000..2236244 --- /dev/null +++ b/database/migrations/2026_07_26_000060_allow_system_audit_entries.php @@ -0,0 +1,43 @@ +dropForeign(['user_id']); + $table->unsignedBigInteger('user_id')->nullable()->change(); + $table->foreign('user_id') + ->references('id') + ->on('users') + ->nullOnDelete(); + }); + } + + public function down(): void + { + if ( + DB::table('ptero_audit_logs') + ->whereNull('user_id') + ->exists() + ) { + throw new RuntimeException( + 'System audit entries must be retained before this migration can be rolled back.' + ); + } + + Schema::table('ptero_audit_logs', function (Blueprint $table): void { + $table->dropForeign(['user_id']); + $table->unsignedBigInteger('user_id')->nullable(false)->change(); + $table->foreign('user_id') + ->references('id') + ->on('users') + ->cascadeOnDelete(); + }); + } +}; diff --git a/database/migrations/2026_07_26_000070_add_scan_cursor_to_scheduler_heartbeats.php b/database/migrations/2026_07_26_000070_add_scan_cursor_to_scheduler_heartbeats.php new file mode 100644 index 0000000..4c05619 --- /dev/null +++ b/database/migrations/2026_07_26_000070_add_scan_cursor_to_scheduler_heartbeats.php @@ -0,0 +1,28 @@ +unsignedBigInteger('last_scanned_entity_id') + ->default(0) + ->after('last_alerted_at'); + } + ); + } + + public function down(): void + { + Schema::table( + 'ptero_scheduler_heartbeats', + fn (Blueprint $table) => $table->dropColumn('last_scanned_entity_id') + ); + } +}; diff --git a/migration-readiness.php b/migration-readiness.php new file mode 100644 index 0000000..05b5c5c --- /dev/null +++ b/migration-readiness.php @@ -0,0 +1,2897 @@ + + * }> + */ + public function blockers(): array + { + if (! Schema::hasTable('ptero_resource_reservations')) { + return [[ + 'reservation_id' => 0, + 'service_id' => null, + 'purpose' => 'schema', + 'missing' => ['table:ptero_resource_reservations'], + ]]; + } + + $requiredColumns = [ + 'token', + 'idempotency_key', + 'purpose', + 'status', + 'admin_notes', + 'cart_item_id', + 'cart_item_guard_id', + 'cart_id', + 'configuration_fingerprint', + 'configuration_payload', + 'service_id', + 'service_guard_id', + 'service_upgrade_id', + 'upgrade_guard_id', + 'server_extension_id', + 'panel_identity', + 'invoice_id', + 'user_id', + 'product_id', + 'plan_id', + 'quantity', + 'currency_code', + 'node_id', + 'location_id', + 'memory', + 'cpu', + 'disk', + 'reserved_memory', + 'reserved_cpu', + 'reserved_disk', + 'calculated_price', + 'pricing_breakdown', + 'pricing_version', + 'formula_version', + 'expires_at', + 'guaranteed_until', + 'paid_committed_at', + 'provisioning_started_at', + 'provisioning_lease_id', + 'provisioning_attempts', + 'last_provisioning_attempt_at', + 'next_provisioning_attempt_at', + 'last_provisioning_error', + 'consumed_at', + 'failure_alerted_at', + 'cancellation_requested_at', + 'last_cancellation_error', + 'cancellation_failure_alerted_at', + 'external_server_id', + 'external_user_id', + 'external_server_uuid', + 'external_server_identifier', + 'last_reconciled_at', + 'customer_notified_at', + 'product_stock_released_at', + ]; + foreach ($requiredColumns as $column) { + if (! Schema::hasColumn('ptero_resource_reservations', $column)) { + return [[ + 'reservation_id' => 0, + 'service_id' => null, + 'purpose' => 'schema', + 'missing' => ["column:{$column}"], + ]]; + } + } + + $requiredTableColumns = [ + 'ptero_capacity_scopes' => [ + 'panel_identity', + 'location_id', + ], + 'ptero_reservation_allocations' => [ + 'reservation_id', + 'panel_identity', + 'node_id', + 'allocation_id', + 'ip', + 'port', + 'environment_key', + 'is_primary', + 'released_at', + ], + 'ptero_node_capacity_policies' => [ + 'panel_identity', + 'node_uuid', + 'node_id', + 'location_id', + 'cpu_capacity_percent', + 'cpu_overcommit_bps', + 'enabled', + ], + 'services' => [ + 'status', + 'product_id', + 'plan_id', + 'user_id', + 'quantity', + 'currency_code', + 'price', + 'product_stock_released_at', + ], + 'invoices' => [ + 'status', + 'user_id', + 'currency_code', + ], + 'invoice_items' => [ + 'invoice_id', + 'price', + 'quantity', + 'reference_type', + 'reference_id', + ], + 'service_configs' => [ + 'configurable_type', + 'configurable_id', + 'config_option_id', + 'config_value_id', + 'slider_value', + ], + 'properties' => [ + 'model_type', + 'model_id', + 'key', + 'value', + ], + 'settings' => [ + 'settingable_type', + 'settingable_id', + 'key', + 'value', + 'type', + ], + 'extensions' => [ + 'id', + 'type', + 'extension', + ], + 'products' => [ + 'id', + 'server_id', + ], + 'config_options' => [ + 'id', + 'parent_id', + 'type', + 'env_variable', + 'metadata', + ], + 'config_option_products' => [ + 'product_id', + 'config_option_id', + ], + ]; + foreach ($requiredTableColumns as $table => $columns) { + if (! Schema::hasTable($table)) { + return [[ + 'reservation_id' => 0, + 'service_id' => null, + 'purpose' => 'schema', + 'missing' => ["table:{$table}"], + ]]; + } + foreach ($columns as $column) { + if (! Schema::hasColumn($table, $column)) { + return [[ + 'reservation_id' => 0, + 'service_id' => null, + 'purpose' => 'schema', + 'missing' => ["column:{$table}.{$column}"], + ]]; + } + } + } + + if (! Schema::hasTable('service_upgrades')) { + return [[ + 'reservation_id' => 0, + 'service_id' => null, + 'purpose' => 'schema', + 'missing' => ['table:service_upgrades'], + ]]; + } + foreach ([ + 'service_id', + 'product_id', + 'plan_id', + 'invoice_id', + 'status', + 'active_service_guard_id', + 'source_snapshot', + 'target_snapshot', + 'source_fingerprint', + 'target_fingerprint', + 'quoted_amount', + 'currency_code', + 'completed_at', + ] as $column) { + if (! Schema::hasColumn('service_upgrades', $column)) { + return [[ + 'reservation_id' => 0, + 'service_id' => null, + 'purpose' => 'schema', + 'missing' => ["column:service_upgrades.{$column}"], + ]]; + } + } + + $blockers = DB::table('ptero_resource_reservations') + ->where(function ($query): void { + $query->where(function ($query): void { + $query->where('purpose', 'checkout') + ->where(function ($query): void { + $query->where(function ($query): void { + $query->where('status', 'pending') + ->where(function ($query): void { + $query + ->whereNotNull('service_id') + ->orWhereNotNull( + 'service_guard_id' + ); + }); + })->orWhereIn('status', [ + 'paid_committed', + 'confirmed', + ]); + }); + })->orWhere(function ($query): void { + $query->where('purpose', 'upgrade') + ->whereIn('status', [ + 'pending', + 'paid_committed', + 'confirmed', + ]); + })->orWhere(function ($query): void { + // Older checkout-identity and durable-fulfillment + // migrations could silently cancel a service-bound + // commitment before this gate ran. + $query->where('purpose', 'checkout') + ->where('status', 'cancelled') + ->where(function ($query): void { + $query->whereNotNull('service_id') + ->orWhereNotNull('service_guard_id'); + }) + ->whereIn( + 'admin_notes', + self::LEGACY_RETIREMENT_NOTES + ); + }); + }) + ->orderBy('id') + ->get() + ->map(function (object $reservation): array { + $missing = $reservation->purpose === 'upgrade' + ? $this->missingUpgradeIdentity($reservation) + : $this->missingCheckoutIdentity($reservation); + if ($this->wasBoundCommitmentRetired($reservation)) { + $missing[] = + 'legacy bound commitment was retired before readiness'; + } + + return [ + 'reservation_id' => (int) $reservation->id, + 'service_id' => $reservation->service_id !== null + ? (int) $reservation->service_id + : null, + 'purpose' => (string) $reservation->purpose, + 'missing' => $missing, + ]; + }) + ->filter(fn (array $row): bool => $row['missing'] !== []) + ->values() + ->all(); + + return array_values(array_merge( + $blockers, + $this->missingDynamicServiceReservations(), + $this->missingActiveUpgradeReservations() + )); + } + + public function assertReady(): void + { + $blockers = $this->blockers(); + if ($blockers === []) { + return; + } + + $shown = array_slice($blockers, 0, 25); + $details = array_map( + fn (array $row): string => sprintf( + 'reservation #%d%s [%s]: %s', + $row['reservation_id'], + $row['service_id'] !== null + ? " / service #{$row['service_id']}" + : '', + $row['purpose'], + implode(', ', $row['missing']) + ), + $shown + ); + if (count($blockers) > count($shown)) { + $details[] = sprintf( + 'and %d more unresolved row(s)', + count($blockers) - count($shown) + ); + } + + throw new RuntimeException( + 'Dynamic Pterodactyl cannot finish upgrading because its dynamic ' + .'stock schema or fulfillment proofs are incomplete: ' + .implode('; ', $details) + .'. Reconstruct the exact signed order configuration for every ' + .'bound unpaid commitment. For provisioned services, also verify ' + .'and import the exact numeric server/user IDs, UUID, identifier, ' + .'panel, node, nest, and egg from Pterodactyl and auditable order ' + .'records; never infer authority from an external-ID lookup or ' + .'current product settings. Then rerun ' + .'php artisan app:extension:migrate other DynamicPterodactyl --force.' + ); + } + + /** + * @return list + */ + private function missingCheckoutIdentity(object $reservation): array + { + $missing = $reservation->status === 'confirmed' + ? $this->missingCommonRemoteIdentity($reservation) + : []; + foreach ([ + 'server_extension_id', + 'service_id', + 'service_guard_id', + 'cart_item_guard_id', + 'cart_id', + 'user_id', + 'product_id', + 'plan_id', + ] as $field) { + $this->requirePositive( + $reservation->{$field}, + $field, + $missing + ); + } + $payload = $this->decodedPayload($reservation, $missing); + if ($payload === null) { + return array_values(array_unique($missing)); + } + + $fingerprint = $reservation->configuration_fingerprint; + if ( + ! is_string($fingerprint) + || preg_match('/^[a-f0-9]{64}$/D', $fingerprint) !== 1 + || ! hash_equals( + $fingerprint, + $this->fingerprint($payload) + ) + ) { + $missing[] = 'valid configuration_fingerprint'; + } + $service = Service::query()->find($reservation->service_id); + if ($service === null) { + $missing[] = 'service'; + } else { + if (! $this->checkoutPayloadMatchesService( + $payload, + $reservation, + $service + )) { + $missing[] = 'signed checkout/service identity agreement'; + } + if (! $this->checkoutLifecycleMatches( + $reservation, + $service + )) { + $missing[] = 'checkout service guard/lifecycle agreement'; + } + if (! $this->checkoutServiceResourcesMatch( + $payload, + $reservation, + $service + )) { + $missing[] = 'delivered service resource agreement'; + } + if (! $this->checkoutPricingMatches( + $payload, + $reservation + )) { + $missing[] = 'signed checkout pricing agreement'; + } + if (! $this->checkoutInvoiceMatches( + $reservation, + $service + )) { + $missing[] = 'checkout invoice/service billing agreement'; + } + } + + $provisioning = $payload['provisioning_identity'] ?? null; + if (! is_array($provisioning)) { + $missing[] = 'provisioning_identity'; + + return array_values(array_unique($missing)); + } + $this->requirePositive( + $provisioning['nest_id'] ?? null, + 'provisioning_identity.nest_id', + $missing + ); + $this->requirePositive( + $provisioning['egg_id'] ?? null, + 'provisioning_identity.egg_id', + $missing + ); + $expectedUserExternalId = $reservation->user_id !== null + ? "paymenter-user-{$reservation->user_id}" + : null; + if ( + $expectedUserExternalId === null + || ! is_string($provisioning['user_external_id'] ?? null) + || ! hash_equals( + $expectedUserExternalId, + $provisioning['user_external_id'] + ) + ) { + $missing[] = 'provisioning_identity.user_external_id'; + } + foreach ([ + 'panel_identity' => $reservation->panel_identity, + 'cart_id' => $reservation->cart_id, + 'node_id' => $reservation->node_id, + 'customer_id' => $reservation->user_id, + 'product_id' => $reservation->product_id, + 'plan_id' => $reservation->plan_id, + ] as $field => $expected) { + $actual = data_get($payload, $field); + if ( + $field === 'panel_identity' + ? ! is_string($actual) + || ! is_string($expected) + || ! hash_equals($expected, $actual) + : StrictInteger::parse($actual) === null + || (int) $actual !== (int) $expected + ) { + $missing[] = "configuration_payload.{$field}"; + } + } + $this->requireSignedAllocationClaims( + $reservation, + $payload, + $missing + ); + + return array_values(array_unique($missing)); + } + + /** + * A bound checkout is not migratable unless the allocation rows still + * materialize the exact signed port claims. Otherwise a paid invoice could + * become durable before provisioning discovers the drift. + * + * @param array $payload + * @param list $missing + */ + private function requireSignedAllocationClaims( + object $reservation, + array $payload, + array &$missing + ): void { + $claimRequirement = $reservation->status === 'confirmed' + ? 'released signed allocation claims' + : 'active signed allocation claims'; + if (! Schema::hasTable('ptero_reservation_allocations')) { + $missing[] = $claimRequirement; + + return; + } + + $allocations = $payload['allocations'] ?? null; + $requiredCount = StrictInteger::parse( + data_get( + $payload, + 'allocation_requirements.required_count' + ) + ); + if ( + ! is_array($allocations) + || $allocations === [] + || $requiredCount === null + || $requiredCount <= 0 + || count($allocations) !== $requiredCount + ) { + $missing[] = $claimRequirement; + + return; + } + + $expected = []; + foreach ($allocations as $allocation) { + if (! is_array($allocation)) { + $missing[] = $claimRequirement; + + return; + } + $allocationId = StrictInteger::parse( + $allocation['allocation_id'] ?? null + ); + $port = StrictInteger::parse($allocation['port'] ?? null); + $ip = $allocation['ip'] ?? null; + $environmentKey = $allocation['environment_key'] ?? null; + $isPrimary = $allocation['is_primary'] ?? null; + if ( + $allocationId === null + || $allocationId <= 0 + || $port === null + || $port <= 0 + || $port > 65535 + || ! is_string($ip) + || trim($ip) === '' + || ( + $environmentKey !== null + && ! is_string($environmentKey) + ) + || ! is_bool($isPrimary) + ) { + $missing[] = $claimRequirement; + + return; + } + $expected[] = [ + 'panel_identity' => (string) $reservation->panel_identity, + 'node_id' => (int) $reservation->node_id, + 'allocation_id' => $allocationId, + 'ip' => $ip, + 'port' => $port, + 'environment_key' => $environmentKey, + 'is_primary' => $isPrimary, + ]; + } + usort( + $expected, + fn (array $left, array $right): int => $left['allocation_id'] <=> $right['allocation_id'] + ); + + $claims = DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservation->id) + ->get(); + $actual = $claims + ->map(fn (object $allocation): array => [ + 'panel_identity' => (string) $allocation->panel_identity, + 'node_id' => (int) $allocation->node_id, + 'allocation_id' => (int) $allocation->allocation_id, + 'ip' => (string) ($allocation->ip ?? ''), + 'port' => (int) $allocation->port, + 'environment_key' => $allocation->environment_key, + 'is_primary' => (bool) $allocation->is_primary, + ]) + ->sortBy('allocation_id') + ->values() + ->all(); + + if ( + $expected !== $actual + || count(array_unique( + array_column($actual, 'allocation_id') + )) !== count($actual) + || collect($actual)->where('is_primary', true)->count() !== 1 + || ( + $reservation->status === 'confirmed' + ? $claims->contains( + fn (object $allocation): bool => $allocation->released_at === null + ) + : $claims->contains( + fn (object $allocation): bool => $allocation->released_at !== null + ) + ) + ) { + $missing[] = $claimRequirement; + } + } + + private function wasBoundCommitmentRetired( + object $reservation + ): bool { + return $reservation->purpose === 'checkout' + && $reservation->status === 'cancelled' + && ( + $reservation->service_id !== null + || $reservation->service_guard_id !== null + ) + && in_array( + $reservation->admin_notes, + self::LEGACY_RETIREMENT_NOTES, + true + ); + } + + /** + * A reservation-first scan cannot detect a dynamic service whose + * commitment was deleted, detached, or duplicated with a null guard. + * Inspect every non-cancelled capacity-backed service in reverse and + * require one lifecycle-coherent checkout commitment. + * + * @return list + * }> + */ + private function missingDynamicServiceReservations(): array + { + $historyServiceIds = DB::table('ptero_resource_reservations') + ->where('purpose', 'checkout') + ->whereNotNull('service_id') + ->distinct() + ->pluck('service_id') + ->map(fn ($id): int => (int) $id) + ->all(); + $dynamicProductIds = array_keys($this->rawDynamicProductIds()); + $configuredServiceIds = + $this->rawDynamicConfiguredServiceIds(); + if ( + $historyServiceIds === [] + && $dynamicProductIds === [] + && $configuredServiceIds === [] + ) { + return []; + } + + $services = Service::query() + ->where('status', '!=', Service::STATUS_CANCELLED) + ->where(function ($query) use ( + $historyServiceIds, + $dynamicProductIds, + $configuredServiceIds + ): void { + if ($historyServiceIds !== []) { + $query->whereIn('id', $historyServiceIds); + } + if ($configuredServiceIds !== []) { + $method = $historyServiceIds === [] + ? 'whereIn' + : 'orWhereIn'; + $query->{$method}('id', $configuredServiceIds); + } + if ($dynamicProductIds !== []) { + $method = $historyServiceIds === [] + && $configuredServiceIds === [] + ? 'whereIn' + : 'orWhereIn'; + $query->{$method}('product_id', $dynamicProductIds); + } + }) + ->orderBy('id') + ->get(); + if ($services->isEmpty()) { + return []; + } + + $history = DB::table('ptero_resource_reservations') + ->where('purpose', 'checkout') + ->whereIn('service_id', $services->modelKeys()) + ->orderBy('id') + ->get() + ->groupBy('service_id'); + $blockers = []; + + foreach ($services as $service) { + $reservations = $history + ->get($service->id, collect()) + ->whereIn('status', [ + 'pending', + 'paid_committed', + 'confirmed', + ]) + ->values(); + $expectedStatuses = $this->checkoutStatusesForService( + (string) $service->status + ); + $coherent = $expectedStatuses !== [] + && $reservations->count() === 1 + && in_array( + (string) $reservations->first()->status, + $expectedStatuses, + true + ) + && $this->checkoutLifecycleMatches( + $reservations->first(), + $service + ); + if ($coherent) { + continue; + } + + $blockers[] = [ + 'reservation_id' => $reservations->count() === 1 + ? (int) $reservations->first()->id + : 0, + 'service_id' => (int) $service->id, + 'purpose' => 'checkout', + 'missing' => [ + "dynamic service #{$service->id} requires exactly one " + .'lifecycle-coherent checkout capacity reservation', + ], + ]; + } + + return $blockers; + } + + /** + * A product or pivot may have been removed after a service was created. + * Its persisted dynamic ServiceConfig rows remain enough evidence that the + * service requires a capacity commitment. + * + * @return list + */ + private function rawDynamicConfiguredServiceIds(): array + { + $rows = DB::table('service_configs') + ->join( + 'config_options', + 'config_options.id', + '=', + 'service_configs.config_option_id' + ) + ->where( + 'service_configs.configurable_type', + Service::class + ) + ->where('config_options.type', 'dynamic_slider') + ->get([ + 'service_configs.configurable_id', + 'config_options.env_variable', + 'config_options.metadata', + ]); + $serviceIds = []; + foreach ($rows as $row) { + $metadata = is_string($row->metadata) + ? json_decode($row->metadata, true) + : (array) $row->metadata; + $resource = strtolower((string) ( + (is_array($metadata) + ? ($metadata['resource_type'] ?? null) + : null) + ?? $row->env_variable + ?? '' + )); + if (in_array($resource, ['memory', 'cpu', 'disk'], true)) { + $serviceIds[(int) $row->configurable_id] = true; + } + } + + return array_keys($serviceIds); + } + + /** + * Reservation-first scans cannot see an active dynamic ServiceUpgrade + * whose capacity row is missing, terminal, or duplicated. Inspect the + * core lifecycle in the reverse direction and require its one authoritative + * reservation before an extension upgrade may complete. + * + * @return list + * }> + */ + private function missingActiveUpgradeReservations(): array + { + $upgrades = ServiceUpgrade::query() + ->with([ + 'service.product', + 'product', + ]) + ->where(function ($query): void { + $query->whereIn('status', [ + ...ServiceUpgrade::activeStatuses(), + ServiceUpgrade::STATUS_COMPLETED, + ]) + ->orWhereNotNull('active_service_guard_id'); + }) + ->orderBy('id') + ->get(); + if ($upgrades->isEmpty()) { + return []; + } + + $history = DB::table('ptero_resource_reservations') + ->where('purpose', 'upgrade') + ->whereIn('service_upgrade_id', $upgrades->modelKeys()) + ->orderBy('id') + ->get() + ->groupBy('service_upgrade_id'); + $rawDynamicProducts = $this->rawDynamicProductIds(); + $rawPterodactylProducts = $this->rawPterodactylProductIds(); + $capacityBackedServices = DB::table( + 'ptero_resource_reservations' + ) + ->where('purpose', 'checkout') + ->whereNotNull('service_id') + ->pluck('service_id') + ->mapWithKeys( + fn ($id): array => [(int) $id => true] + ) + ->all(); + $dynamicCache = []; + $blockers = []; + + foreach ($upgrades as $upgrade) { + $rows = $history->get($upgrade->id, collect()); + $classificationFailed = false; + try { + $currentProduct = $upgrade->service?->product; + $targetProduct = $upgrade->product; + if ($currentProduct === null || $targetProduct === null) { + throw new RuntimeException( + 'The active upgrade product identity is missing.' + ); + } + foreach ([$currentProduct, $targetProduct] as $product) { + $productId = (int) $product->id; + $dynamicCache[$productId] ??= + $product->usesDynamicResources(); + } + $dynamic = $rows->isNotEmpty() + || isset($capacityBackedServices[ + (int) $upgrade->service_id + ]) + || ($dynamicCache[(int) $currentProduct->id] ?? false) + || ($dynamicCache[(int) $targetProduct->id] ?? false) + || isset($rawDynamicProducts[(int) $currentProduct->id]) + || isset($rawDynamicProducts[(int) $targetProduct->id]) + || ( + ( + isset($rawPterodactylProducts[ + (int) $currentProduct->id + ]) + || isset($rawPterodactylProducts[ + (int) $targetProduct->id + ]) + ) + && $this->upgradeSnapshotsChangeOnlyResources( + $upgrade + ) + ); + } catch (Throwable) { + $dynamic = true; + $classificationFailed = true; + } + if (! $dynamic) { + continue; + } + + $reservations = $rows->whereIn('status', [ + 'pending', + 'paid_committed', + 'confirmed', + ])->values(); + $expectedStatus = match ((string) $upgrade->status) { + ServiceUpgrade::STATUS_PENDING, + ServiceUpgrade::STATUS_AWAITING_PAYMENT => 'pending', + ServiceUpgrade::STATUS_PAID_COMMITTED, + ServiceUpgrade::STATUS_PROVISIONING, + ServiceUpgrade::STATUS_RETRYABLE_FAILED, + ServiceUpgrade::STATUS_NEEDS_ATTENTION => 'paid_committed', + ServiceUpgrade::STATUS_COMPLETED => 'confirmed', + default => null, + }; + $reservation = $reservations->count() === 1 + ? $reservations->first() + : null; + $reservationGuard = $reservation !== null + ? $this->positiveInteger( + $reservation->upgrade_guard_id ?? null + ) + : null; + $upgradeGuard = $this->positiveInteger( + $upgrade->active_service_guard_id + ); + if ( + ! $classificationFailed + && $expectedStatus !== null + && $reservations->count() === 1 + && $reservation?->status === $expectedStatus + && $this->upgradeLifecycleMatches( + (string) $reservation->status, + (string) $upgrade->status, + $reservationGuard, + $upgradeGuard, + (int) $upgrade->id, + (int) $upgrade->service_id, + $reservation->consumed_at ?? null, + $upgrade->completed_at + ) + ) { + continue; + } + + $blockers[] = [ + 'reservation_id' => $reservations->count() === 1 + ? (int) $reservations->first()->id + : 0, + 'service_id' => $upgrade->service_id !== null + ? (int) $upgrade->service_id + : null, + 'purpose' => 'upgrade', + 'missing' => [ + "dynamic service upgrade #{$upgrade->id} " + .'requires exactly one coherent capacity reservation', + ], + ]; + } + + return $blockers; + } + + /** + * Detect capacity-backed products without Eloquent soft-delete or current + * visibility scopes. A host or slider hidden after quote creation must not + * make an active dynamic upgrade disappear from readiness. + * + * @return array + */ + private function rawDynamicProductIds(): array + { + $rows = DB::table('config_options') + ->join( + 'config_option_products', + 'config_option_products.config_option_id', + '=', + 'config_options.id' + ) + ->join( + 'products', + 'products.id', + '=', + 'config_option_products.product_id' + ) + ->join( + 'extensions as server_extensions', + 'server_extensions.id', + '=', + 'products.server_id' + ) + ->where('config_options.type', 'dynamic_slider') + ->whereNull('config_options.parent_id') + ->where('server_extensions.type', 'server') + ->where('server_extensions.extension', 'Pterodactyl') + ->get([ + 'config_option_products.product_id', + 'config_options.env_variable', + 'config_options.metadata', + ]); + $products = []; + + foreach ($rows as $row) { + $metadata = is_string($row->metadata) + ? json_decode($row->metadata, true) + : (array) $row->metadata; + $resource = strtolower((string) ( + (is_array($metadata) + ? ($metadata['resource_type'] ?? null) + : null) + ?? $row->env_variable + ?? '' + )); + if (in_array($resource, ['memory', 'cpu', 'disk'], true)) { + $products[(int) $row->product_id] = true; + } + } + + return $products; + } + + /** + * @return array + */ + private function rawPterodactylProductIds(): array + { + return DB::table('products') + ->join( + 'extensions as server_extensions', + 'server_extensions.id', + '=', + 'products.server_id' + ) + ->where('server_extensions.type', 'server') + ->where('server_extensions.extension', 'Pterodactyl') + ->pluck('products.id') + ->mapWithKeys( + fn ($id): array => [(int) $id => true] + ) + ->all(); + } + + private function upgradeSnapshotsChangeOnlyResources( + ServiceUpgrade $upgrade + ): bool { + $source = $this->snapshotProperties( + $this->decodedArray($upgrade->source_snapshot) + ); + $target = $this->snapshotProperties( + $this->decodedArray($upgrade->target_snapshot) + ); + if ($source === null || $target === null) { + return false; + } + + $sourceResources = $this->resourceVectorFromProperties($source); + $targetResources = $this->resourceVectorFromProperties($target); + + return $sourceResources !== null + && $targetResources !== null + && $sourceResources !== $targetResources + && $this->onlyResourcesChanged($source, $target); + } + + /** + * Reproduce the checkout identity proof locally so an upload never resolves + * a readiness service class that may already be loaded from the old tree. + * + * @param array $payload + */ + private function checkoutPayloadMatchesService( + array $payload, + object $reservation, + Service $service + ): bool { + $provisioning = $payload['provisioning_identity'] ?? null; + $nestId = is_array($provisioning) + ? StrictInteger::parse($provisioning['nest_id'] ?? null) + : null; + $eggId = is_array($provisioning) + ? StrictInteger::parse($provisioning['egg_id'] ?? null) + : null; + $customerId = StrictInteger::parse( + $payload['customer_id'] ?? null + ); + $serverExtensionId = StrictInteger::parse( + $payload['server_extension_id'] ?? null + ); + $productId = StrictInteger::parse( + $payload['product_id'] ?? null + ); + $planId = StrictInteger::parse($payload['plan_id'] ?? null); + $quantity = StrictInteger::parse($payload['quantity'] ?? null); + $memory = StrictInteger::parse( + data_get($payload, 'resources.memory') + ); + $cpu = StrictInteger::parse( + data_get($payload, 'resources.cpu') + ); + $disk = StrictInteger::parse( + data_get($payload, 'resources.disk') + ); + $locationId = StrictInteger::parse( + $payload['location_id'] ?? null + ); + $nodeId = StrictInteger::parse($payload['node_id'] ?? null); + + return (int) $reservation->service_id === (int) $service->id + && (int) $reservation->user_id === (int) $service->user_id + && (int) $reservation->product_id === (int) $service->product_id + && (int) $reservation->plan_id === (int) $service->plan_id + && (int) $reservation->quantity === (int) $service->quantity + && (int) $reservation->quantity === 1 + && strtoupper((string) $reservation->currency_code) + === strtoupper((string) $service->currency_code) + && $customerId !== null + && $customerId + === (int) $reservation->user_id + && $serverExtensionId !== null + && $serverExtensionId + === (int) $reservation->server_extension_id + && (string) ($payload['panel_identity'] ?? '') + === (string) $reservation->panel_identity + && $productId !== null + && $productId + === (int) $reservation->product_id + && $planId !== null + && $planId + === (int) $reservation->plan_id + && $quantity === 1 + && $quantity + === (int) $reservation->quantity + && strtoupper((string) ($payload['currency_code'] ?? '')) + === strtoupper((string) $reservation->currency_code) + && $memory !== null + && $memory + === (int) $reservation->memory + && $cpu !== null + && $cpu + === (int) $reservation->cpu + && $disk !== null + && $disk + === (int) $reservation->disk + && $locationId !== null + && $locationId + === (int) $reservation->location_id + && $nodeId !== null + && $nodeId + === (int) $reservation->node_id + && $nestId !== null + && $nestId > 0 + && $eggId !== null + && $eggId > 0 + && is_string($provisioning['user_external_id'] ?? null) + && hash_equals( + "paymenter-user-{$service->user_id}", + $provisioning['user_external_id'] + ) + && is_string($provisioning['user_email'] ?? null) + && trim($provisioning['user_email']) !== '' + && $this->productPanelMatchesCheckout( + $reservation + ); + } + + private function productPanelMatchesCheckout( + object $reservation + ): bool { + try { + $serverId = $this->positiveInteger( + $reservation->server_extension_id ?? null + ); + $server = $serverId !== null + ? Server::query() + ->with('settings') + ->find($serverId) + : null; + if ( + $server === null + || $server->extension !== 'Pterodactyl' + || ! is_string($reservation->panel_identity ?? null) + ) { + return false; + } + $settings = ExtensionHelper::settingsToArray( + $server->settings + ); + $panelIdentity = PanelEndpointIdentity::hash( + trim((string) ($settings['host'] ?? '')) + ); + } catch (Throwable) { + return false; + } + + return hash_equals( + (string) $reservation->panel_identity, + $panelIdentity + ); + } + + /** + * @return list + */ + private function checkoutStatusesForService(string $status): array + { + return match ($status) { + Service::STATUS_PENDING => ['pending'], + Service::STATUS_PROVISIONING, + Service::STATUS_PROVISIONING_FAILED => ['paid_committed'], + Service::STATUS_ACTIVE, + Service::STATUS_SUSPENDED => ['confirmed'], + Service::STATUS_CANCELLATION_PENDING => [ + 'paid_committed', + 'confirmed', + ], + Service::STATUS_CANCELLED => ['confirmed'], + default => [], + }; + } + + private function checkoutLifecycleMatches( + object $reservation, + Service $service + ): bool { + $guard = $this->positiveInteger( + $reservation->service_guard_id ?? null + ); + $status = (string) ($reservation->status ?? ''); + $expectedStatuses = $this->checkoutStatusesForService( + (string) $service->status + ); + + $cancelledConfirmed = (string) $service->status + === Service::STATUS_CANCELLED + && $status === 'confirmed'; + $cancellationPending = (string) $service->status + === Service::STATUS_CANCELLATION_PENDING; + $stockReleaseMatches = $cancelledConfirmed + ? ($reservation->product_stock_released_at ?? null) !== null + && $service->product_stock_released_at !== null + : ($reservation->product_stock_released_at ?? null) === null + && $service->product_stock_released_at === null; + + return (int) ($reservation->service_id ?? 0) === (int) $service->id + && $guard === (int) $service->id + && in_array($status, $expectedStatuses, true) + && $stockReleaseMatches + && ( + ! $cancellationPending + || ($reservation->cancellation_requested_at ?? null) + !== null + ) + && ( + ! $cancelledConfirmed + || ( + ($reservation->cancellation_requested_at ?? null) + !== null + ) + ) + && ( + $status === 'confirmed' + ? ($reservation->consumed_at ?? null) !== null + : in_array( + $status, + ['pending', 'paid_committed'], + true + ) + && ($reservation->consumed_at ?? null) === null + ); + } + + /** + * The signed resources must also be the values Paymenter's provisioner + * reads from the Service. A self-consistent reservation cannot authorize + * different mutable ServiceConfig values. + * + * @param array $payload + */ + private function checkoutServiceResourcesMatch( + array $payload, + object $reservation, + Service $service + ): bool { + $properties = $this->currentServiceProperties($service); + $serviceResources = $this->resourceVectorFromProperties( + $properties + ); + $serviceLocation = $this->locationFromProperties($properties); + $payloadResources = $this->resourceVector( + $payload['resources'] ?? null + ); + $payloadLocation = StrictInteger::parse( + $payload['location_id'] ?? null + ); + $rowResources = $this->resourceVector([ + 'memory' => $reservation->memory ?? null, + 'cpu' => $reservation->cpu ?? null, + 'disk' => $reservation->disk ?? null, + ]); + $rowLocation = StrictInteger::parse( + $reservation->location_id ?? null + ); + + $immutableReservationMatches = $payloadResources !== null + && $payloadLocation !== null + && $rowResources !== null + && $rowLocation !== null + && $payloadResources === $rowResources + && $payloadLocation === $rowLocation; + if ( + ! $immutableReservationMatches + || $this->serviceHasCompletedUpgrade((int) $service->id) + ) { + return $immutableReservationMatches; + } + + return $serviceResources !== null + && $serviceLocation !== null + && $serviceResources === $rowResources + && $serviceLocation === $rowLocation; + } + + /** + * @param array $payload + */ + private function checkoutPricingMatches( + array $payload, + object $reservation + ): bool { + $configOptions = $payload['config_options'] ?? null; + if (! is_array($configOptions) || ! array_is_list($configOptions)) { + return false; + } + if (! $this->checkoutResourceOptionsMatch( + $payload, + $configOptions + )) { + return false; + } + + try { + $payloadAmount = $this->normalizedMoney( + $payload['calculated_price'] ?? null + ); + $reservationAmount = $this->normalizedMoney( + $reservation->calculated_price ?? null + ); + $pricingVersion = $this->fingerprint([ + 'product_id' => (int) $reservation->product_id, + 'plan_id' => (int) $reservation->plan_id, + 'currency_code' => strtoupper( + (string) $reservation->currency_code + ), + 'calculated_price' => $reservationAmount, + 'config_options' => $configOptions, + ]); + } catch (Throwable) { + return false; + } + + return $reservationAmount[0] !== '-' + && $payloadAmount === $reservationAmount + && is_string($payload['pricing_version'] ?? null) + && hash_equals( + $pricingVersion, + $payload['pricing_version'] + ) + && is_string($reservation->pricing_version ?? null) + && hash_equals( + $pricingVersion, + (string) $reservation->pricing_version + ) + && ($payload['formula_version'] ?? null) + === 'dynamic-pterodactyl-v1' + && ($reservation->formula_version ?? null) + === 'dynamic-pterodactyl-v1'; + } + + /** + * @param array $payload + * @param list $configOptions + */ + private function checkoutResourceOptionsMatch( + array $payload, + array $configOptions + ): bool { + $payloadResources = $this->resourceVector( + $payload['resources'] ?? null + ); + if ($payloadResources === null) { + return false; + } + + $selected = []; + $optionIds = []; + foreach ($configOptions as $configOption) { + if (! is_array($configOption)) { + return false; + } + $resource = strtolower((string) ( + $configOption['resource_type'] ?? '' + )); + if (! in_array($resource, ['memory', 'cpu', 'disk'], true)) { + continue; + } + $optionId = $this->positiveInteger( + $configOption['id'] ?? null + ); + $value = StrictInteger::parse( + $configOption['value'] ?? null + ); + if ( + $optionId === null + || $value === null + || ($configOption['type'] ?? null) !== 'dynamic_slider' + || array_key_exists($resource, $selected) + || array_key_exists($optionId, $optionIds) + ) { + return false; + } + $selected[$resource] = $value; + $optionIds[$optionId] = $resource; + } + + return ! ( + $selected === [] + || collect($selected)->contains( + fn (int $value, string $resource): bool => $value !== $payloadResources[$resource] + ) + ); + } + + private function checkoutInvoiceMatches( + object $reservation, + Service $service + ): bool { + try { + $amount = $this->normalizedMoney( + $reservation->calculated_price ?? null + ); + } catch (Throwable) { + return false; + } + if ($amount[0] === '-') { + return false; + } + if (! $this->moneyIsPositive($amount)) { + return ($reservation->invoice_id ?? null) === null; + } + + $invoiceId = $this->positiveInteger( + $reservation->invoice_id ?? null + ); + if ($invoiceId === null) { + return false; + } + $invoice = DB::table('invoices')->where('id', $invoiceId)->first(); + $reservationStatus = (string) ($reservation->status ?? ''); + if ( + $invoice === null + || (int) $invoice->user_id !== (int) $service->user_id + || strtoupper((string) $invoice->currency_code) + !== strtoupper((string) $service->currency_code) + || ( + $reservationStatus === 'pending' + ? (string) $invoice->status !== 'pending' + : (string) $invoice->status !== 'paid' + ) + ) { + return false; + } + + $items = DB::table('invoice_items') + ->where('invoice_id', $invoiceId) + ->where('reference_type', $service->getMorphClass()) + ->where('reference_id', $service->id) + ->orderBy('id') + ->get(); + if ($items->count() !== 1) { + return false; + } + $item = $items->first(); + + try { + $lineAmount = $this->normalizedMoney($item->price); + } catch (Throwable) { + return false; + } + + return (int) $item->quantity === 1 + && (int) $reservation->quantity === 1 + && $lineAmount === $amount; + } + + /** + * @return array|null + */ + private function currentServiceProperties(Service $service): ?array + { + try { + $service->loadMissing([ + 'product.settings', + 'properties', + 'configs.configOption', + 'configs.configValue', + ]); + if ($service->product === null) { + return null; + } + $properties = array_merge( + ExtensionHelper::settingsToArray( + $service->product->settings + ), + ExtensionHelper::getServiceProperties($service) + ); + // MariaDB hydrates DECIMAL slider values as strings while SQLite + // commonly returns integers. Restore their exact stored integer + // representation before comparing the live service with its + // signed target. Do not apply today's mutable min/max/step rules + // to a historical upgrade that has already completed. + foreach ($service->configs as $config) { + $option = $config->configOption; + if ( + $option === null + || ! $option->isDynamicSlider() + || $config->slider_value === null + ) { + continue; + } + $key = strtolower((string) ( + $option->env_variable ?: $option->name + )); + if (! in_array($key, ['memory', 'cpu', 'disk'], true)) { + continue; + } + $numericValue = StrictInteger::parseStoredDecimal( + $config->slider_value + ); + if ($numericValue === null) { + return null; + } + $properties[$key] = $numericValue; + } + } catch (Throwable) { + return null; + } + + $normalized = []; + foreach ($properties as $key => $value) { + $key = strtolower((string) $key); + if ($key === '' || array_key_exists($key, $normalized)) { + return null; + } + $normalized[$key] = $value; + } + ksort($normalized); + + return $normalized; + } + + private function serviceHasCompletedUpgrade(int $serviceId): bool + { + return ServiceUpgrade::query() + ->where('service_id', $serviceId) + ->where('status', ServiceUpgrade::STATUS_COMPLETED) + ->exists(); + } + + /** + * @param array $payload + */ + private function fingerprint(array $payload): string + { + return hash('sha256', json_encode( + $this->canonicalize($payload), + JSON_THROW_ON_ERROR + | JSON_PRESERVE_ZERO_FRACTION + | JSON_UNESCAPED_SLASHES + )); + } + + /** + * @param array $value + * @return array + */ + private function canonicalize(array $value): array + { + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = $this->canonicalize($item); + } elseif ( + is_float($item) + && is_finite($item) + && floor($item) === $item + && $item >= PHP_INT_MIN + && $item <= PHP_INT_MAX + ) { + $value[$key] = (int) $item; + } + } + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } + + /** + * @return list + */ + private function missingUpgradeIdentity(object $reservation): array + { + $missing = $this->missingCommonRemoteIdentity($reservation); + $payload = $this->decodedPayload($reservation, $missing); + if ($payload === null) { + return array_values(array_unique($missing)); + } + + foreach ([ + 'service_upgrade_id', + 'node_id', + 'location_id', + 'external_server_id', + 'external_user_id', + 'nest_id', + 'egg_id', + 'allocation_id', + ] as $field) { + $this->requirePositive( + $payload[$field] ?? null, + "configuration_payload.{$field}", + $missing + ); + } + foreach ([ + 'external_server_uuid', + 'external_server_identifier', + 'external_server_external_id', + 'user_external_id', + ] as $field) { + if ( + ! is_string($payload[$field] ?? null) + || trim($payload[$field]) === '' + ) { + $missing[] = "configuration_payload.{$field}"; + } + } + if ( + is_string($payload['external_server_uuid'] ?? null) + && ! Str::isUuid($payload['external_server_uuid']) + ) { + $missing[] = 'configuration_payload.external_server_uuid'; + } + foreach (['source', 'target', 'delta', 'preserved_build'] as $field) { + if (! is_array($payload[$field] ?? null)) { + $missing[] = "configuration_payload.{$field}"; + } + } + $allocationIds = $payload['assigned_allocation_ids'] ?? null; + if ( + ! is_array($allocationIds) + || $allocationIds === [] + || collect($allocationIds)->contains( + fn ($id): bool => StrictInteger::parse($id) === null + || (int) $id <= 0 + ) + ) { + $missing[] = 'configuration_payload.assigned_allocation_ids'; + } + if ( + (string) ($payload['panel_identity'] ?? '') + !== (string) $reservation->panel_identity + || (int) ($payload['node_id'] ?? 0) + !== (int) $reservation->node_id + || (int) ($payload['external_server_id'] ?? 0) + !== (int) $reservation->external_server_id + || (int) ($payload['external_user_id'] ?? 0) + !== (int) $reservation->external_user_id + || (string) ($payload['external_server_uuid'] ?? '') + !== (string) $reservation->external_server_uuid + || (string) ($payload['external_server_identifier'] ?? '') + !== (string) $reservation->external_server_identifier + ) { + $missing[] = 'payload/row identity agreement'; + } + if ( + ! is_string($reservation->configuration_fingerprint) + || preg_match( + '/^[a-f0-9]{64}$/D', + $reservation->configuration_fingerprint + ) !== 1 + ) { + $missing[] = 'configuration_fingerprint'; + } elseif (! $this->upgradeFingerprintMatches( + $reservation, + $payload + )) { + $missing[] = 'valid configuration_fingerprint'; + } + if ( + Schema::hasTable('ptero_reservation_allocations') + && DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservation->id) + ->exists() + ) { + $missing[] = 'unexpected checkout allocation claims'; + } + + return array_values(array_unique($missing)); + } + + /** + * Prove the active upgrade payload against the billing and before/after + * fingerprints captured on its ServiceUpgrade row. + * + * @param array $payload + */ + private function upgradeFingerprintMatches( + object $reservation, + array $payload + ): bool { + $upgradeId = $this->positiveInteger( + $reservation->service_upgrade_id ?? null + ); + if ($upgradeId === null) { + return false; + } + + try { + $upgrade = ServiceUpgrade::query() + ->with([ + 'service.product.server.settings', + 'service.product.settings', + 'service.plan', + 'service.configs.configOption', + 'service.configs.configValue', + 'service.user', + 'product.server.settings', + 'product.settings', + 'plan', + 'configs.configOption', + 'configs.configValue', + 'invoice', + ]) + ->find($upgradeId); + } catch (Throwable) { + return false; + } + if ($upgrade === null || $upgrade->service === null) { + return false; + } + + $source = $this->resourceVector($payload['source'] ?? null); + $target = $this->resourceVector($payload['target'] ?? null); + $delta = $this->resourceVector($payload['delta'] ?? null); + $rowTarget = $this->resourceVector([ + 'memory' => $reservation->memory ?? null, + 'cpu' => $reservation->cpu ?? null, + 'disk' => $reservation->disk ?? null, + ]); + $rowDelta = $this->resourceVector([ + 'memory' => $reservation->reserved_memory ?? null, + 'cpu' => $reservation->reserved_cpu ?? null, + 'disk' => $reservation->reserved_disk ?? null, + ]); + $sourceSnapshot = $this->decodedArray( + $upgrade->source_snapshot + ); + $targetSnapshot = $this->decodedArray( + $upgrade->target_snapshot + ); + $sourceProperties = $this->snapshotProperties($sourceSnapshot); + $targetProperties = $this->snapshotProperties($targetSnapshot); + $snapshotSource = $this->resourceVectorFromProperties( + $sourceProperties + ); + $snapshotTarget = $this->resourceVectorFromProperties( + $targetProperties + ); + $snapshotLocation = $this->locationFromProperties( + $targetProperties + ); + $sourceSnapshotHash = $this->snapshotFingerprint($sourceSnapshot); + $targetSnapshotHash = $this->snapshotFingerprint($targetSnapshot); + $sourceFingerprint = $upgrade->source_fingerprint; + $targetFingerprint = $upgrade->target_fingerprint; + $upgradeServiceId = $this->positiveInteger($upgrade->service_id); + $upgradeProductId = $this->positiveInteger($upgrade->product_id); + $upgradePlanId = $this->positiveInteger($upgrade->plan_id); + $upgradeInvoiceId = $this->positiveInteger($upgrade->invoice_id); + $serviceUserId = $this->positiveInteger($upgrade->service->user_id); + $serviceProductId = $this->positiveInteger( + $upgrade->service->product_id + ); + $servicePlanId = $this->positiveInteger( + $upgrade->service->plan_id + ); + $serviceQuantity = StrictInteger::parse( + $upgrade->service->quantity + ); + $serviceCurrency = strtoupper( + (string) $upgrade->service->currency_code + ); + $productServerId = $this->positiveInteger( + $upgrade->product?->server_id + ); + $reservationServiceId = $this->positiveInteger( + $reservation->service_id ?? null + ); + $reservationUpgradeId = $this->positiveInteger( + $reservation->service_upgrade_id ?? null + ); + $reservationUserId = $this->positiveInteger( + $reservation->user_id ?? null + ); + $reservationProductId = $this->positiveInteger( + $reservation->product_id ?? null + ); + $reservationPlanId = $this->positiveInteger( + $reservation->plan_id ?? null + ); + $reservationInvoiceId = $this->positiveInteger( + $reservation->invoice_id ?? null + ); + $reservationServerId = $this->positiveInteger( + $reservation->server_extension_id ?? null + ); + $reservationQuantity = StrictInteger::parse( + $reservation->quantity ?? null + ); + $reservationGuard = $this->positiveInteger( + $reservation->upgrade_guard_id ?? null + ); + $upgradeGuard = $this->positiveInteger( + $upgrade->active_service_guard_id + ); + $payloadUpgradeId = $this->positiveInteger( + $payload['service_upgrade_id'] ?? null + ); + $nodeId = $this->positiveInteger($reservation->node_id ?? null); + $payloadNodeId = $this->positiveInteger( + $payload['node_id'] ?? null + ); + $locationId = $this->positiveInteger( + $reservation->location_id ?? null + ); + $payloadLocationId = $this->positiveInteger( + $payload['location_id'] ?? null + ); + $externalServerId = $this->positiveInteger( + $reservation->external_server_id ?? null + ); + $payloadExternalServerId = $this->positiveInteger( + $payload['external_server_id'] ?? null + ); + $externalUserId = $this->positiveInteger( + $reservation->external_user_id ?? null + ); + $payloadExternalUserId = $this->positiveInteger( + $payload['external_user_id'] ?? null + ); + $nestId = $this->positiveInteger($payload['nest_id'] ?? null); + $eggId = $this->positiveInteger($payload['egg_id'] ?? null); + $allocationId = $this->positiveInteger( + $payload['allocation_id'] ?? null + ); + $assignedAllocationIds = $this->positiveIntegerList( + $payload['assigned_allocation_ids'] ?? null + ); + $preservedBuild = $this->preservedBuild( + $payload['preserved_build'] ?? null + ); + $panelIdentity = $payload['panel_identity'] ?? null; + $externalServerUuid = $payload['external_server_uuid'] ?? null; + $externalServerIdentifier = + $payload['external_server_identifier'] ?? null; + $externalServerExternalId = + $payload['external_server_external_id'] ?? null; + $userExternalId = $payload['user_external_id'] ?? null; + $userEmail = $payload['user_email'] ?? null; + $reservationStatus = (string) ( + $reservation->status ?? '' + ); + + try { + $targetRecurring = StrictDecimal::parseNonNegative( + $targetSnapshot['recurring_price'] ?? null + ); + $targetRecurringAmount = $this->normalizedMoney( + $targetSnapshot['recurring_price'] ?? null + ); + $quotedAmount = $this->normalizedMoney( + $upgrade->quoted_amount + ); + $reservedAmount = $this->normalizedMoney( + $reservation->calculated_price ?? null + ); + $expectedFingerprint = $this->upgradeFingerprint( + $upgrade, + $payload + ); + $pricingVersion = $this->upgradePricingVersion($upgrade); + $invoiceMatches = $this->upgradeInvoiceMatches( + $upgrade, + (string) $reservation->status, + $quotedAmount, + $serviceUserId, + $serviceCurrency + ); + $upgrade->load([ + 'service.product.settings', + 'service.configs.configOption', + 'service.configs.configValue', + 'product.settings', + 'configs.configOption', + 'configs.configValue', + ]); + if ($reservationStatus === 'confirmed') { + if ($this->isLatestCompletedUpgrade($upgrade)) { + $liveTargetProperties = + $this->currentServiceProperties( + $upgrade->service + ); + $liveRecurringAmount = $this->normalizedMoney( + $upgrade->service->price + ); + } else { + // Historical completed upgrades are superseded by the + // next signed target. Only the latest target can equal + // the Service's current mutable state. + $liveTargetProperties = $targetProperties; + $liveRecurringAmount = $targetRecurringAmount; + } + } else { + $liveTargetProperties = $this->snapshotProperties([ + 'properties' => $upgrade->targetProperties(), + ]); + $liveRecurringAmount = $targetRecurringAmount; + } + $sourceStillMatches = ! in_array( + $reservationStatus, + ['pending', 'paid_committed'], + true + ) || $upgrade->sourceStillMatches(); + } catch (Throwable) { + return false; + } + + return $source !== null + && $target !== null + && $delta !== null + && $rowTarget !== null + && $rowDelta !== null + && $sourceSnapshot !== null + && $targetSnapshot !== null + && $sourceProperties !== null + && $targetProperties !== null + && $liveTargetProperties === $targetProperties + && $snapshotSource !== null + && $snapshotTarget !== null + && $snapshotLocation !== null + && $targetRecurring !== null + && $liveRecurringAmount === $targetRecurringAmount + && $sourceSnapshotHash !== null + && $targetSnapshotHash !== null + && is_string($sourceFingerprint) + && preg_match('/^[a-f0-9]{64}$/D', $sourceFingerprint) === 1 + && is_string($targetFingerprint) + && preg_match('/^[a-f0-9]{64}$/D', $targetFingerprint) === 1 + && hash_equals($sourceFingerprint, $sourceSnapshotHash) + && hash_equals($targetFingerprint, $targetSnapshotHash) + && $source === $snapshotSource + && $target === $snapshotTarget + && $source !== $target + && ! collect($target)->contains( + fn (int $value): bool => $value <= 0 + ) + && $target['disk'] >= $source['disk'] + && $this->onlyResourcesChanged( + $sourceProperties, + $targetProperties + ) + && $delta === [ + 'memory' => max(0, $target['memory'] - $source['memory']), + 'cpu' => max(0, $target['cpu'] - $source['cpu']), + 'disk' => max(0, $target['disk'] - $source['disk']), + ] + && ($reservation->purpose ?? null) === 'upgrade' + && $upgradeServiceId !== null + && $reservationServiceId === $upgradeServiceId + && $reservationUpgradeId === $upgradeId + && $payloadUpgradeId === $upgradeId + && $serviceUserId !== null + && $reservationUserId === $serviceUserId + && $serviceProductId !== null + && $upgradeProductId === $serviceProductId + && $reservationProductId === $serviceProductId + && $servicePlanId !== null + && $upgradePlanId === $servicePlanId + && $reservationPlanId === $servicePlanId + && $serviceQuantity === 1 + && $reservationQuantity === 1 + && $serviceCurrency !== '' + && strtoupper((string) $upgrade->currency_code) + === $serviceCurrency + && strtoupper((string) $reservation->currency_code) + === $serviceCurrency + && $this->snapshotIdentityMatches( + $sourceSnapshot, + $targetSnapshot, + $upgradeServiceId, + $serviceProductId, + $servicePlanId, + $serviceCurrency + ) + && ( + $upgrade->invoice_id === null + || $upgradeInvoiceId !== null + ) + && ( + ($reservation->invoice_id ?? null) === null + || $reservationInvoiceId !== null + ) + && $reservationInvoiceId === $upgradeInvoiceId + && ( + $this->moneyIsPositive($quotedAmount) + ? $upgradeInvoiceId !== null + : $upgradeInvoiceId === null + ) + && $invoiceMatches + && $productServerId !== null + && $reservationServerId === $productServerId + && $this->productPanelMatchesUpgrade( + $upgrade, + $reservation + ) + && ( + ($reservation->upgrade_guard_id ?? null) === null + || $reservationGuard !== null + ) + && ( + $upgrade->active_service_guard_id === null + || $upgradeGuard !== null + ) + && $quotedAmount === $reservedAmount + && (string) ($reservation->pricing_version ?? '') + === $pricingVersion + && (string) ($reservation->formula_version ?? '') + === 'dynamic-upgrade-v1' + && $this->upgradeLifecycleMatches( + (string) ($reservation->status ?? ''), + (string) $upgrade->status, + $reservationGuard, + $upgradeGuard, + $upgradeId, + $upgradeServiceId, + $reservation->consumed_at ?? null, + $upgrade->completed_at + ) + && $sourceStillMatches + && is_string($reservation->configuration_fingerprint) + && preg_match( + '/^[a-f0-9]{64}$/D', + $reservation->configuration_fingerprint + ) === 1 + && hash_equals( + $reservation->configuration_fingerprint, + $expectedFingerprint + ) + && (string) ($payload['source_fingerprint'] ?? '') + === $sourceFingerprint + && (string) ($payload['target_fingerprint'] ?? '') + === $targetFingerprint + && is_string($panelIdentity) + && preg_match('/^[a-f0-9]{64}$/D', $panelIdentity) === 1 + && is_string($reservation->panel_identity ?? null) + && hash_equals( + (string) $reservation->panel_identity, + $panelIdentity + ) + && $nodeId !== null + && $payloadNodeId === $nodeId + && $locationId !== null + && $payloadLocationId === $locationId + && $payloadLocationId === $snapshotLocation + && $externalServerId !== null + && $payloadExternalServerId === $externalServerId + && $externalUserId !== null + && $payloadExternalUserId === $externalUserId + && is_string($externalServerUuid) + && Str::isUuid($externalServerUuid) + && is_string($reservation->external_server_uuid ?? null) + && hash_equals( + (string) $reservation->external_server_uuid, + $externalServerUuid + ) + && is_string($externalServerIdentifier) + && trim($externalServerIdentifier) !== '' + && is_string( + $reservation->external_server_identifier ?? null + ) + && hash_equals( + (string) $reservation->external_server_identifier, + $externalServerIdentifier + ) + && is_string($externalServerExternalId) + && hash_equals( + (string) $upgradeServiceId, + $externalServerExternalId + ) + && is_string($userExternalId) + && hash_equals( + "paymenter-user-{$serviceUserId}", + $userExternalId + ) + && is_string($userEmail) + && trim($userEmail) !== '' + && $nestId !== null + && $eggId !== null + && $allocationId !== null + && $assignedAllocationIds !== null + && in_array($allocationId, $assignedAllocationIds, true) + && $preservedBuild !== null + && $target === $rowTarget + && $delta === $rowDelta + && $this->checkoutIdentityMatchesUpgrade( + $reservation, + $payload, + $upgradeServiceId + ); + } + + private function isLatestCompletedUpgrade( + ServiceUpgrade $upgrade + ): bool { + return (string) $upgrade->status === ServiceUpgrade::STATUS_COMPLETED + && ! ServiceUpgrade::query() + ->where('service_id', $upgrade->service_id) + ->where('status', ServiceUpgrade::STATUS_COMPLETED) + ->where('id', '>', $upgrade->id) + ->exists(); + } + + /** + * @return array|null + */ + private function decodedArray(mixed $value): ?array + { + if (is_array($value)) { + return $value; + } + if (! is_string($value) || trim($value) === '') { + return null; + } + + try { + $decoded = json_decode( + $value, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (JsonException) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + private function snapshotFingerprint(?array $snapshot): ?string + { + if ($snapshot === null) { + return null; + } + + try { + return hash('sha256', json_encode( + $this->canonicalizeUpgradeSnapshot($snapshot), + JSON_THROW_ON_ERROR + | JSON_PRESERVE_ZERO_FRACTION + | JSON_UNESCAPED_SLASHES + )); + } catch (JsonException) { + return null; + } + } + + /** + * ServiceUpgrade deliberately preserves numeric representation while + * recursively sorting associative maps. Checkout fingerprints use the + * separate canonicalizer above, which normalizes integral JSON floats. + * + * @param array $value + * @return array + */ + private function canonicalizeUpgradeSnapshot(array $value): array + { + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = $this->canonicalizeUpgradeSnapshot($item); + } + } + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } + + /** + * @param array|null $snapshot + * @return array|null + */ + private function snapshotProperties(?array $snapshot): ?array + { + $properties = $snapshot['properties'] ?? null; + if (! is_array($properties) || array_is_list($properties)) { + return null; + } + + $normalized = []; + foreach ($properties as $key => $value) { + $key = strtolower((string) $key); + if ($key === '' || array_key_exists($key, $normalized)) { + return null; + } + $normalized[$key] = $value; + } + ksort($normalized); + + return $normalized; + } + + /** + * @param array|null $properties + * @return array{memory: int, cpu: int, disk: int}|null + */ + private function resourceVectorFromProperties( + ?array $properties + ): ?array { + if ($properties === null) { + return null; + } + + $vector = []; + foreach (['memory', 'cpu', 'disk'] as $resource) { + $parsed = StrictInteger::parse( + $properties[$resource] ?? null + ) ?? StrictInteger::parseStoredDecimal( + $properties[$resource] ?? null + ); + if ($parsed === null || $parsed < 0) { + return null; + } + $vector[$resource] = $parsed; + } + + return $vector; + } + + /** + * @param array|null $properties + */ + private function locationFromProperties( + ?array $properties + ): ?int { + if ($properties === null) { + return null; + } + + $location = StrictInteger::parse( + $properties['location'] ?? null + ) ?? StrictInteger::parseStoredDecimal( + $properties['location'] ?? null + ); + if ($location !== null) { + return $location > 0 ? $location : null; + } + + $locationIds = $properties['location_ids'] ?? null; + if (is_string($locationIds)) { + $decoded = json_decode($locationIds, true); + $locationIds = json_last_error() === JSON_ERROR_NONE + && is_array($decoded) + ? $decoded + : [$locationIds]; + } elseif (! is_array($locationIds)) { + $locationIds = [$locationIds]; + } + if (! array_is_list($locationIds) || count($locationIds) !== 1) { + return null; + } + + $location = StrictInteger::parse($locationIds[0]) + ?? StrictInteger::parseStoredDecimal($locationIds[0]); + + return $location !== null && $location > 0 + ? $location + : null; + } + + /** + * @param array $source + * @param array $target + */ + private function onlyResourcesChanged( + array $source, + array $target + ): bool { + $keys = array_unique(array_merge( + array_keys($source), + array_keys($target) + )); + foreach ($keys as $key) { + if (in_array($key, ['memory', 'cpu', 'disk'], true)) { + continue; + } + if ( + ! array_key_exists($key, $source) + || ! array_key_exists($key, $target) + || $source[$key] !== $target[$key] + ) { + return false; + } + } + + return true; + } + + /** + * @param array $source + * @param array $target + */ + private function snapshotIdentityMatches( + array $source, + array $target, + int $serviceId, + int $productId, + int $planId, + string $currencyCode + ): bool { + return StrictInteger::parse($source['service_id'] ?? null) + === $serviceId + && StrictInteger::parse($target['service_id'] ?? null) + === $serviceId + && StrictInteger::parse($source['product_id'] ?? null) + === $productId + && StrictInteger::parse($target['product_id'] ?? null) + === $productId + && StrictInteger::parse($source['plan_id'] ?? null) + === $planId + && StrictInteger::parse($target['plan_id'] ?? null) + === $planId + && StrictInteger::parse($source['quantity'] ?? null) === 1 + && StrictInteger::parse($target['quantity'] ?? null) === 1 + && strtoupper((string) ($source['currency_code'] ?? '')) + === $currencyCode + && strtoupper((string) ($target['currency_code'] ?? '')) + === $currencyCode + && array_key_exists('billing_anchor', $source) + && array_key_exists('billing_anchor', $target) + && $source['billing_anchor'] === $target['billing_anchor']; + } + + private function upgradeLifecycleMatches( + string $reservationStatus, + string $upgradeStatus, + ?int $reservationGuard, + ?int $upgradeGuard, + int $upgradeId, + int $serviceId, + mixed $consumedAt, + mixed $completedAt + ): bool { + if ($reservationStatus === 'pending') { + return in_array( + $upgradeStatus, + [ + ServiceUpgrade::STATUS_PENDING, + ServiceUpgrade::STATUS_AWAITING_PAYMENT, + ], + true + ) + && $reservationGuard === $upgradeId + && $upgradeGuard === $serviceId + && $consumedAt === null + && $completedAt === null; + } + if ($reservationStatus === 'paid_committed') { + return in_array( + $upgradeStatus, + [ + ServiceUpgrade::STATUS_PAID_COMMITTED, + ServiceUpgrade::STATUS_PROVISIONING, + ServiceUpgrade::STATUS_RETRYABLE_FAILED, + ServiceUpgrade::STATUS_NEEDS_ATTENTION, + ], + true + ) + && $reservationGuard === $upgradeId + && $upgradeGuard === $serviceId + && $consumedAt === null + && $completedAt === null; + } + + return $reservationStatus === 'confirmed' + && $upgradeStatus === ServiceUpgrade::STATUS_COMPLETED + && $reservationGuard === null + && $upgradeGuard === null + && $consumedAt !== null + && $completedAt !== null; + } + + private function upgradeFingerprint( + ServiceUpgrade $upgrade, + array $payload + ): string { + return hash('sha256', json_encode( + $this->canonicalizeUpgradeSnapshot([ + 'upgrade_id' => (int) $upgrade->id, + 'source_fingerprint' => (string) $upgrade->source_fingerprint, + 'target_fingerprint' => (string) $upgrade->target_fingerprint, + 'panel_identity' => $payload['panel_identity'], + 'node_id' => $payload['node_id'], + 'location_id' => $payload['location_id'], + 'external_server_id' => $payload['external_server_id'], + 'external_server_uuid' => $payload['external_server_uuid'], + 'external_server_identifier' => $payload['external_server_identifier'], + 'external_server_external_id' => $payload['external_server_external_id'], + 'external_user_id' => $payload['external_user_id'], + 'user_external_id' => $payload['user_external_id'], + 'user_email' => $payload['user_email'], + 'nest_id' => $payload['nest_id'], + 'egg_id' => $payload['egg_id'], + 'preserved_build' => $payload['preserved_build'], + 'allocation_id' => $payload['allocation_id'], + 'assigned_allocation_ids' => $payload['assigned_allocation_ids'], + 'source' => $payload['source'], + 'target' => $payload['target'], + 'delta' => $payload['delta'], + 'quoted_amount' => $this->normalizedMoney( + $upgrade->quoted_amount + ), + 'currency_code' => strtoupper((string) $upgrade->currency_code), + ]), + JSON_THROW_ON_ERROR + | JSON_PRESERVE_ZERO_FRACTION + | JSON_UNESCAPED_SLASHES + )); + } + + private function upgradePricingVersion( + ServiceUpgrade $upgrade + ): string { + return hash('sha256', json_encode([ + 'quoted_amount' => $this->normalizedMoney( + $upgrade->quoted_amount + ), + 'currency_code' => strtoupper((string) $upgrade->currency_code), + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } + + private function upgradeInvoiceMatches( + ServiceUpgrade $upgrade, + string $reservationStatus, + string $quotedAmount, + ?int $serviceUserId, + string $serviceCurrency + ): bool { + if (! $this->moneyIsPositive($quotedAmount)) { + return $upgrade->invoice_id === null; + } + + $invoice = $upgrade->invoice; + if ( + $invoice === null + || $serviceUserId === null + || (int) $invoice->user_id !== $serviceUserId + || strtoupper((string) $invoice->currency_code) + !== $serviceCurrency + || ( + $reservationStatus === 'pending' + ? $invoice->status !== 'pending' + : $invoice->status !== 'paid' + ) + ) { + return false; + } + + $items = $invoice->items() + ->orderBy('id') + ->get(); + if ($items->count() !== 1) { + return false; + } + $item = $items->first(); + + try { + $lineAmount = $this->normalizedMoney($item->price); + } catch (Throwable) { + return false; + } + + return $item->reference_type === ServiceUpgrade::class + && (int) $item->reference_id === (int) $upgrade->id + && (int) $item->quantity === 1 + && $lineAmount === $quotedAmount; + } + + /** + * The upgrade is an in-place mutation of a server created by checkout. + * Re-prove the immutable server, panel, user, nest, and egg identity rather + * than trusting a self-consistent upgrade payload in isolation. + * + * @param array $payload + */ + private function checkoutIdentityMatchesUpgrade( + object $reservation, + array $payload, + int $serviceId + ): bool { + $checkout = DB::table('ptero_resource_reservations') + ->where('purpose', 'checkout') + ->where('service_id', $serviceId) + ->where('status', 'confirmed') + ->orderByDesc('id') + ->first(); + if ($checkout === null) { + return false; + } + + try { + $checkoutPayload = json_decode( + (string) $checkout->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (JsonException) { + return false; + } + if (! is_array($checkoutPayload)) { + return false; + } + $identity = $checkoutPayload['provisioning_identity'] ?? null; + if (! is_array($identity)) { + return false; + } + + $checkoutNest = $this->positiveInteger( + $identity['nest_id'] ?? null + ); + $checkoutEgg = $this->positiveInteger( + $identity['egg_id'] ?? null + ); + $checkoutUserExternalId = + $identity['user_external_id'] ?? null; + $checkoutUserEmail = $identity['user_email'] ?? null; + + return is_string($checkout->configuration_fingerprint) + && preg_match( + '/^[a-f0-9]{64}$/D', + $checkout->configuration_fingerprint + ) === 1 + && hash_equals( + $checkout->configuration_fingerprint, + $this->fingerprint($checkoutPayload) + ) + && (int) $checkout->service_id === $serviceId + && (int) $checkout->user_id === (int) $reservation->user_id + && (int) $checkout->product_id + === (int) $reservation->product_id + && (int) $checkout->plan_id === (int) $reservation->plan_id + && (int) $checkout->server_extension_id + === (int) $reservation->server_extension_id + && is_string($checkout->panel_identity) + && is_string($reservation->panel_identity ?? null) + && hash_equals( + $checkout->panel_identity, + (string) $reservation->panel_identity + ) + && (int) $checkout->external_server_id + === (int) $reservation->external_server_id + && (int) $checkout->external_user_id + === (int) $reservation->external_user_id + && is_string($checkout->external_server_uuid) + && is_string($reservation->external_server_uuid ?? null) + && hash_equals( + $checkout->external_server_uuid, + (string) $reservation->external_server_uuid + ) + && is_string($checkout->external_server_identifier) + && is_string( + $reservation->external_server_identifier ?? null + ) + && hash_equals( + $checkout->external_server_identifier, + (string) $reservation->external_server_identifier + ) + && $checkoutNest !== null + && $checkoutNest === $this->positiveInteger( + $payload['nest_id'] ?? null + ) + && $checkoutEgg !== null + && $checkoutEgg === $this->positiveInteger( + $payload['egg_id'] ?? null + ) + && is_string($checkoutUserExternalId) + && is_string($payload['user_external_id'] ?? null) + && hash_equals( + $checkoutUserExternalId, + $payload['user_external_id'] + ) + && is_string($checkoutUserEmail) + && is_string($payload['user_email'] ?? null) + && hash_equals( + strtolower(trim($checkoutUserEmail)), + strtolower(trim($payload['user_email'])) + ); + } + + private function productPanelMatchesUpgrade( + ServiceUpgrade $upgrade, + object $reservation + ): bool { + $server = $upgrade->product?->server; + if ( + $server === null + || $server->extension !== 'Pterodactyl' + || ! is_string($reservation->panel_identity ?? null) + ) { + return false; + } + + try { + $settings = ExtensionHelper::settingsToArray( + $server->settings + ); + $panelIdentity = PanelEndpointIdentity::hash( + trim((string) ($settings['host'] ?? '')) + ); + } catch (Throwable) { + return false; + } + + return hash_equals( + (string) $reservation->panel_identity, + $panelIdentity + ); + } + + /** + * @return array{memory: int, cpu: int, disk: int}|null + */ + private function resourceVector(mixed $value): ?array + { + if (! is_array($value)) { + return null; + } + + $resources = []; + foreach (['memory', 'cpu', 'disk'] as $resource) { + $parsed = StrictInteger::parse($value[$resource] ?? null); + if ($parsed === null || $parsed < 0) { + return null; + } + $resources[$resource] = $parsed; + } + + return $resources; + } + + private function positiveInteger(mixed $value): ?int + { + $parsed = StrictInteger::parse($value); + + return $parsed !== null && $parsed > 0 ? $parsed : null; + } + + /** + * @return list|null + */ + private function positiveIntegerList(mixed $value): ?array + { + if (! is_array($value) || ! array_is_list($value)) { + return null; + } + + $normalized = []; + foreach ($value as $item) { + $parsed = $this->positiveInteger($item); + if ($parsed === null) { + return null; + } + $normalized[] = $parsed; + } + sort($normalized, SORT_NUMERIC); + + return $normalized !== [] + && count(array_unique($normalized)) === count($normalized) + ? $normalized + : null; + } + + /** + * @return array{ + * swap: int, + * io: int, + * threads: string|null, + * databases: int, + * allocations: int, + * backups: int + * }|null + */ + private function preservedBuild(mixed $value): ?array + { + if (! is_array($value)) { + return null; + } + + $threads = $value['threads'] ?? null; + $swap = StrictInteger::parse($value['swap'] ?? null); + $io = StrictInteger::parse($value['io'] ?? null); + $databases = StrictInteger::parse( + $value['databases'] ?? null + ); + $allocations = StrictInteger::parse( + $value['allocations'] ?? null + ); + $backups = StrictInteger::parse($value['backups'] ?? null); + if ( + ! array_key_exists('threads', $value) + || ($threads !== null && ! is_string($threads)) + || $swap === null + || $swap < 0 + || $io === null + || $io < 0 + || $databases === null + || $databases < 0 + || $allocations !== 0 + || $backups === null + || $backups < 0 + ) { + return null; + } + + return [ + 'swap' => $swap, + 'io' => $io, + 'threads' => $threads, + 'databases' => $databases, + 'allocations' => $allocations, + 'backups' => $backups, + ]; + } + + /** + * @param list $missing + * @return array|null + */ + private function decodedPayload( + object $reservation, + array &$missing + ): ?array { + try { + $payload = json_decode( + (string) $reservation->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (JsonException) { + $payload = null; + } + if (! is_array($payload)) { + $missing[] = 'configuration_payload'; + + return null; + } + + return $payload; + } + + /** + * @return list + */ + private function missingCommonRemoteIdentity(object $reservation): array + { + $missing = []; + $this->requirePositive( + $reservation->external_server_id, + 'external_server_id', + $missing + ); + $this->requirePositive( + $reservation->external_user_id, + 'external_user_id', + $missing + ); + if ( + ! is_string($reservation->external_server_uuid) + || ! Str::isUuid($reservation->external_server_uuid) + ) { + $missing[] = 'external_server_uuid'; + } + if ( + ! is_string($reservation->external_server_identifier) + || trim($reservation->external_server_identifier) === '' + ) { + $missing[] = 'external_server_identifier'; + } + if ( + ! is_string($reservation->panel_identity) + || preg_match('/^[a-f0-9]{64}$/D', $reservation->panel_identity) + !== 1 + ) { + $missing[] = 'panel_identity'; + } + $this->requirePositive( + $reservation->node_id, + 'node_id', + $missing + ); + + return $missing; + } + + /** + * @param list $missing + */ + private function requirePositive( + mixed $value, + string $field, + array &$missing + ): void { + $parsed = StrictInteger::parse($value); + if ($parsed === null || $parsed <= 0) { + $missing[] = $field; + } + } + + private function normalizedMoney(mixed $value): string + { + if (is_int($value)) { + $text = (string) $value; + } elseif (is_float($value) && is_finite($value)) { + $text = number_format($value, 2, '.', ''); + } elseif (is_string($value)) { + $text = $value; + } else { + throw new UnexpectedValueException( + 'The upgrade quote amount is invalid.' + ); + } + + if ( + preg_match( + '/^(-?)(0|[1-9]\d*)(?:\.(\d+))?$/D', + $text, + $matches + ) !== 1 + ) { + throw new UnexpectedValueException( + 'The upgrade quote amount is invalid.' + ); + } + + $fraction = $matches[3] ?? ''; + if ( + strlen($fraction) > 2 + && trim(substr($fraction, 2), '0') !== '' + ) { + throw new UnexpectedValueException( + 'The upgrade quote amount exceeds cent precision.' + ); + } + + $fraction = str_pad(substr($fraction, 0, 2), 2, '0'); + $sign = ($matches[1] ?? '') === '-' + && ($matches[2] !== '0' || $fraction !== '00') + ? '-' + : ''; + + return $sign.$matches[2].'.'.$fraction; + } + + private function moneyIsPositive(string $amount): bool + { + return $amount[0] !== '-' + && $amount !== '0.00'; + } +}; diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index 292d2bb..28f8937 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -33,8 +33,14 @@ - 30-Day Revenue - ${{ number_format($stats['confirmed_revenue'] ?? 0, 2) }} + 30-Day Confirmed Revenue + + @forelse(($stats['confirmed_revenue_by_currency'] ?? []) as $currency => $amount) + {{ $currency }} {{ $amount }} + @empty + — + @endforelse + diff --git a/routes/api.php b/routes/api.php index 5316d1d..18391ba 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,26 +7,31 @@ */ use Illuminate\Support\Facades\Route; -use Paymenter\Extensions\Others\DynamicPterodactyl\Http\Controllers\Api\AvailabilityController; -use Paymenter\Extensions\Others\DynamicPterodactyl\Http\Controllers\Api\PricingController; -use Paymenter\Extensions\Others\DynamicPterodactyl\Http\Controllers\Api\ReservationController; use Paymenter\Extensions\Others\DynamicPterodactyl\Http\Controllers\Api\Admin\AdminCapacityController; use Paymenter\Extensions\Others\DynamicPterodactyl\Http\Controllers\Api\Admin\AdminReservationController; +use Paymenter\Extensions\Others\DynamicPterodactyl\Http\Controllers\Api\AvailabilityController; +use Paymenter\Extensions\Others\DynamicPterodactyl\Http\Controllers\Api\ResourceQuoteController; +use Paymenter\Extensions\Others\DynamicPterodactyl\Http\Controllers\Api\UpgradeQuoteController; use Paymenter\Extensions\Others\DynamicPterodactyl\Http\Middleware\EnsureUserIsAdmin; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\QuoteRateLimiterService; -// Availability and pricing — throttled (30 req/min) to protect Pterodactyl API budget -Route::prefix('api/dynamic-pterodactyl')->middleware(['web', 'auth', 'throttle:30,1'])->group(function () { - Route::get('/availability/{locationId}', [AvailabilityController::class, 'getByLocation']); - Route::post('/pricing/calculate', [PricingController::class, 'calculate']); - Route::get('/pricing/config/{productId}', [PricingController::class, 'getConfig']); +// Customer stock quote — guest-safe, CSRF-protected, and intentionally does +// not expose node identity or other infrastructure detail. +Route::prefix('api/dynamic-pterodactyl')->middleware([ + 'web', + 'throttle:'.QuoteRateLimiterService::NAME, +])->group(function () { + Route::post('/products/{product}/resource-quote', ResourceQuoteController::class) + ->whereNumber('product'); }); -// Reservation endpoints — throttled (10 req/min) for checkout-retry burst tolerance without enabling abuse -Route::prefix('api/dynamic-pterodactyl')->middleware(['web', 'checkout', 'throttle:10,1'])->group(function () { - Route::post('/reservation', [ReservationController::class, 'create']); - Route::get('/reservation/{token}', [ReservationController::class, 'get']); - Route::delete('/reservation/{token}', [ReservationController::class, 'cancel']); - Route::post('/reservation/{token}/extend', [ReservationController::class, 'extend']); +// Existing-service stock quotes are authenticated and customer-owned. +Route::prefix('api/dynamic-pterodactyl')->middleware([ + 'web', + 'auth', + 'throttle:'.QuoteRateLimiterService::NAME, +])->group(function () { + Route::post('/services/{service}/upgrade-quote', UpgradeQuoteController::class); }); // Admin routes — session-based, gated by non-null role (matches User::canAccessPanel) diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 14cf2ee..ce1d03b 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -58,4 +58,4 @@ Largest files are `Unit/ReservationServiceTest.php` and `Unit/AlertServiceTest.p - Do not loosen the `paymenter_test` / `:memory:` bootstrap guard. - Do not add live Pterodactyl network calls; fake panel HTTP explicitly and prevent strays when testing capacity reads. - Do not expose or assert full reservation tokens in audit payloads; assert `token_prefix` behavior. -- Current direct coverage gaps to watch: `CartItemCreatedListener`, `ServiceCreatedListener`, `PricingController::getConfig()`, and `AuditLogService` retrieval methods. +- Current direct coverage gaps to watch: full cart-listener database integration and `AuditLogService` retrieval methods. diff --git a/tests/Feature/AdminApiTest.php b/tests/Feature/AdminApiTest.php index 86bb02c..4a3d5b7 100644 --- a/tests/Feature/AdminApiTest.php +++ b/tests/Feature/AdminApiTest.php @@ -59,6 +59,7 @@ public function test_non_admin_user_gets_403(): void { $user = $this->makeRegularUser(); $response = $this->actingAs($user) + ->withSession($this->loginUser($user)) ->getJson('/api/dynamic-pterodactyl/admin/reservations'); $response->assertStatus(403); @@ -73,6 +74,7 @@ public function test_admin_lists_reservations_paginated(): void $this->makeReservation(); $response = $this->actingAs($admin) + ->withSession($this->loginUser($admin)) ->getJson('/api/dynamic-pterodactyl/admin/reservations'); $response->assertStatus(200); @@ -89,6 +91,7 @@ public function test_admin_filters_by_status(): void $this->makeReservation(['status' => 'cancelled']); $response = $this->actingAs($admin) + ->withSession($this->loginUser($admin)) ->getJson('/api/dynamic-pterodactyl/admin/reservations?status=pending'); $response->assertStatus(200); @@ -98,15 +101,37 @@ public function test_admin_filters_by_status(): void } } + public function test_admin_can_filter_durable_paid_commitments(): void + { + $admin = $this->makeAdminUser(); + $this->makeReservation(['status' => 'paid_committed']); + $this->makeReservation(['status' => 'pending']); + + $response = $this->actingAs($admin) + ->withSession($this->loginUser($admin)) + ->getJson( + '/api/dynamic-pterodactyl/admin/reservations?status=paid_committed' + ); + + $response->assertOk(); + $this->assertCount(1, $response->json('data.data')); + $this->assertSame( + 'paid_committed', + $response->json('data.data.0.status') + ); + } + public function test_admin_cancels_reservation_with_reason(): void { $admin = $this->makeAdminUser(); $reservation = $this->makeReservation(['status' => 'pending']); - $response = $this->actingAs($admin)->postJson( - '/api/dynamic-pterodactyl/admin/reservations/'.$reservation->token.'/cancel', - ['reason' => 'Admin cancelled for testing'] - ); + $response = $this->actingAs($admin) + ->withSession($this->loginUser($admin)) + ->postJson( + '/api/dynamic-pterodactyl/admin/reservations/'.$reservation->token.'/cancel', + ['reason' => 'Admin cancelled for testing'] + ); $response->assertStatus(200); $response->assertJson(['success' => true, 'message' => 'Reservation cancelled']); @@ -121,10 +146,12 @@ public function test_admin_cancel_requires_reason(): void $admin = $this->makeAdminUser(); $reservation = $this->makeReservation(['status' => 'pending']); - $response = $this->actingAs($admin)->postJson( - '/api/dynamic-pterodactyl/admin/reservations/'.$reservation->token.'/cancel', - [] - ); + $response = $this->actingAs($admin) + ->withSession($this->loginUser($admin)) + ->postJson( + '/api/dynamic-pterodactyl/admin/reservations/'.$reservation->token.'/cancel', + [] + ); $response->assertStatus(422); } @@ -155,6 +182,7 @@ public function test_admin_capacity_endpoint_returns_structure(): void ]); $response = $this->actingAs($admin) + ->withSession($this->loginUser($admin)) ->getJson('/api/dynamic-pterodactyl/admin/capacity'); $response->assertStatus(200); @@ -169,6 +197,7 @@ public function test_customer_cannot_read_per_node_availability(): void { $user = $this->makeRegularUser(); $response = $this->actingAs($user) + ->withSession($this->loginUser($user)) ->getJson('/api/dynamic-pterodactyl/admin/availability/1/nodes'); $response->assertStatus(403); @@ -191,6 +220,7 @@ public function test_admin_can_read_per_node_availability(): void ]); $response = $this->actingAs($admin) + ->withSession($this->loginUser($admin)) ->getJson('/api/dynamic-pterodactyl/admin/availability/1/nodes'); $response->assertStatus(200); diff --git a/tests/Feature/AlertConfigResourceTest.php b/tests/Feature/AlertConfigResourceTest.php new file mode 100644 index 0000000..80c6760 --- /dev/null +++ b/tests/Feature/AlertConfigResourceTest.php @@ -0,0 +1,42 @@ +shouldReceive('getLocations') + ->once() + ->andReturn([ + ['id' => 7, 'long' => 'Sydney'], + ['id' => 42, 'long' => 'Melbourne'], + ]); + $this->app->instance(ResourceCalculationService::class, $resources); + + $method = new \ReflectionMethod( + AlertConfigResource::class, + 'getScopedLocationOptions' + ); + $method->setAccessible(true); + $options = $method->invoke(null); + + $this->assertSame([ + '' => 'Global (All Locations)', + 7 => 'Sydney', + 42 => 'Melbourne', + ], $options); + } +} diff --git a/tests/Feature/AlertScheduleTest.php b/tests/Feature/AlertScheduleTest.php index c85d98a..7e4b232 100644 --- a/tests/Feature/AlertScheduleTest.php +++ b/tests/Feature/AlertScheduleTest.php @@ -3,23 +3,37 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Tests\Feature; use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Support\Collection; +use Mockery; use Paymenter\Extensions\Others\DynamicPterodactyl\DynamicPterodactyl; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\SchedulerHealthService; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\LaravelTestCase; class AlertScheduleTest extends LaravelTestCase { private ?object $capacityAlertEvent = null; + /** @var Collection */ + private $events; + protected function setUp(): void { parent::setUp(); (new DynamicPterodactyl)->boot(); - $this->capacityAlertEvent = collect(app(Schedule::class)->events()) + $this->events = collect(app(Schedule::class)->events()); + $this->capacityAlertEvent = $this->events ->first(fn ($event) => $event->description === 'dynamic-pterodactyl:check-capacity-alerts'); } + protected function tearDown(): void + { + Mockery::close(); + + parent::tearDown(); + } + public function test_capacity_alert_schedule_is_registered(): void { $this->assertNotNull($this->capacityAlertEvent); @@ -31,4 +45,84 @@ public function test_capacity_alert_schedule_uses_without_overlapping(): void $this->assertNotNull($this->capacityAlertEvent); $this->assertTrue($this->capacityAlertEvent->withoutOverlapping); } + + public function test_lifecycle_task_types_have_independent_events(): void + { + $expected = [ + 'dynamic-pterodactyl:expire-checkout-reservations' => '* * * * *', + 'dynamic-pterodactyl:expire-upgrade-reservations' => '* * * * *', + 'dynamic-pterodactyl:reconcile-paid-checkout-commitments' => '*/10 * * * *', + 'dynamic-pterodactyl:reconcile-paid-upgrades' => '*/10 * * * *', + 'dynamic-pterodactyl:check-capacity-alerts' => '*/5 * * * *', + 'dynamic-pterodactyl:monitor-scheduler-health' => '*/5 * * * *', + ]; + + foreach ($expected as $description => $expression) { + $matching = $this->events->filter( + fn ($event) => $event->description === $description + ); + + $this->assertCount( + 1, + $matching, + "Expected one independent scheduler event [{$description}]." + ); + $event = $matching->first(); + $this->assertSame($expression, $event->expression); + $this->assertTrue($event->withoutOverlapping); + } + } + + public function test_failed_expiry_callback_does_not_share_upgrade_callback(): void + { + $health = Mockery::mock(SchedulerHealthService::class); + $health->shouldReceive('run') + ->once() + ->with( + SchedulerHealthService::TASK_EXPIRE_CHECKOUT, + Mockery::type(\Closure::class) + ) + ->andThrow(new \RuntimeException('checkout cleanup failed')); + $health->shouldReceive('run') + ->once() + ->with( + SchedulerHealthService::TASK_EXPIRE_UPGRADES, + Mockery::type(\Closure::class) + ) + ->andReturn(0); + $this->app->instance(SchedulerHealthService::class, $health); + + try { + ($this->callbackFor( + 'dynamic-pterodactyl:expire-checkout-reservations' + ))(); + $this->fail('Expected the checkout callback to fail.'); + } catch (\RuntimeException $exception) { + $this->assertSame( + 'checkout cleanup failed', + $exception->getMessage() + ); + } + + $this->assertSame( + 0, + ($this->callbackFor( + 'dynamic-pterodactyl:expire-upgrade-reservations' + ))() + ); + } + + private function callbackFor(string $description): \Closure + { + $event = $this->events->first( + fn ($candidate) => $candidate->description === $description + ); + $this->assertNotNull($event); + $property = new \ReflectionProperty($event, 'callback'); + $property->setAccessible(true); + $callback = $property->getValue($event); + $this->assertInstanceOf(\Closure::class, $callback); + + return $callback; + } } diff --git a/tests/Feature/AuditLogServiceTest.php b/tests/Feature/AuditLogServiceTest.php new file mode 100644 index 0000000..30509b5 --- /dev/null +++ b/tests/Feature/AuditLogServiceTest.php @@ -0,0 +1,100 @@ +log( + 'scheduler_reconciled', + 'scheduler', + 1 + ); + + $this->assertDatabaseHas('ptero_audit_logs', [ + 'id' => $auditId, + 'user_id' => null, + 'user_name' => 'System', + 'user_email' => 'system@localhost', + ]); + } + + public function test_deleting_an_operator_retains_and_anonymizes_audit_history(): void + { + $operator = User::factory()->create(); + Auth::login($operator); + $auditId = app(AuditLogService::class)->log( + 'configuration_updated', + 'product_config', + 42 + ); + Auth::logout(); + + $operator->delete(); + + $this->assertDatabaseMissing('users', ['id' => $operator->id]); + $this->assertDatabaseHas('ptero_audit_logs', [ + 'id' => $auditId, + 'user_id' => null, + 'user_name' => $operator->name, + 'user_email' => $operator->email, + ]); + } + + public function test_audit_user_foreign_key_sets_null_instead_of_cascading(): void + { + $rule = DB::getDriverName() === 'sqlite' + ? collect(DB::select( + "PRAGMA foreign_key_list('ptero_audit_logs')" + )) + ->first( + fn (object $foreignKey): bool => (string) $foreignKey->from === 'user_id' + ) + ?->on_delete + : DB::table( + 'information_schema.REFERENTIAL_CONSTRAINTS as constraints' + ) + ->join( + 'information_schema.KEY_COLUMN_USAGE as columns', + function ($join): void { + $join->on( + 'columns.CONSTRAINT_SCHEMA', + '=', + 'constraints.CONSTRAINT_SCHEMA' + )->on( + 'columns.CONSTRAINT_NAME', + '=', + 'constraints.CONSTRAINT_NAME' + )->on( + 'columns.TABLE_NAME', + '=', + 'constraints.TABLE_NAME' + ); + } + ) + ->where( + 'constraints.CONSTRAINT_SCHEMA', + DB::getDatabaseName() + ) + ->where( + 'constraints.TABLE_NAME', + 'ptero_audit_logs' + ) + ->where('columns.COLUMN_NAME', 'user_id') + ->value('constraints.DELETE_RULE'); + + $this->assertSame('SET NULL', strtoupper((string) $rule)); + } +} diff --git a/tests/Feature/AvailabilityApiTest.php b/tests/Feature/AvailabilityApiTest.php index b31d5f9..e95609f 100644 --- a/tests/Feature/AvailabilityApiTest.php +++ b/tests/Feature/AvailabilityApiTest.php @@ -4,9 +4,6 @@ use App\Models\User; use Illuminate\Foundation\Testing\DatabaseTransactions; -use Mockery; -use Paymenter\Extensions\Others\DynamicPterodactyl\Services\NodeSelectionService; -use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ResourceCalculationService; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\LaravelTestCase; class AvailabilityApiTest extends LaravelTestCase @@ -17,78 +14,33 @@ protected function setUp(): void { parent::setUp(); - require __DIR__ . '/../../routes/api.php'; + require __DIR__.'/../../routes/api.php'; } - public function test_has_capacity_false_when_cpu_exhausted_but_memory_positive(): void + public function test_legacy_independent_maximum_availability_route_is_removed(): void { /** @var User $user */ $user = User::factory()->create(); - $this->bindAvailabilityServices([ - 'memory' => 1000, - 'cpu' => 0, - 'disk' => 1000, - ]); - - $response = $this->actingAs($user)->getJson('/api/dynamic-pterodactyl/availability/1'); - - $response->assertOk()->assertJson([ - 'success' => true, - 'data' => [ - 'has_capacity' => false, - 'resource_capacity' => [ - 'memory' => true, - 'cpu' => false, - 'disk' => true, - ], - ], - ]); + $this->actingAs($user) + ->withSession($this->loginUser($user)) + ->getJson('/api/dynamic-pterodactyl/availability/1') + ->assertNotFound(); } - public function test_has_capacity_true_when_all_resources_positive(): void + public function test_legacy_extension_pricing_routes_are_removed(): void { /** @var User $user */ $user = User::factory()->create(); - $this->bindAvailabilityServices([ - 'memory' => 1000, - 'cpu' => 100, - 'disk' => 1000, - ]); - - $response = $this->actingAs($user)->getJson('/api/dynamic-pterodactyl/availability/1'); - - $response->assertOk()->assertJson([ - 'success' => true, - 'data' => [ - 'has_capacity' => true, - 'resource_capacity' => [ - 'memory' => true, - 'cpu' => true, - 'disk' => true, - ], - ], - ]); - } - - private function bindAvailabilityServices(array $maxAvailable): void - { - $nodeService = Mockery::mock(NodeSelectionService::class); - $nodeService->shouldReceive('getMaxAvailable') - ->once() - ->with(1) - ->andReturn($maxAvailable); - $this->app->instance(NodeSelectionService::class, $nodeService); + $this->actingAs($user) + ->withSession($this->loginUser($user)) + ->postJson('/api/dynamic-pterodactyl/pricing/calculate', []) + ->assertNotFound(); - $resourceService = Mockery::mock(ResourceCalculationService::class); - $resourceService->shouldReceive('getLocationAvailability') - ->once() - ->with(1) - ->andReturn([ - 'location_id' => 1, - 'nodes' => [['node_id' => 1, 'name' => 'Node 1']], - ]); - $this->app->instance(ResourceCalculationService::class, $resourceService); + $this->actingAs($user) + ->withSession($this->loginUser($user)) + ->getJson('/api/dynamic-pterodactyl/pricing/config/1') + ->assertNotFound(); } } diff --git a/tests/Feature/DurableReservationForeignKeyTest.php b/tests/Feature/DurableReservationForeignKeyTest.php new file mode 100644 index 0000000..eb9bbfb --- /dev/null +++ b/tests/Feature/DurableReservationForeignKeyTest.php @@ -0,0 +1,135 @@ +reservationDeleteRules(); + + $this->assertContains( + $deleteRules['service_id'] ?? null, + ['RESTRICT', 'NO ACTION'] + ); + $this->assertContains( + $deleteRules['user_id'] ?? null, + ['RESTRICT', 'NO ACTION'] + ); + $this->assertSame( + 'SET NULL', + $deleteRules['cart_item_id'] ?? null + ); + } + + public function test_sqlite_foreign_key_rebuild_preserves_partial_uniqueness_guards(): void + { + if (DB::getDriverName() !== 'sqlite') { + $this->markTestSkipped( + 'MariaDB uses generated-column uniqueness guards.' + ); + } + + $indexes = DB::table('sqlite_master') + ->where('type', 'index') + ->whereIn('name', [ + 'ptero_reservations_active_upgrade_unique', + 'ptero_reservations_active_checkout_service_unique', + ]) + ->pluck('sql', 'name') + ->map(fn (?string $sql): string => strtolower((string) $sql)); + + $this->assertStringContainsString( + "where purpose = 'upgrade'", + $indexes->get( + 'ptero_reservations_active_upgrade_unique', + '' + ) + ); + $this->assertStringContainsString( + "status in ('pending', 'paid_committed')", + $indexes->get( + 'ptero_reservations_active_upgrade_unique', + '' + ) + ); + $this->assertStringContainsString( + "where purpose = 'checkout'", + $indexes->get( + 'ptero_reservations_active_checkout_service_unique', + '' + ) + ); + $this->assertStringContainsString( + "status in ('pending', 'paid_committed', 'confirmed')", + $indexes->get( + 'ptero_reservations_active_checkout_service_unique', + '' + ) + ); + } + + /** + * @return array + */ + private function reservationDeleteRules(): array + { + if (DB::getDriverName() === 'sqlite') { + return collect(DB::select( + "PRAGMA foreign_key_list('ptero_resource_reservations')" + ))->mapWithKeys( + fn (object $foreignKey): array => [ + (string) $foreignKey->from => strtoupper((string) $foreignKey->on_delete), + ] + )->all(); + } + + return DB::table( + 'information_schema.REFERENTIAL_CONSTRAINTS as constraints' + ) + ->join( + 'information_schema.KEY_COLUMN_USAGE as columns', + function ($join): void { + $join->on( + 'columns.CONSTRAINT_SCHEMA', + '=', + 'constraints.CONSTRAINT_SCHEMA' + )->on( + 'columns.CONSTRAINT_NAME', + '=', + 'constraints.CONSTRAINT_NAME' + )->on( + 'columns.TABLE_NAME', + '=', + 'constraints.TABLE_NAME' + ); + } + ) + ->where( + 'constraints.CONSTRAINT_SCHEMA', + DB::getDatabaseName() + ) + ->where( + 'constraints.TABLE_NAME', + 'ptero_resource_reservations' + ) + ->whereIn('columns.COLUMN_NAME', [ + 'cart_item_id', + 'service_id', + 'user_id', + ]) + ->get([ + 'columns.COLUMN_NAME as column_name', + 'constraints.DELETE_RULE as delete_rule', + ]) + ->mapWithKeys( + fn (object $foreignKey): array => [ + (string) $foreignKey->column_name => strtoupper((string) $foreignKey->delete_rule), + ] + ) + ->all(); + } +} diff --git a/tests/Feature/LegacyReservationReadinessTest.php b/tests/Feature/LegacyReservationReadinessTest.php new file mode 100644 index 0000000..d98106c --- /dev/null +++ b/tests/Feature/LegacyReservationReadinessTest.php @@ -0,0 +1,1798 @@ +assertReady(); + + $this->addToAssertionCount(1); + } + + public function test_confirmed_legacy_checkout_reports_actionable_identity_fields(): void + { + $service = $this->service(); + $reservationId = $this->insertReservation($service, [ + 'status' => 'confirmed', + 'configuration_fingerprint' => null, + 'configuration_payload' => null, + ]); + + try { + (new LegacyReservationReadinessService)->assertReady(); + $this->fail('Expected legacy confirmed identity to block migration.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + "reservation #{$reservationId} / service #{$service->id}", + $exception->getMessage() + ); + $this->assertStringContainsString( + 'external_server_uuid', + $exception->getMessage() + ); + $this->assertStringContainsString( + 'configuration_payload', + $exception->getMessage() + ); + $this->assertStringContainsString( + 'never infer authority', + $exception->getMessage() + ); + } + } + + public function test_active_upgrade_without_immutable_identity_blocks_migration(): void + { + $service = $this->service(); + $reservationId = $this->insertReservation($service, [ + 'purpose' => 'upgrade', + 'status' => 'paid_committed', + 'configuration_fingerprint' => str_repeat('a', 64), + 'configuration_payload' => json_encode([], JSON_THROW_ON_ERROR), + ]); + + $blockers = (new LegacyReservationReadinessService)->blockers(); + + $this->assertSame($reservationId, $blockers[0]['reservation_id']); + $this->assertSame('upgrade', $blockers[0]['purpose']); + $this->assertContains( + 'configuration_payload.external_server_id', + $blockers[0]['missing'] + ); + } + + public function test_complete_signed_upgrade_identity_passes_gate_across_decimal_materialization(): void + { + $this->insertCompleteUpgradeReservation(); + + (new LegacyReservationReadinessService)->assertReady(); + + $this->addToAssertionCount(1); + } + + public function test_upgrade_can_preserve_a_legitimate_new_node_and_allocation_identity(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $reservation = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->first(); + $this->assertNotNull($reservation); + $upgrade = ServiceUpgrade::query() + ->findOrFail((int) $reservation->service_upgrade_id); + $payload = json_decode( + (string) $reservation->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + $payload['node_id'] = 8; + $payload['allocation_id'] = 8001; + $payload['assigned_allocation_ids'] = [8001, 8002]; + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'node_id' => 8, + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'configuration_fingerprint' => (new UpgradeReservationIntegrityService) + ->fingerprint($upgrade, $payload), + 'updated_at' => now(), + ]); + + (new LegacyReservationReadinessService)->assertReady(); + + $this->addToAssertionCount(1); + } + + public function test_upgrade_resource_row_drift_blocks_migration(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update(['reserved_memory' => 0]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'valid configuration_fingerprint', + $blocker['missing'] + ); + } + + public function test_upgrade_price_version_drift_blocks_migration(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'pricing_version' => str_repeat('b', 64), + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'valid configuration_fingerprint', + $blocker['missing'] + ); + } + + public function test_upgrade_live_target_drift_blocks_migration(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $upgradeId = $this->upgradeIdForReservation($reservationId); + $configId = DB::table('service_configs') + ->where('configurable_type', ServiceUpgrade::class) + ->where('configurable_id', $upgradeId) + ->orderBy('id') + ->value('id'); + $this->assertNotNull($configId); + DB::table('service_configs') + ->where('id', $configId) + ->update(['slider_value' => 16384]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'valid configuration_fingerprint', + $blocker['missing'] + ); + } + + public function test_upgrade_source_billing_anchor_drift_blocks_migration(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $serviceId = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('service_id'); + $this->assertNotNull($serviceId); + DB::table('services') + ->where('id', $serviceId) + ->update([ + 'expires_at' => now()->addMonths(2), + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'valid configuration_fingerprint', + $blocker['missing'] + ); + } + + public function test_active_dynamic_upgrade_missing_reservation_blocks_migration(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $upgradeId = $this->upgradeIdForReservation($reservationId); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->delete(); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->first( + fn (array $row): bool => $row['purpose'] === 'upgrade' + && in_array( + "dynamic service upgrade #{$upgradeId} " + .'requires exactly one coherent capacity reservation', + $row['missing'], + true + ) + ); + + $this->assertNotNull($blocker); + $this->assertSame(0, $blocker['reservation_id']); + } + + public function test_upgrade_lifecycle_status_drift_blocks_migration(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $upgradeId = $this->upgradeIdForReservation($reservationId); + DB::table('service_upgrades') + ->where('id', $upgradeId) + ->update([ + 'status' => ServiceUpgrade::STATUS_PAID_COMMITTED, + 'updated_at' => now(), + ]); + + $blockers = collect( + (new LegacyReservationReadinessService)->blockers() + ); + $this->assertTrue( + $blockers->contains( + fn (array $row): bool => $row['purpose'] === 'upgrade' + && in_array( + "dynamic service upgrade #{$upgradeId} " + .'requires exactly one coherent capacity reservation', + $row['missing'], + true + ) + ) + ); + } + + public function test_upgrade_snapshot_loss_blocks_migration(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $upgradeId = $this->upgradeIdForReservation($reservationId); + DB::table('service_upgrades') + ->where('id', $upgradeId) + ->update([ + 'target_snapshot' => null, + 'target_fingerprint' => null, + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'valid configuration_fingerprint', + $blocker['missing'] + ); + } + + public function test_bound_unpaid_checkout_without_signed_snapshot_blocks_migration(): void + { + $service = $this->service(); + $reservationId = $this->insertReservation($service, [ + 'status' => 'pending', + 'configuration_fingerprint' => null, + 'configuration_payload' => null, + 'external_server_id' => null, + 'external_user_id' => null, + 'external_server_uuid' => null, + 'external_server_identifier' => null, + ]); + + $blockers = (new LegacyReservationReadinessService)->blockers(); + + $this->assertSame($reservationId, $blockers[0]['reservation_id']); + $this->assertContains( + 'configuration_payload', + $blockers[0]['missing'] + ); + $this->assertNotContains( + 'external_server_id', + $blockers[0]['missing'] + ); + } + + public function test_service_bound_commitment_retired_by_legacy_migration_still_blocks_readiness(): void + { + $service = $this->service(); + $reservationId = $this->insertReservation($service, [ + 'status' => 'cancelled', + 'configuration_fingerprint' => null, + 'configuration_payload' => null, + 'admin_notes' => 'Retired during migration to server-owned reservations.', + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'legacy bound commitment was retired before readiness', + $blocker['missing'] + ); + } + + public function test_duplicate_commitment_retired_by_old_durable_migration_still_blocks_readiness(): void + { + $fixture = $this->insertCompleteCheckoutReservation(); + $retiredId = $this->duplicateReservation( + $fixture['reservation_id'], + [ + 'status' => 'cancelled', + 'admin_notes' => 'Retired duplicate service commitment during ' + .'durable-fulfillment migration.', + ] + ); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $retiredId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'legacy bound commitment was retired before readiness', + $blocker['missing'] + ); + } + + public function test_checkout_commitment_migration_refuses_duplicate_paid_obligations_without_mutation(): void + { + $fixture = $this->insertCompleteCheckoutReservation( + status: 'paid_committed' + ); + $secondInvoice = Invoice::factory()->create([ + 'user_id' => $fixture['service']->user_id, + 'currency_code' => 'USD', + 'status' => Invoice::STATUS_PAID, + ]); + $duplicateId = $this->duplicateReservation( + $fixture['reservation_id'], + [ + 'service_guard_id' => null, + 'invoice_id' => $secondInvoice->id, + 'configuration_fingerprint' => str_repeat('b', 64), + ] + ); + $this->insertAllocation( + $duplicateId, + $fixture['panel'], + released: false, + allocationId: 7002 + ); + + try { + $this->invokeCheckoutCommitmentPreflight(); + $this->fail( + 'Expected duplicate paid checkout commitments to block ' + .'the migration.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'refused to retire or release any obligation', + $exception->getMessage() + ); + $this->assertStringContainsString( + "service {$fixture['service']->id}", + $exception->getMessage() + ); + $this->assertStringContainsString( + "#{$fixture['reservation_id']}", + $exception->getMessage() + ); + $this->assertStringContainsString( + "#{$duplicateId}", + $exception->getMessage() + ); + } + + $this->assertSame( + ['paid_committed', 'paid_committed'], + DB::table('ptero_resource_reservations') + ->whereIn('id', [ + $fixture['reservation_id'], + $duplicateId, + ]) + ->orderBy('id') + ->pluck('status') + ->all() + ); + $this->assertSame( + 0, + DB::table('ptero_reservation_allocations') + ->whereIn('reservation_id', [ + $fixture['reservation_id'], + $duplicateId, + ]) + ->whereNotNull('released_at') + ->count() + ); + } + + public function test_checkout_commitment_migration_preserves_upgrade_history(): void + { + $upgradeReservationId = + $this->insertCompleteUpgradeReservation(); + + $this->invokeCheckoutCommitmentPreflight(); + + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $upgradeReservationId, + 'purpose' => 'upgrade', + 'status' => 'pending', + ]); + } + + public function test_complete_signed_checkout_identity_passes_gate(): void + { + $this->insertCompleteCheckoutReservation(); + + (new LegacyReservationReadinessService)->assertReady(); + + $this->addToAssertionCount(1); + } + + public function test_paid_committed_checkout_matches_provisioning_service_lifecycle(): void + { + $this->insertCompleteCheckoutReservation( + status: 'paid_committed' + ); + + (new LegacyReservationReadinessService)->assertReady(); + + $this->addToAssertionCount(1); + } + + public function test_cancelled_service_preserves_its_confirmed_fulfillment_tombstone(): void + { + $fixture = $this->insertCompleteCheckoutReservation(); + DB::table('services') + ->where('id', $fixture['service']->id) + ->update([ + 'status' => Service::STATUS_CANCELLED, + 'product_stock_released_at' => now(), + 'updated_at' => now(), + ]); + DB::table('ptero_resource_reservations') + ->where('id', $fixture['reservation_id']) + ->update([ + 'cancellation_requested_at' => now(), + 'product_stock_released_at' => now(), + 'updated_at' => now(), + ]); + + (new LegacyReservationReadinessService)->assertReady(); + + $this->addToAssertionCount(1); + } + + public function test_fractional_resource_identity_cannot_pass_readiness_by_integer_truncation(): void + { + $service = $this->service(); + $panel = hash('sha256', 'https://panel.example.com'); + $payload = $this->completeCheckoutPayload( + $service, + $panel, + memory: 4096.5 + ); + $reservationId = $this->insertReservation($service, [ + 'status' => 'confirmed', + 'server_extension_id' => 41, + 'panel_identity' => $panel, + 'configuration_fingerprint' => (new ReservationConfigurationService) + ->fingerprint($payload), + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'external_server_id' => 71, + 'external_user_id' => 44, + 'external_server_uuid' => '2f4f28b0-0f36-4e6b-a2aa-a686c3466696', + 'external_server_identifier' => 'server-71', + ]); + $this->insertReleasedAllocation($reservationId, $panel); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'signed checkout/service identity agreement', + $blocker['missing'] + ); + } + + public function test_quantity_above_one_cannot_pass_legacy_readiness(): void + { + $service = $this->service(); + DB::table('services')->where('id', $service->id)->update([ + 'quantity' => 2, + ]); + $service->refresh(); + $panel = hash('sha256', 'https://panel.example.com'); + $payload = $this->completeCheckoutPayload( + $service, + $panel, + quantity: 2 + ); + $reservationId = $this->insertReservation($service, [ + 'status' => 'confirmed', + 'quantity' => 2, + 'server_extension_id' => 41, + 'panel_identity' => $panel, + 'configuration_fingerprint' => (new ReservationConfigurationService) + ->fingerprint($payload), + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'external_server_id' => 71, + 'external_user_id' => 44, + 'external_server_uuid' => '2f4f28b0-0f36-4e6b-a2aa-a686c3466696', + 'external_server_identifier' => 'server-71', + ]); + $this->insertReleasedAllocation($reservationId, $panel); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'signed checkout/service identity agreement', + $blocker['missing'] + ); + } + + public function test_bound_unpaid_checkout_requires_materialized_allocation_claims(): void + { + $fixture = $this->insertCompleteCheckoutReservation( + status: 'pending', + withAllocation: false + ); + $reservationId = $fixture['reservation_id']; + + $blockers = (new LegacyReservationReadinessService)->blockers(); + $blocker = collect($blockers)->firstWhere( + 'reservation_id', + $reservationId + ); + + $this->assertNotNull($blocker); + $this->assertContains( + 'active signed allocation claims', + $blocker['missing'] + ); + + $this->insertAllocation( + $reservationId, + $fixture['panel'], + released: false + ); + + (new LegacyReservationReadinessService)->assertReady(); + $this->addToAssertionCount(1); + } + + public function test_dynamic_service_missing_checkout_reservation_blocks_reverse_scan(): void + { + $fixture = $this->insertCompleteCheckoutReservation(); + DB::table('ptero_resource_reservations') + ->where('id', $fixture['reservation_id']) + ->delete(); + + $this->assertTrue( + collect( + (new LegacyReservationReadinessService)->blockers() + )->contains( + fn (array $row): bool => $row['purpose'] === 'checkout' + && $row['service_id'] === $fixture['service']->id + && in_array( + "dynamic service #{$fixture['service']->id} " + .'requires exactly one lifecycle-coherent ' + .'checkout capacity reservation', + $row['missing'], + true + ) + ) + ); + } + + public function test_duplicate_checkout_reservations_block_reverse_scan_even_without_second_guard(): void + { + $fixture = $this->insertCompleteCheckoutReservation(); + $duplicateId = $this->duplicateReservation( + $fixture['reservation_id'], + [ + 'service_guard_id' => null, + ] + ); + $this->insertAllocation( + $duplicateId, + $fixture['panel'], + released: true + ); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->first( + fn (array $row): bool => $row['purpose'] === 'checkout' + && $row['reservation_id'] === 0 + && $row['service_id'] === $fixture['service']->id + ); + + $this->assertNotNull($blocker); + $this->assertStringContainsString( + 'exactly one lifecycle-coherent checkout', + implode(', ', $blocker['missing']) + ); + } + + public function test_checkout_consumed_lifecycle_drift_blocks_readiness(): void + { + $fixture = $this->insertCompleteCheckoutReservation(); + DB::table('ptero_resource_reservations') + ->where('id', $fixture['reservation_id']) + ->update([ + 'consumed_at' => null, + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $fixture['reservation_id']); + + $this->assertNotNull($blocker); + $this->assertContains( + 'checkout service guard/lifecycle agreement', + $blocker['missing'] + ); + } + + public function test_checkout_service_guard_drift_blocks_readiness(): void + { + $fixture = $this->insertCompleteCheckoutReservation(); + DB::table('ptero_resource_reservations') + ->where('id', $fixture['reservation_id']) + ->update([ + 'service_guard_id' => null, + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $fixture['reservation_id']); + + $this->assertNotNull($blocker); + $this->assertContains( + 'checkout service guard/lifecycle agreement', + $blocker['missing'] + ); + } + + public function test_checkout_live_service_resource_drift_blocks_readiness(): void + { + $fixture = $this->insertCompleteCheckoutReservation(); + $memoryOption = DB::table('config_options') + ->where('env_variable', 'memory') + ->whereExists(function ($query) use ($fixture): void { + $query->selectRaw('1') + ->from('config_option_products') + ->whereColumn( + 'config_option_products.config_option_id', + 'config_options.id' + ) + ->where( + 'config_option_products.product_id', + $fixture['service']->product_id + ); + }) + ->value('id'); + $this->assertNotNull($memoryOption); + DB::table('service_configs') + ->where('configurable_type', Service::class) + ->where('configurable_id', $fixture['service']->id) + ->where('config_option_id', $memoryOption) + ->update([ + 'slider_value' => 8192, + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $fixture['reservation_id']); + + $this->assertNotNull($blocker); + $this->assertContains( + 'delivered service resource agreement', + $blocker['missing'] + ); + } + + public function test_checkout_price_version_drift_blocks_readiness(): void + { + $fixture = $this->insertCompleteCheckoutReservation(); + DB::table('ptero_resource_reservations') + ->where('id', $fixture['reservation_id']) + ->update([ + 'pricing_version' => str_repeat('b', 64), + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $fixture['reservation_id']); + + $this->assertNotNull($blocker); + $this->assertContains( + 'signed checkout pricing agreement', + $blocker['missing'] + ); + } + + public function test_checkout_invoice_line_drift_blocks_readiness(): void + { + $fixture = $this->insertCompleteCheckoutReservation(); + DB::table('invoice_items') + ->where('invoice_id', $fixture['invoice_id']) + ->where('reference_type', Service::class) + ->where('reference_id', $fixture['service']->id) + ->update([ + 'price' => '9.00', + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $fixture['reservation_id']); + + $this->assertNotNull($blocker); + $this->assertContains( + 'checkout invoice/service billing agreement', + $blocker['missing'] + ); + } + + public function test_completed_upgrade_missing_reservation_blocks_reverse_scan(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $this->completeUpgrade($reservationId, applyTarget: true); + $upgradeId = $this->upgradeIdForReservation($reservationId); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->delete(); + + $this->assertTrue( + collect( + (new LegacyReservationReadinessService)->blockers() + )->contains( + fn (array $row): bool => $row['purpose'] === 'upgrade' + && in_array( + "dynamic service upgrade #{$upgradeId} " + .'requires exactly one coherent capacity ' + .'reservation', + $row['missing'], + true + ) + ) + ); + } + + public function test_completed_upgrade_duplicate_reservations_block_reverse_scan(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $this->completeUpgrade($reservationId, applyTarget: true); + $upgradeId = $this->upgradeIdForReservation($reservationId); + $this->duplicateReservation($reservationId); + + $this->assertTrue( + collect( + (new LegacyReservationReadinessService)->blockers() + )->contains( + fn (array $row): bool => $row['purpose'] === 'upgrade' + && $row['reservation_id'] === 0 + && in_array( + "dynamic service upgrade #{$upgradeId} " + .'requires exactly one coherent capacity ' + .'reservation', + $row['missing'], + true + ) + ) + ); + } + + public function test_confirmed_upgrade_not_applied_to_service_blocks_readiness(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $this->completeUpgrade($reservationId, applyTarget: false); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'valid configuration_fingerprint', + $blocker['missing'] + ); + } + + public function test_confirmed_upgrade_recurring_price_drift_blocks_readiness(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $this->completeUpgrade($reservationId, applyTarget: true); + $serviceId = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('service_id'); + $this->assertNotNull($serviceId); + DB::table('services')->where('id', $serviceId)->update([ + 'price' => '1.00', + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'valid configuration_fingerprint', + $blocker['missing'] + ); + } + + public function test_completed_upgrade_with_applied_target_passes_readiness(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $this->completeUpgrade($reservationId, applyTarget: true); + + (new LegacyReservationReadinessService)->assertReady(); + + $this->addToAssertionCount(1); + } + + public function test_completed_upgrade_remains_ready_after_slider_policy_changes(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $this->completeUpgrade($reservationId, applyTarget: true); + $serviceId = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('service_id'); + $this->assertNotNull($serviceId); + $optionId = DB::table('service_configs') + ->join( + 'config_options', + 'config_options.id', + '=', + 'service_configs.config_option_id' + ) + ->where('service_configs.configurable_type', Service::class) + ->where('service_configs.configurable_id', $serviceId) + ->where('config_options.env_variable', 'memory') + ->value('config_options.id'); + $this->assertNotNull($optionId); + $option = ConfigOption::query()->findOrFail((int) $optionId); + $metadata = (array) $option->metadata; + $metadata['min'] = 9000; + $metadata['step'] = 2048; + $metadata['default'] = 9000; + $option->metadata = $metadata; + $option->save(); + + try { + $option->fresh()->normalizeDynamicSliderValue(8192); + $this->fail( + 'Expected the historical target to violate the new slider policy.' + ); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + + (new LegacyReservationReadinessService)->assertReady(); + + $this->addToAssertionCount(1); + } + + public function test_upgrade_source_and_target_billing_anchors_must_match(): void + { + $reservationId = $this->insertCompleteUpgradeReservation(); + $upgradeId = $this->upgradeIdForReservation($reservationId); + $upgrade = ServiceUpgrade::query()->findOrFail($upgradeId); + $target = (array) $upgrade->target_snapshot; + $target['billing_anchor']['stored_recurring_price'] = '999.00'; + $targetFingerprint = $this->upgradeSnapshotFingerprint($target); + DB::table('service_upgrades') + ->where('id', $upgradeId) + ->update([ + 'target_snapshot' => json_encode( + $target, + JSON_THROW_ON_ERROR + ), + 'target_fingerprint' => $targetFingerprint, + 'updated_at' => now(), + ]); + $upgrade->refresh(); + $reservation = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->first(); + $this->assertNotNull($reservation); + $payload = json_decode( + (string) $reservation->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + $payload['target_fingerprint'] = $targetFingerprint; + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'configuration_fingerprint' => (new UpgradeReservationIntegrityService) + ->fingerprint($upgrade, $payload), + 'updated_at' => now(), + ]); + + $blocker = collect( + (new LegacyReservationReadinessService)->blockers() + )->firstWhere('reservation_id', $reservationId); + + $this->assertNotNull($blocker); + $this->assertContains( + 'valid configuration_fingerprint', + $blocker['missing'] + ); + } + + private function service(): Service + { + $user = User::factory()->create(); + $product = Product::factory()->create(); + $plan = Plan::factory()->create([ + 'priceable_id' => $product->id, + 'priceable_type' => Product::class, + ]); + + return Service::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + 'plan_id' => $plan->id, + 'quantity' => 1, + 'currency_code' => 'USD', + ]); + } + + /** + * @return array{ + * reservation_id: int, + * service: Service, + * server: Server, + * panel: string, + * payload: array, + * invoice_id: int + * } + */ + private function insertCompleteCheckoutReservation( + string $status = 'confirmed', + bool $withAllocation = true, + ?Service $service = null, + ?Server $server = null + ): array { + if ($service === null || $server === null) { + $fixture = $this->dynamicServiceFixture( + match ($status) { + 'pending' => Service::STATUS_PENDING, + 'paid_committed' => Service::STATUS_PROVISIONING, + default => Service::STATUS_ACTIVE, + } + ); + $service = $fixture['service']; + $server = $fixture['server']; + $panel = $fixture['panel']; + } else { + $panel = hash('sha256', 'https://panel.example.com'); + } + + $service->load([ + 'user', + 'product.server', + 'configs.configOption', + ]); + $payload = $this->completeCheckoutPayload($service, $panel); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'currency_code' => 'USD', + 'status' => $status === 'pending' + ? Invoice::STATUS_PENDING + : Invoice::STATUS_PAID, + ]); + $invoice->items()->create([ + 'description' => 'Dynamic service', + 'price' => $payload['calculated_price'], + 'quantity' => 1, + 'reference_id' => $service->id, + 'reference_type' => Service::class, + ]); + $confirmed = $status === 'confirmed'; + $reservationId = $this->insertReservation($service, [ + 'purpose' => 'checkout', + 'status' => $status, + 'cart_id' => 99, + 'cart_item_guard_id' => 199, + 'service_guard_id' => $service->id, + 'server_extension_id' => $server->id, + 'panel_identity' => $panel, + 'configuration_fingerprint' => (new ReservationConfigurationService) + ->fingerprint($payload), + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'calculated_price' => $payload['calculated_price'], + 'pricing_version' => $payload['pricing_version'], + 'formula_version' => $payload['formula_version'], + 'invoice_id' => $invoice->id, + 'expires_at' => $confirmed + ? now()->subDay() + : now()->addDay(), + 'guaranteed_until' => $confirmed + ? now()->subDay() + : now()->addDay(), + 'consumed_at' => $confirmed ? now()->subDay() : null, + 'external_server_id' => $confirmed ? 71 : null, + 'external_user_id' => $confirmed ? 44 : null, + 'external_server_uuid' => $confirmed + ? '2f4f28b0-0f36-4e6b-a2aa-a686c3466696' + : null, + 'external_server_identifier' => $confirmed + ? 'server-71' + : null, + ]); + if ($withAllocation) { + $this->insertAllocation( + $reservationId, + $panel, + released: $confirmed + ); + } + + return [ + 'reservation_id' => $reservationId, + 'service' => $service, + 'server' => $server, + 'panel' => $panel, + 'payload' => $payload, + 'invoice_id' => (int) $invoice->id, + ]; + } + + /** + * @return array{service: Service, server: Server, panel: string} + */ + private function dynamicServiceFixture( + string $status = Service::STATUS_ACTIVE + ): array { + $panel = hash('sha256', 'https://panel.example.com'); + $server = Server::create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $server->settings()->create([ + 'key' => 'host', + 'value' => 'https://panel.example.com', + 'type' => 'string', + 'encrypted' => false, + ]); + $product = Product::factory()->create([ + 'server_id' => $server->id, + 'hidden' => false, + ]); + $plan = Plan::factory()->create([ + 'priceable_id' => $product->id, + 'priceable_type' => Product::class, + ]); + foreach ([ + 'location_ids' => [[3], 'array'], + 'nest_id' => [1, 'integer'], + 'egg_id' => [2, 'integer'], + ] as $key => [$value, $type]) { + $product->settings()->create([ + 'key' => $key, + 'value' => $value, + 'type' => $type, + 'encrypted' => false, + ]); + } + Price::factory()->create([ + 'plan_id' => $plan->id, + 'price' => 10, + 'setup_fee' => 0, + 'currency_code' => 'USD', + ]); + $resources = [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + ]; + $options = []; + foreach ($resources as $resource => $value) { + $options[$resource] = $this->dynamicOption( + $product, + $resource + ); + } + $service = CapacityServiceCreationCoordinator::run( + function () use ( + $product, + $plan, + $options, + $resources, + $status + ): Service { + $service = Service::factory()->create([ + 'user_id' => User::factory()->create()->id, + 'product_id' => $product->id, + 'plan_id' => $plan->id, + 'status' => $status, + 'price' => 10, + 'quantity' => 1, + 'currency_code' => 'USD', + 'expires_at' => now()->addMonth(), + ]); + foreach ($resources as $resource => $value) { + $service->configs()->create([ + 'config_option_id' => $options[$resource]->id, + 'config_value_id' => null, + 'slider_value' => $value, + ]); + } + + return $service; + } + ); + + return compact('service', 'server', 'panel'); + } + + /** + * @return array + */ + private function completeCheckoutPayload( + Service $service, + string $panel, + mixed $memory = 4096, + int $quantity = 1 + ): array { + $service->loadMissing([ + 'product.server', + 'configs.configOption', + ]); + $configOptions = $service->configs + ->sortBy('config_option_id') + ->map(function ($config) use ($memory): array { + $option = $config->configOption; + $resource = strtolower((string) ( + $option?->getMetadata('resource_type', '') + )); + $value = $resource === 'memory' + ? $memory + : $config->slider_value; + + return [ + 'id' => (int) $config->config_option_id, + 'type' => (string) ($option?->type ?? ''), + 'environment_key' => strtolower((string) ( + $option?->env_variable ?: $option?->name + )), + 'resource_type' => $resource ?: null, + 'value' => is_numeric($value) + ? (float) $value + : $value, + 'metadata' => (array) ($option?->metadata ?? []), + ]; + }) + ->values() + ->all(); + $calculatedPrice = number_format( + (float) $service->price, + 2, + '.', + '' + ); + $payload = [ + 'customer_id' => $service->user_id, + 'cart_id' => 99, + 'server_extension_id' => (int) ($service->product?->server_id ?? 41), + 'panel_identity' => $panel, + 'product_id' => $service->product_id, + 'plan_id' => $service->plan_id, + 'quantity' => $quantity, + 'currency_code' => 'USD', + 'resources' => [ + 'memory' => $memory, + 'cpu' => 200, + 'disk' => 20480, + ], + 'location_id' => 3, + 'node_id' => 7, + 'calculated_price' => $calculatedPrice, + 'formula_version' => 'dynamic-pterodactyl-v1', + 'config_options' => $configOptions, + 'allocation_requirements' => [ + 'required_count' => 1, + ], + 'provisioning_identity' => [ + 'nest_id' => 1, + 'egg_id' => 2, + 'user_external_id' => "paymenter-user-{$service->user_id}", + 'user_email' => (string) $service->user->email, + ], + 'allocations' => [[ + 'allocation_id' => 7001, + 'ip' => '192.0.2.10', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ]], + ]; + $payload['pricing_version'] = + (new ReservationConfigurationService)->fingerprint([ + 'product_id' => (int) $service->product_id, + 'plan_id' => (int) $service->plan_id, + 'currency_code' => 'USD', + 'calculated_price' => $calculatedPrice, + 'config_options' => $configOptions, + ]); + + return $payload; + } + + private function insertReleasedAllocation( + int $reservationId, + string $panel + ): void { + $this->insertAllocation($reservationId, $panel, released: true); + } + + private function insertAllocation( + int $reservationId, + string $panel, + bool $released, + int $allocationId = 7001 + ): void { + DB::table('ptero_reservation_allocations')->insert([ + 'reservation_id' => $reservationId, + 'panel_identity' => $panel, + 'node_id' => 7, + 'allocation_id' => $allocationId, + 'ip' => '192.0.2.10', + 'port' => $allocationId === 7001 ? 25565 : 25566, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + 'released_at' => $released ? now() : null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function insertCompleteUpgradeReservation(): int + { + Currency::query()->firstOrCreate( + ['code' => 'USD'], + [ + 'name' => 'US Dollar', + 'prefix' => '$', + 'suffix' => '', + 'format' => '1,000.00', + ] + ); + $panel = hash('sha256', 'https://panel.example.com'); + $server = Server::create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $server->settings()->create([ + 'key' => 'host', + 'value' => 'https://panel.example.com', + 'type' => 'string', + 'encrypted' => false, + ]); + $product = Product::factory()->create([ + 'server_id' => $server->id, + 'hidden' => false, + ]); + $plan = Plan::factory()->create([ + 'priceable_id' => $product->id, + 'priceable_type' => Product::class, + 'name' => 'Monthly', + 'billing_unit' => 'month', + 'billing_period' => 1, + 'type' => 'recurring', + ]); + foreach ([ + 'location_ids' => [[3], 'array'], + 'nest_id' => [1, 'integer'], + 'egg_id' => [2, 'integer'], + ] as $key => [$value, $type]) { + $product->settings()->create([ + 'key' => $key, + 'value' => $value, + 'type' => $type, + 'encrypted' => false, + ]); + } + Price::factory()->create([ + 'plan_id' => $plan->id, + 'price' => 10, + 'setup_fee' => 0, + 'currency_code' => 'USD', + ]); + $source = [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + ]; + $target = [ + 'memory' => 8192, + 'cpu' => 400, + 'disk' => 40960, + ]; + $delta = [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + ]; + $sourceRecurringPrice = number_format( + 10 + array_sum($source), + 2, + '.', + '' + ); + $pricingLedgerStartedAt = now()->startOfDay(); + $pricingPeriodEndsAt = $pricingLedgerStartedAt + ->copy() + ->addMonth(); + $options = []; + foreach ($source as $resource => $value) { + $options[$resource] = $this->dynamicOption( + $product, + $resource + ); + } + $service = CapacityServiceCreationCoordinator::run(function () use ( + $product, + $plan, + $options, + $source, + $sourceRecurringPrice, + $pricingLedgerStartedAt, + $pricingPeriodEndsAt + ): Service { + $service = Service::factory()->create([ + 'user_id' => User::factory()->create()->id, + 'product_id' => $product->id, + 'plan_id' => $plan->id, + 'status' => Service::STATUS_ACTIVE, + 'price' => $sourceRecurringPrice, + 'period_base_price' => $sourceRecurringPrice, + 'current_period_price' => $sourceRecurringPrice, + 'pricing_ledger_started_at' => $pricingLedgerStartedAt, + 'pricing_ledger_verified_at' => now(), + 'billing_cycles_completed' => 1, + 'quantity' => 1, + 'currency_code' => 'USD', + 'expires_at' => $pricingPeriodEndsAt, + ]); + foreach ($source as $resource => $value) { + $service->configs()->create([ + 'config_option_id' => $options[$resource]->id, + 'config_value_id' => null, + 'slider_value' => $value, + ]); + } + + return $service; + }); + + $this->insertCompleteCheckoutReservation( + service: $service, + server: $server + ); + + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'currency_code' => 'USD', + 'status' => Invoice::STATUS_PENDING, + ]); + $upgrade = ServiceUpgrade::create([ + 'service_id' => $service->id, + 'product_id' => $service->product_id, + 'plan_id' => $service->plan_id, + 'invoice_id' => null, + 'status' => 'awaiting_payment', + 'active_service_guard_id' => $service->id, + 'type' => 'config_options', + 'capacity_mode' => ServiceUpgrade::CAPACITY_MODE_DYNAMIC, + 'quoted_amount' => null, + 'currency_code' => 'USD', + 'credit_amount' => 0, + 'provisioning_attempts' => 0, + ]); + foreach ($target as $resource => $value) { + $upgrade->configs()->create([ + 'config_option_id' => $options[$resource]->id, + 'config_value_id' => null, + 'slider_value' => $value, + ]); + } + $upgrade->load([ + 'service.product.server.settings', + 'service.product.settings', + 'service.plan.prices', + 'service.configs.configOption', + 'service.configs.configValue', + 'product.server.settings', + 'product.settings', + 'plan.prices', + 'configs.configOption', + 'configs.configValue', + ]); + $upgrade->captureSnapshots(); + $targetSnapshot = (array) $upgrade->target_snapshot; + $quotedAmount = (string) ( + $targetSnapshot['upgrade_price'] ?? '' + ); + $creditAmount = (string) ( + $targetSnapshot['credit_amount'] ?? '' + ); + $upgrade->forceFill([ + 'quoted_amount' => $quotedAmount, + 'credit_amount' => $creditAmount, + ]); + ServiceUpgradeMutationCoordinator::save($upgrade); + $invoice->items()->create([ + 'description' => 'Resource upgrade', + 'price' => $quotedAmount, + 'quantity' => 1, + 'reference_id' => $upgrade->id, + 'reference_type' => ServiceUpgrade::class, + ]); + $upgrade->invoice_id = $invoice->id; + ServiceUpgradeMutationCoordinator::save($upgrade); + $sourceFingerprint = (string) $upgrade->source_fingerprint; + $targetFingerprint = (string) $upgrade->target_fingerprint; + $upgradeId = (int) $upgrade->id; + $payload = [ + 'service_upgrade_id' => $upgradeId, + 'source_fingerprint' => $sourceFingerprint, + 'target_fingerprint' => $targetFingerprint, + 'panel_identity' => $panel, + 'node_id' => 7, + 'location_id' => 3, + 'external_server_id' => 71, + 'external_server_uuid' => '2f4f28b0-0f36-4e6b-a2aa-a686c3466696', + 'external_server_identifier' => 'server-71', + 'external_server_external_id' => (string) $service->id, + 'external_user_id' => 44, + 'user_external_id' => "paymenter-user-{$service->user_id}", + 'user_email' => (string) $service->user->email, + 'nest_id' => 1, + 'egg_id' => 2, + 'preserved_build' => [ + 'swap' => 0, + 'io' => 500, + 'threads' => null, + 'databases' => 0, + 'allocations' => 0, + 'backups' => 0, + ], + 'allocation_id' => 7001, + 'assigned_allocation_ids' => [7001], + 'source' => $source, + 'target' => $target, + 'delta' => $delta, + ]; + + return $this->insertReservation($service, [ + 'purpose' => 'upgrade', + 'status' => 'pending', + 'service_guard_id' => null, + 'server_extension_id' => $server->id, + 'service_upgrade_id' => $upgradeId, + 'upgrade_guard_id' => $upgradeId, + 'panel_identity' => $panel, + 'configuration_fingerprint' => (new UpgradeReservationIntegrityService) + ->fingerprint($upgrade, $payload), + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'node_id' => 7, + 'location_id' => 3, + 'memory' => $target['memory'], + 'cpu' => $target['cpu'], + 'disk' => $target['disk'], + 'reserved_memory' => $delta['memory'], + 'reserved_cpu' => $delta['cpu'], + 'reserved_disk' => $delta['disk'], + 'external_server_id' => 71, + 'external_user_id' => 44, + 'external_server_uuid' => '2f4f28b0-0f36-4e6b-a2aa-a686c3466696', + 'external_server_identifier' => 'server-71', + 'calculated_price' => $quotedAmount, + 'pricing_version' => (new UpgradeReservationIntegrityService) + ->pricingVersion($upgrade), + 'formula_version' => 'dynamic-upgrade-v1', + 'invoice_id' => $invoice->id, + 'expires_at' => now()->addDay(), + 'guaranteed_until' => now()->addDay(), + 'consumed_at' => null, + ]); + } + + private function upgradeIdForReservation(int $reservationId): int + { + $upgradeId = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('service_upgrade_id'); + $this->assertNotNull($upgradeId); + + return (int) $upgradeId; + } + + private function completeUpgrade( + int $reservationId, + bool $applyTarget + ): void { + $reservation = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->first(); + $this->assertNotNull($reservation); + $upgrade = ServiceUpgrade::query() + ->findOrFail((int) $reservation->service_upgrade_id); + $target = (array) $upgrade->target_snapshot; + $targetProperties = (array) ($target['properties'] ?? []); + + if ($applyTarget) { + foreach (['memory', 'cpu', 'disk'] as $resource) { + $optionId = DB::table('config_options') + ->where('env_variable', $resource) + ->whereExists( + function ($query) use ($upgrade): void { + $query->selectRaw('1') + ->from('config_option_products') + ->whereColumn( + 'config_option_products.config_option_id', + 'config_options.id' + ) + ->where( + 'config_option_products.product_id', + $upgrade->product_id + ); + } + ) + ->value('id'); + $this->assertNotNull($optionId); + DB::table('service_configs') + ->where('configurable_type', Service::class) + ->where('configurable_id', $upgrade->service_id) + ->where('config_option_id', $optionId) + ->update([ + 'slider_value' => $targetProperties[$resource], + 'updated_at' => now(), + ]); + } + DB::table('services') + ->where('id', $upgrade->service_id) + ->update([ + 'price' => $target['recurring_price'], + 'current_period_price' => $target['recurring_price'], + 'updated_at' => now(), + ]); + } + + DB::table('invoices') + ->where('id', $upgrade->invoice_id) + ->update([ + 'status' => Invoice::STATUS_PAID, + 'updated_at' => now(), + ]); + DB::table('service_upgrades') + ->where('id', $upgrade->id) + ->update([ + 'status' => ServiceUpgrade::STATUS_COMPLETED, + 'active_service_guard_id' => null, + 'completed_at' => now(), + 'updated_at' => now(), + ]); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'status' => 'confirmed', + 'upgrade_guard_id' => null, + 'consumed_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * @param array $overrides + */ + private function duplicateReservation( + int $reservationId, + array $overrides = [] + ): int { + $row = (array) DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->first(); + $this->assertNotSame([], $row); + foreach ([ + 'id', + 'active_idempotency_key', + 'active_cart_item_id', + 'active_upgrade_id', + 'active_checkout_service_id', + ] as $generatedColumn) { + unset($row[$generatedColumn]); + } + $row['token'] = bin2hex(random_bytes(32)); + $row['created_at'] = now(); + $row['updated_at'] = now(); + + return DB::table('ptero_resource_reservations')->insertGetId( + array_merge($row, $overrides) + ); + } + + private function invokeCheckoutCommitmentPreflight(): void + { + $migration = require dirname(__DIR__, 2) + .'/database/migrations/' + .'2026_07_26_000030_enforce_one_checkout_commitment_per_service.php'; + $method = new \ReflectionMethod( + $migration, + 'assertNoDuplicateCheckoutCommitments' + ); + $method->setAccessible(true); + $method->invoke($migration); + } + + /** + * @param array $snapshot + */ + private function upgradeSnapshotFingerprint(array $snapshot): string + { + $canonicalize = function (array $value) use (&$canonicalize): array { + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = $canonicalize($item); + } + } + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + }; + + return hash('sha256', json_encode( + $canonicalize($snapshot), + JSON_THROW_ON_ERROR + | JSON_PRESERVE_ZERO_FRACTION + | JSON_UNESCAPED_SLASHES + )); + } + + private function dynamicOption( + Product $product, + string $resource + ): ConfigOption { + $option = ConfigOption::create([ + 'name' => ucfirst($resource), + 'env_variable' => $resource, + 'type' => 'dynamic_slider', + 'hidden' => false, + 'upgradable' => true, + 'metadata' => [ + 'resource_type' => $resource, + 'min' => 1, + 'max' => 100000, + 'step' => 1, + 'default' => 1, + 'display_divisor' => 1, + 'pricing' => [ + 'model' => 'linear', + 'rate_per_unit' => 1, + ], + ], + ]); + ConfigOptionProduct::create([ + 'product_id' => $product->id, + 'config_option_id' => $option->id, + ]); + + return $option; + } + + /** + * @param array $overrides + */ + private function insertReservation( + Service $service, + array $overrides + ): int { + return DB::table('ptero_resource_reservations')->insertGetId( + array_merge([ + 'token' => bin2hex(random_bytes(32)), + 'purpose' => 'checkout', + 'server_extension_id' => 41, + 'panel_identity' => hash('sha256', 'https://panel.example.com'), + 'service_id' => $service->id, + 'service_guard_id' => $service->id, + 'user_id' => $service->user_id, + 'product_id' => $service->product_id, + 'plan_id' => $service->plan_id, + 'quantity' => 1, + 'currency_code' => 'USD', + 'node_id' => 7, + 'location_id' => 3, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + 'reserved_memory' => 0, + 'reserved_cpu' => 0, + 'reserved_disk' => 0, + 'calculated_price' => '10.00', + 'pricing_breakdown' => json_encode( + [], + JSON_THROW_ON_ERROR + ), + 'status' => 'confirmed', + 'expires_at' => now()->subDay(), + 'guaranteed_until' => now()->subDay(), + 'consumed_at' => now()->subDay(), + 'external_server_id' => null, + 'external_user_id' => null, + 'external_server_uuid' => null, + 'external_server_identifier' => null, + 'created_at' => now()->subDay(), + 'updated_at' => now()->subDay(), + ], $overrides) + ); + } +} diff --git a/tests/Feature/ManagedNodeIsolationTest.php b/tests/Feature/ManagedNodeIsolationTest.php new file mode 100644 index 0000000..bc3d1a8 --- /dev/null +++ b/tests/Feature/ManagedNodeIsolationTest.php @@ -0,0 +1,143 @@ +provisioner = new Pterodactyl([ + 'host' => 'https://panel.example.com', + 'api_key' => 'secret', + ]); + DB::table('ptero_node_capacity_policies')->insert([ + 'panel_identity' => hash('sha256', 'https://panel.example.com'), + 'node_uuid' => '00000000-0000-0000-0000-000000000007', + 'node_id' => 7, + 'location_id' => 3, + 'cpu_capacity_percent' => 800, + 'cpu_overcommit_bps' => 10000, + 'enabled' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_static_create_rejects_a_managed_node(): void + { + $this->assertStaticCreateRejected( + ['node' => 7, 'location_ids' => [3]], + 'dedicated to reservation-backed dynamic products' + ); + } + + public function test_static_auto_deploy_rejects_any_scope_that_can_reach_a_managed_node(): void + { + $this->assertStaticCreateRejected( + ['node' => null, 'location_ids' => [3, 4]], + 'location with reservation-managed nodes' + ); + $this->assertStaticCreateRejected( + ['node' => null, 'location_ids' => []], + 'spans nodes dedicated to reservation-backed dynamic products' + ); + } + + public function test_static_create_preserves_unmanaged_node_and_location_paths(): void + { + $method = new \ReflectionMethod( + Pterodactyl::class, + 'assertStaticCreateAvoidsManagedNodes' + ); + $method->setAccessible(true); + + $method->invoke( + $this->provisioner, + ['node' => 8, 'location_ids' => [3]] + ); + $method->invoke( + $this->provisioner, + ['node' => null, 'location_ids' => [4]] + ); + + $this->addToAssertionCount(2); + } + + public function test_non_capacity_upgrade_rejects_managed_node_and_preserves_unmanaged_node(): void + { + $method = new \ReflectionMethod( + Pterodactyl::class, + 'assertStaticUpgradeAvoidsManagedNode' + ); + $method->setAccessible(true); + + try { + $method->invoke($this->provisioner, [ + 'attributes' => ['node' => 7], + ]); + $this->fail('Expected managed-node upgrade isolation to fail closed.'); + } catch (\ReflectionException $exception) { + throw $exception; + } catch (\Throwable $exception) { + $actual = $exception instanceof \ReflectionException + ? $exception + : ($exception->getPrevious() ?? $exception); + $this->assertInstanceOf( + PermanentProvisioningException::class, + $actual + ); + $this->assertStringContainsString( + 'capacity-aware upgrade', + $actual->getMessage() + ); + } + + $method->invoke($this->provisioner, [ + 'attributes' => ['node' => 8], + ]); + $this->addToAssertionCount(1); + } + + /** + * @param array $settings + */ + private function assertStaticCreateRejected( + array $settings, + string $message + ): void { + $method = new \ReflectionMethod( + Pterodactyl::class, + 'assertStaticCreateAvoidsManagedNodes' + ); + $method->setAccessible(true); + + try { + $method->invoke($this->provisioner, $settings); + $this->fail('Expected static managed-node isolation to fail closed.'); + } catch (\ReflectionException $exception) { + throw $exception; + } catch (\Throwable $exception) { + $actual = $exception->getPrevious() ?? $exception; + $this->assertInstanceOf( + PermanentProvisioningException::class, + $actual + ); + $this->assertStringContainsString( + $message, + $actual->getMessage() + ); + } + } +} diff --git a/tests/Feature/NormalizeDynamicOptionKeysMigrationTest.php b/tests/Feature/NormalizeDynamicOptionKeysMigrationTest.php new file mode 100644 index 0000000..c538b1b --- /dev/null +++ b/tests/Feature/NormalizeDynamicOptionKeysMigrationTest.php @@ -0,0 +1,327 @@ +dynamicService(['memory']); + $propertyId = $this->insertProperty( + $service, + 'MEMORY', + '4096.0000' + ); + + $this->runMigration(); + + $this->assertSame( + 'memory', + DB::table('config_options') + ->where('id', $options['memory']->id) + ->value('env_variable') + ); + $this->assertDatabaseHas('properties', [ + 'id' => $propertyId, + 'model_type' => Service::class, + 'model_id' => $service->id, + 'key' => 'memory', + 'value' => '4096.0000', + ]); + $this->assertSame( + 1, + DB::table('properties') + ->where('model_type', Service::class) + ->where('model_id', $service->id) + ->count() + ); + } + + public function test_equal_uppercase_and_lowercase_properties_keep_the_canonical_row(): void + { + $this->requireCaseSensitivePropertyKeys(); + [$service] = $this->dynamicService(['memory']); + $upperId = $this->insertProperty( + $service, + 'MEMORY', + '4096.0000' + ); + $lowerId = $this->insertProperty( + $service, + 'memory', + '4096' + ); + + $this->runMigration(); + + $this->assertDatabaseMissing('properties', ['id' => $upperId]); + $this->assertDatabaseHas('properties', [ + 'id' => $lowerId, + 'key' => 'memory', + 'value' => '4096', + ]); + } + + public function test_conflicting_duplicate_values_abort_before_mutation(): void + { + $this->requireCaseSensitivePropertyKeys(); + [$service, $options] = $this->dynamicService(['memory']); + $this->insertProperty($service, 'MEMORY', '4096'); + $this->insertProperty($service, 'memory', '8192'); + + try { + $this->runMigration(); + $this->fail( + 'Conflicting legacy resource properties must block migration.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'conflicting [memory] property values', + $exception->getMessage() + ); + } + + $this->assertSame( + 'MEMORY', + DB::table('config_options') + ->where('id', $options['memory']->id) + ->value('env_variable') + ); + $this->assertSame( + 2, + DB::table('properties') + ->where('model_type', Service::class) + ->where('model_id', $service->id) + ->count() + ); + } + + public function test_non_integer_resource_value_aborts_before_mutation(): void + { + [$service, $options] = $this->dynamicService(['memory']); + $propertyId = $this->insertProperty( + $service, + 'MEMORY', + '4096.5' + ); + + try { + $this->runMigration(); + $this->fail( + 'Fractional resource properties must block migration.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'non-integer [memory] property value', + $exception->getMessage() + ); + } + + $this->assertSame( + 'MEMORY', + DB::table('config_options') + ->where('id', $options['memory']->id) + ->value('env_variable') + ); + $this->assertDatabaseHas('properties', [ + 'id' => $propertyId, + 'key' => 'MEMORY', + 'value' => '4096.5', + ]); + } + + public function test_later_invalid_key_casing_blocks_every_planned_change(): void + { + [$service, $options] = $this->dynamicService([ + 'memory', + 'cpu', + ]); + $memoryId = $this->insertProperty( + $service, + 'MEMORY', + '4096' + ); + $cpuId = $this->insertProperty($service, 'Cpu', '200'); + + try { + $this->runMigration(); + $this->fail( + 'Unexpected resource-key casing must block migration.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'unsupported resource key casing [Cpu]', + $exception->getMessage() + ); + } + + foreach ($options as $resource => $option) { + $this->assertSame( + strtoupper($resource), + DB::table('config_options') + ->where('id', $option->id) + ->value('env_variable') + ); + } + $this->assertDatabaseHas('properties', [ + 'id' => $memoryId, + 'key' => 'MEMORY', + ]); + $this->assertDatabaseHas('properties', [ + 'id' => $cpuId, + 'key' => 'Cpu', + ]); + } + + public function test_later_malformed_metadata_blocks_every_planned_change(): void + { + [$service, $options] = $this->dynamicService([ + 'memory', + 'cpu', + ]); + $memoryId = $this->insertProperty( + $service, + 'MEMORY', + '4096' + ); + DB::table('config_options') + ->where('id', $options['cpu']->id) + ->update(['metadata' => '[]']); + + try { + $this->runMigration(); + $this->fail( + 'Malformed slider metadata must block migration.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + "Dynamic option {$options['cpu']->id} has invalid metadata", + $exception->getMessage() + ); + } + + foreach ($options as $resource => $option) { + $this->assertSame( + strtoupper($resource), + DB::table('config_options') + ->where('id', $option->id) + ->value('env_variable') + ); + } + $this->assertDatabaseHas('properties', [ + 'id' => $memoryId, + 'key' => 'MEMORY', + 'value' => '4096', + ]); + } + + /** + * @param array $resources + * @return array{ + * Service, + * array + * } + */ + private function dynamicService(array $resources): array + { + $server = Server::query()->create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $product = Product::factory()->create([ + 'server_id' => $server->id, + ]); + $user = User::factory()->create(); + $serviceId = DB::table('services')->insertGetId([ + 'user_id' => $user->id, + 'product_id' => $product->id, + 'status' => Service::STATUS_CANCELLED, + 'currency_code' => 'USD', + 'quantity' => 1, + 'price' => '0.00', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $service = Service::query()->findOrFail($serviceId); + $options = []; + + foreach ($resources as $resource) { + $option = ConfigOption::query()->create([ + 'name' => ucfirst($resource), + 'env_variable' => strtoupper($resource), + 'type' => 'dynamic_slider', + 'sort' => 0, + 'hidden' => false, + 'upgradable' => true, + 'metadata' => [ + 'resource_type' => $resource, + 'min' => 1, + 'max' => 100000, + 'step' => 1, + 'default' => 1, + 'display_divisor' => 1, + 'pricing' => [ + 'model' => 'linear', + 'rate_per_unit' => 0, + ], + ], + ]); + DB::table('config_option_products')->insert([ + 'config_option_id' => $option->id, + 'product_id' => $product->id, + ]); + $options[$resource] = $option; + } + + return [$service, $options]; + } + + private function insertProperty( + Service $service, + string $key, + string $value + ): int { + return DB::table('properties')->insertGetId([ + 'custom_property_id' => null, + 'name' => ucfirst(strtolower($key)), + 'key' => $key, + 'value' => $value, + 'model_type' => Service::class, + 'model_id' => $service->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function runMigration(): void + { + $migration = require __DIR__ + .'/../../database/migrations/' + .'2026_07_25_000002_normalize_dynamic_option_keys.php'; + + $migration->up(); + } + + private function requireCaseSensitivePropertyKeys(): void + { + if (DB::getDriverName() !== 'sqlite') { + $this->markTestSkipped( + 'The production MariaDB unique key intentionally rejects ' + .'case-only duplicate properties.' + ); + } + } +} diff --git a/tests/Feature/PricingPreviewParityTest.php b/tests/Feature/PricingPreviewParityTest.php deleted file mode 100644 index a985ade..0000000 --- a/tests/Feature/PricingPreviewParityTest.php +++ /dev/null @@ -1,154 +0,0 @@ - User::factory()->create()); - $product = Product::factory()->create(); - $plan = Plan::factory()->create([ - 'priceable_id' => $product->id, - 'priceable_type' => Product::class, - 'name' => 'Monthly', - 'billing_unit' => 'month', - 'billing_period' => 1, - 'type' => 'recurring', - 'dynamic_slider_base_price' => 5.00, - ]); - - Price::factory()->create([ - 'plan_id' => $plan->id, - 'price' => 10.00, - 'setup_fee' => 0.00, - 'currency_code' => 'USD', - ]); - - $memory = $this->attachSlider($product, 'Memory', 'memory', 1024, [ - 'model' => 'linear', - 'rate_per_unit' => 1.50, - ]); - $cpu = $this->attachSlider($product, 'CPU', 'cpu', 100, [ - 'model' => 'linear', - 'rate_per_unit' => 2.00, - ]); - $disk = $this->attachSlider($product, 'Disk', 'disk', 1024, [ - 'model' => 'linear', - 'rate_per_unit' => 0.25, - ]); - - $payload = [ - 'product_id' => $product->id, - 'plan_id' => $plan->id, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 20480, - ]; - - $response = $this->actingAs($user)->postJson('/api/dynamic-pterodactyl/pricing/calculate', $payload); - - $response->assertOk()->assertJson(['success' => true]); - - $expectedDelta = $memory->calculateDynamicPriceDelta(4096, 1, 'month') - + $cpu->calculateDynamicPriceDelta(200, 1, 'month') - + $disk->calculateDynamicPriceDelta(20480, 1, 'month'); - - $this->assertEquals(round($expectedDelta + 5.00, 2), $response->json('data.total')); - $response->assertJsonCount(3, 'data.breakdown'); - $response->assertJsonPath('data.model', 'linear'); - } - - private function attachSlider(Product $product, string $name, string $resourceType, int $displayDivisor, array $pricing): ConfigOption - { - $option = ConfigOption::create([ - 'name' => $name, - 'env_variable' => strtoupper($resourceType), - 'type' => 'dynamic_slider', - 'sort' => 0, - 'hidden' => false, - 'upgradable' => true, - 'metadata' => [ - 'resource_type' => $resourceType, - 'min' => $displayDivisor, - 'max' => $displayDivisor * 64, - 'step' => $displayDivisor, - 'default' => $displayDivisor, - 'unit' => $resourceType === 'cpu' ? '%' : 'MB', - 'display_unit' => $resourceType === 'cpu' ? 'cores' : 'GB', - 'display_divisor' => $displayDivisor, - 'pricing' => $pricing, - ], - ]); - - DB::table('config_option_products')->insert([ - 'product_id' => $product->id, - 'config_option_id' => $option->id, - ]); - - return $option; - } - public function test_pricing_calculate_with_foreign_plan_id_returns_client_error(): void - { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - - $product = Product::factory()->create(); - $otherProduct = Product::factory()->create(); - - $this->attachSlider($product, 'Memory', 'memory', 1024, [ - 'model' => 'linear', - 'rate_per_unit' => 1.50, - ]); - - $foreignPlan = Plan::factory()->create([ - 'priceable_id' => $otherProduct->id, - 'priceable_type' => Product::class, - 'name' => 'Other Plan', - 'billing_unit' => 'month', - 'billing_period' => 1, - 'type' => 'recurring', - 'dynamic_slider_base_price' => 5.00, - ]); - - Price::factory()->create([ - 'plan_id' => $foreignPlan->id, - 'price' => 10.00, - 'setup_fee' => 0.00, - 'currency_code' => 'USD', - ]); - - $payload = [ - 'product_id' => $product->id, - 'plan_id' => $foreignPlan->id, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 20480, - ]; - - $response = $this->actingAs($user)->postJson('/api/dynamic-pterodactyl/pricing/calculate', $payload); - - $response->assertStatus(422); - $response->assertJson(['success' => false]); - } - -} diff --git a/tests/Feature/ReservationApiTest.php b/tests/Feature/ReservationApiTest.php deleted file mode 100644 index 98d5054..0000000 --- a/tests/Feature/ReservationApiTest.php +++ /dev/null @@ -1,490 +0,0 @@ -withoutMiddleware(VerifyCsrfToken::class); - - $nodeSelectionService = $this->mock(NodeSelectionService::class); - $nodeSelectionService->shouldReceive('selectBestNode') - ->byDefault() - ->andReturn(['node_id' => 1, 'name' => 'Node 1']); - - $auditLogService = $this->mock(AuditLogService::class); - $auditLogService->shouldReceive('log')->byDefault()->andReturn(1); - } - - public function test_store_with_idempotency_key_returns_same_reservation_on_retry(): void - { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - /** @var Product $product */ - $product = $this->createConfiguredProduct(); - $cartItemId = $this->createCartItemForUser($user, $product->id); - - $response = $this->actingAs($user)->withHeaders([ - 'Idempotency-Key' => 'idem-12345', - ])->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $cartItemId, - ]); - - $retry = $this->actingAs($user)->withHeaders([ - 'Idempotency-Key' => 'idem-12345', - ])->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $cartItemId, - ]); - - $response->assertOk()->assertJson(['success' => true]); - $retry->assertOk()->assertJson(['success' => true]); - - $this->assertSame($response->json('data.id'), $retry->json('data.id')); - $this->assertSame($response->json('data.token'), $retry->json('data.token')); - $this->assertEquals(1, ResourceReservation::query()->where('user_id', $user->id)->count()); - $this->assertDatabaseHas('ptero_resource_reservations', [ - 'user_id' => $user->id, - 'idempotency_key' => 'idem-12345', - 'status' => 'pending', - ]); - } - - public function test_store_without_idempotency_key_creates_fresh_each_time(): void - { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - /** @var Product $product */ - $product = $this->createConfiguredProduct(); - $firstCartItemId = $this->createCartItemForUser($user, $product->id); - $secondCartItemId = $this->createCartItemForUser($user, $product->id); - - $first = $this->actingAs($user)->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $firstCartItemId, - ]); - - $second = $this->actingAs($user)->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $secondCartItemId, - ]); - - $first->assertOk()->assertJson(['success' => true]); - $second->assertOk()->assertJson(['success' => true]); - - $this->assertNotSame($first->json('data.token'), $second->json('data.token')); - $this->assertEquals(2, ResourceReservation::query()->where('user_id', $user->id)->count()); - } - - public function test_store_rejects_invalid_idempotency_key(): void - { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - /** @var Product $product */ - $product = $this->createConfiguredProduct(); - $cartItemId = $this->createCartItemForUser($user, $product->id); - - $response = $this->actingAs($user)->withHeaders([ - 'Idempotency-Key' => 'bad key', - ])->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $cartItemId, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors('idempotency_key'); - } - - public function test_store_rejects_unconfigured_product(): void - { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - /** @var Product $product */ - $product = Product::factory()->create(); - $cartItemId = $this->createCartItemForUser($user, $product->id); - - $response = $this->actingAs($user)->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $cartItemId, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors('product_id'); - } - - public function test_store_rejects_out_of_bounds_memory(): void - { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - /** @var Product $product */ - $product = $this->createConfiguredProduct(); - $cartItemId = $this->createCartItemForUser($user, $product->id); - - $response = $this->actingAs($user)->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 99999, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $cartItemId, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors('memory'); - } - - public function test_user_cannot_create_reservation_against_anothers_cart_item(): void - { - /** @var User $owner */ - $owner = User::withoutEvents(fn () => User::factory()->create()); - /** @var User $stranger */ - $stranger = User::withoutEvents(fn () => User::factory()->create()); - /** @var Product $product */ - $product = $this->createConfiguredProduct(); - $cartItemId = $this->createCartItemForUser($owner, $product->id); - - $response = $this->actingAs($stranger)->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $cartItemId, - ]); - - $response->assertForbidden(); - } - - public function test_user_can_create_reservation_against_own_cart_item(): void - { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - /** @var Product $product */ - $product = $this->createConfiguredProduct(); - $cartItemId = $this->createCartItemForUser($user, $product->id); - - $response = $this->actingAs($user)->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $cartItemId, - ]); - - $response->assertOk()->assertJson(['success' => true]); - $this->assertDatabaseHas('ptero_resource_reservations', [ - 'user_id' => $user->id, - 'cart_item_id' => $cartItemId, - 'status' => 'pending', - ]); - } - - public function test_guest_can_create_reservation_without_cart_item_id(): void - { - /** @var Product $product */ - $product = $this->createConfiguredProduct(); - - $response = $this->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - ]); - - $response->assertOk()->assertJson(['success' => true]); - $this->assertNotNull($response->json('data.token')); - $this->assertDatabaseHas('ptero_resource_reservations', [ - 'user_id' => null, - 'cart_item_id' => null, - 'location_id' => 1, - 'status' => 'pending', - ]); - } - - public function test_guest_reservation_throttled_by_ip(): void - { - /** @var Product $product */ - $product = $this->createConfiguredProduct(); - - $payload = [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - ]; - - for ($i = 1; $i <= 10; $i++) { - $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10']) - ->postJson('/api/dynamic-pterodactyl/reservation', $payload) - ->assertOk(); - } - - $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10']) - ->postJson('/api/dynamic-pterodactyl/reservation', $payload) - ->assertStatus(429); - } - - public function test_reservation_requires_valid_location_id(): void - { - /** @var Product $product */ - $product = $this->createConfiguredProduct(); - - $response = $this->postJson('/api/dynamic-pterodactyl/reservation', [ - 'product_id' => $product->id, - 'location_id' => 999, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors('location_id'); - } - - public function test_admin_can_view_other_users_reservation(): void - { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $admin = $this->makeAdminUser(); - $reservation = $this->makeReservation($owner); - - $this->actingAs($admin) - ->getJson('/api/dynamic-pterodactyl/reservation/' . $reservation->token) - ->assertOk() - ->assertJson(['success' => true]); - } - - public function test_admin_can_cancel_other_users_reservation(): void - { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $admin = $this->makeAdminUser(); - $reservation = $this->makeReservation($owner); - - $this->actingAs($admin) - ->deleteJson('/api/dynamic-pterodactyl/reservation/' . $reservation->token) - ->assertOk() - ->assertJson(['success' => true]); - - $this->assertDatabaseHas('ptero_resource_reservations', [ - 'id' => $reservation->id, - 'status' => 'cancelled', - ]); - } - - public function test_admin_can_extend_other_users_reservation(): void - { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $admin = $this->makeAdminUser(); - $reservation = $this->makeReservation($owner); - - $this->actingAs($admin) - ->postJson('/api/dynamic-pterodactyl/reservation/' . $reservation->token . '/extend', ['minutes' => 20]) - ->assertOk() - ->assertJson(['success' => true]); - } - - public function test_stranger_cannot_view_other_users_reservation(): void - { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $stranger = User::withoutEvents(fn () => User::factory()->create()); - $reservation = $this->makeReservation($owner); - - $this->actingAs($stranger) - ->getJson('/api/dynamic-pterodactyl/reservation/' . $reservation->token) - ->assertForbidden(); - } - - public function test_stranger_cannot_cancel_other_users_reservation(): void - { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $stranger = User::withoutEvents(fn () => User::factory()->create()); - $reservation = $this->makeReservation($owner); - - $this->actingAs($stranger) - ->deleteJson('/api/dynamic-pterodactyl/reservation/' . $reservation->token) - ->assertForbidden(); - } - - public function test_stranger_cannot_extend_other_users_reservation(): void - { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $stranger = User::withoutEvents(fn () => User::factory()->create()); - $reservation = $this->makeReservation($owner); - - $this->actingAs($stranger) - ->postJson('/api/dynamic-pterodactyl/reservation/' . $reservation->token . '/extend', ['minutes' => 20]) - ->assertForbidden(); - } - - public function test_owner_can_view_own_reservation(): void - { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $reservation = $this->makeReservation($owner); - - $this->actingAs($owner) - ->getJson('/api/dynamic-pterodactyl/reservation/' . $reservation->token) - ->assertOk() - ->assertJson(['success' => true]); - } - - public function test_reservation_create_throttles_at_10_per_minute(): void - { - $user = User::withoutEvents(fn () => User::factory()->create()); - /** @var Product $product */ - $product = Product::factory()->create(); - $cartItemId = $this->createCartItemForUser($user, $product->id); - - $payload = [ - 'product_id' => $product->id, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'cart_item_id' => $cartItemId, - ]; - - for ($i = 1; $i <= 10; $i++) { - $this->actingAs($user) - ->postJson('/api/dynamic-pterodactyl/reservation', $payload) - ->assertStatus(422); - } - - $this->actingAs($user) - ->postJson('/api/dynamic-pterodactyl/reservation', $payload) - ->assertStatus(429); - } - - private function createConfiguredProduct(): Product - { - /** @var Product $product */ - $product = Product::factory()->create(); - - $product->settings()->create([ - 'key' => 'location_ids', - 'value' => json_encode([1]), - 'type' => 'array', - ]); - - foreach ([ - 'memory' => ['name' => 'Memory', 'min' => 1024, 'max' => 8192, 'step' => 1024, 'unit' => 'MB'], - 'cpu' => ['name' => 'CPU', 'min' => 100, 'max' => 400, 'step' => 100, 'unit' => '%'], - 'disk' => ['name' => 'Disk', 'min' => 10240, 'max' => 102400, 'step' => 10240, 'unit' => 'MB'], - ] as $resourceType => $slider) { - $optionId = DB::table('config_options')->insertGetId([ - 'name' => $slider['name'], - 'type' => 'dynamic_slider', - 'sort' => 0, - 'hidden' => false, - 'upgradable' => true, - 'metadata' => json_encode([ - 'resource_type' => $resourceType, - 'min' => $slider['min'], - 'max' => $slider['max'], - 'step' => $slider['step'], - 'default' => $slider['min'], - 'unit' => $slider['unit'], - 'display_unit' => $slider['unit'], - 'display_divisor' => 1, - 'pricing' => ['model' => 'linear', 'rate_per_unit' => 1], - ]), - 'created_at' => now(), - 'updated_at' => now(), - ]); - - DB::table('config_option_products')->insert([ - 'product_id' => $product->id, - 'config_option_id' => $optionId, - ]); - } - - return $product; - } - - private function makeAdminUser(): User - { - $role = Role::firstOrCreate(['name' => 'Admin']); - - return User::withoutEvents(fn () => User::factory()->create(['role_id' => $role->id])); - } - - private function makeReservation(User $user, array $attributes = []): ResourceReservation - { - return ResourceReservation::create(array_merge([ - 'token' => (string) Str::random(64), - 'user_id' => $user->id, - 'node_id' => 1, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'calculated_price' => 9.99, - 'pricing_breakdown' => [], - 'status' => 'pending', - 'expires_at' => now()->addMinutes(15), - ], $attributes)); - } - - private function createCartItemForUser(User $user, int $productId): int - { - $cartId = DB::table('carts')->insertGetId([ - 'ulid' => (string) Str::ulid(), - 'user_id' => $user->id, - 'currency_code' => 'USD', - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return DB::table('cart_items')->insertGetId([ - 'cart_id' => $cartId, - 'product_id' => $productId, - 'quantity' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } -} diff --git a/tests/Feature/ReservationStatisticsTest.php b/tests/Feature/ReservationStatisticsTest.php new file mode 100644 index 0000000..a31e946 --- /dev/null +++ b/tests/Feature/ReservationStatisticsTest.php @@ -0,0 +1,76 @@ +reservation('USD', '10.10', 'confirmed'); + $this->reservation('USD', '2.20', 'confirmed'); + $this->reservation('AUD', '7.00', 'confirmed'); + $this->reservation(null, '1.00', 'confirmed'); + $this->reservation('USD', '100.00', 'pending'); + $old = $this->reservation('USD', '50.00', 'confirmed'); + $old->forceFill([ + 'created_at' => now()->subDays(31), + 'updated_at' => now()->subDays(31), + ])->save(); + + $stats = app(ReservationService::class)->getStatistics('30d'); + + $this->assertSame([ + 'AUD' => '7.00', + 'UNSPECIFIED' => '1.00', + 'USD' => '12.30', + ], $stats['confirmed_revenue_by_currency']); + $this->assertArrayNotHasKey('confirmed_revenue', $stats); + } + + public function test_reservation_table_formats_each_rows_own_currency(): void + { + $formatter = new \ReflectionMethod( + ReservationResource::class, + 'formatPrice' + ); + $formatter->setAccessible(true); + + $this->assertSame( + 'AUD 12.30', + $formatter->invoke(null, '12.30', 'aud') + ); + $this->assertSame( + '12.30 (currency unavailable)', + $formatter->invoke(null, '12.30', null) + ); + } + + private function reservation( + ?string $currency, + string $price, + string $status + ): ResourceReservation { + return ResourceReservation::query()->create([ + 'token' => Str::random(64), + 'node_id' => 1, + 'location_id' => 1, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + 'currency_code' => $currency, + 'calculated_price' => $price, + 'pricing_breakdown' => [], + 'status' => $status, + 'expires_at' => now()->addDay(), + ]); + } +} diff --git a/tests/Feature/ResourceQuoteApiTest.php b/tests/Feature/ResourceQuoteApiTest.php new file mode 100644 index 0000000..e983817 --- /dev/null +++ b/tests/Feature/ResourceQuoteApiTest.php @@ -0,0 +1,460 @@ + 'https://panel.example.com', + ]) + ))->register(); + require __DIR__.'/../../routes/api.php'; + } + + public function test_guest_receives_customer_safe_quote_contract(): void + { + $product = $this->quoteableProduct(); + $quotes = Mockery::mock(ResourceQuoteService::class); + $quotes->shouldReceive('quote') + ->once() + ->with( + Mockery::on(fn (Product $resolved): bool => $resolved->is($product)), + ['10' => 32768], + null + ) + ->andReturn([ + 'available' => true, + 'adjusted' => true, + 'selection' => [ + 'memory' => 23552, + 'cpu' => 200, + 'disk' => 51200, + ], + 'bounds' => [ + 'memory' => [ + 'config_option_id' => 10, + 'min' => 1024, + 'max' => 23552, + 'configured_max' => 32768, + 'step' => 1024, + ], + ], + ]); + $this->app->instance(ResourceQuoteService::class, $quotes); + + $response = $this->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => ['10' => 32768]] + ); + + $response->assertOk()->assertExactJson([ + 'data' => [ + 'available' => true, + 'adjusted' => true, + 'selection' => [ + 'memory' => 23552, + 'cpu' => 200, + 'disk' => 51200, + ], + 'bounds' => [ + 'memory' => [ + 'config_option_id' => 10, + 'min' => 1024, + 'max' => 23552, + 'configured_max' => 32768, + 'step' => 1024, + ], + ], + ], + ]); + $this->assertStringNotContainsString('node', $response->getContent()); + } + + public function test_invalid_request_has_safe_top_level_422_message(): void + { + $product = $this->quoteableProduct(); + + $this->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + [] + )->assertStatus(422)->assertJson([ + 'message' => 'The resource quote request is invalid.', + ]); + } + + public function test_stock_conflict_has_safe_top_level_409_message(): void + { + $product = $this->quoteableProduct(); + $quotes = Mockery::mock(ResourceQuoteService::class); + $quotes->shouldReceive('quote') + ->once() + ->andThrow(new StockUnavailableException('No server currently has enough stock.')); + $this->app->instance(ResourceQuoteService::class, $quotes); + + $this->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => []] + )->assertStatus(409)->assertExactJson([ + 'message' => 'No server currently has enough stock.', + ]); + } + + public function test_upstream_failure_does_not_leak_panel_details(): void + { + $product = $this->quoteableProduct(); + $quotes = Mockery::mock(ResourceQuoteService::class); + $quotes->shouldReceive('quote') + ->once() + ->andThrow(new \RuntimeException('panel-internal.example:8443 refused')); + $this->app->instance(ResourceQuoteService::class, $quotes); + + $response = $this->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => []] + ); + + $response->assertStatus(503)->assertExactJson([ + 'message' => 'Dynamic stock is temporarily unavailable.', + ]); + $this->assertStringNotContainsString('panel-internal', $response->getContent()); + } + + public function test_customer_quote_route_enforces_the_per_ip_budget(): void + { + $this->registerQuoteLimiter( + perIp: 1, + global: 10, + panelUrl: 'https://per-ip-rate-limit.example.com' + ); + $product = $this->quoteableProduct(); + $quotes = Mockery::mock(ResourceQuoteService::class); + $quotes->shouldReceive('quote') + ->once() + ->andReturn([ + 'available' => true, + 'adjusted' => false, + 'selection' => [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + ], + 'bounds' => [], + ]); + $this->app->instance(ResourceQuoteService::class, $quotes); + $endpoint = + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote"; + + $this->withServerVariables(['REMOTE_ADDR' => '192.0.2.10']) + ->postJson($endpoint, ['config_options' => []]) + ->assertOk(); + $this->withServerVariables(['REMOTE_ADDR' => '192.0.2.10']) + ->postJson($endpoint, ['config_options' => []]) + ->assertStatus(429); + } + + public function test_customer_quote_route_enforces_the_panel_global_budget(): void + { + $this->registerQuoteLimiter( + perIp: 10, + global: 1, + panelUrl: 'https://global-rate-limit.example.com' + ); + $product = $this->quoteableProduct(); + $quotes = Mockery::mock(ResourceQuoteService::class); + $quotes->shouldReceive('quote') + ->once() + ->andReturn([ + 'available' => true, + 'adjusted' => false, + 'selection' => [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + ], + 'bounds' => [], + ]); + $this->app->instance(ResourceQuoteService::class, $quotes); + $endpoint = + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote"; + + $this->withServerVariables(['REMOTE_ADDR' => '192.0.2.20']) + ->postJson($endpoint, ['config_options' => []]) + ->assertOk(); + $this->withServerVariables(['REMOTE_ADDR' => '192.0.2.21']) + ->postJson($endpoint, ['config_options' => []]) + ->assertStatus(429); + } + + public function test_owned_cart_item_quote_excludes_its_current_hold(): void + { + $product = $this->quoteableProduct(); + $cart = Cart::create(['currency_code' => 'USD']); + $cartItemId = DB::table('cart_items')->insertGetId([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'plan_id' => $product->plans()->value('id'), + 'config_options' => json_encode([]), + 'checkout_config' => json_encode([]), + 'quantity' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('ptero_resource_reservations')->insert([ + 'token' => 'owned-cart-hold', + 'cart_item_id' => $cartItemId, + 'cart_item_guard_id' => $cartItemId, + 'node_id' => 5, + 'location_id' => 1, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + 'calculated_price' => 0, + 'pricing_breakdown' => json_encode([]), + 'status' => 'pending', + 'expires_at' => now()->addMinutes(15), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $quotes = Mockery::mock(ResourceQuoteService::class); + $quotes->shouldReceive('quote') + ->once() + ->with( + Mockery::on(fn (Product $resolved): bool => $resolved->is($product)), + [], + 'owned-cart-hold' + ) + ->andReturn([ + 'available' => true, + 'adjusted' => false, + 'selection' => ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], + 'bounds' => [], + ]); + $this->app->instance(ResourceQuoteService::class, $quotes); + + $this->withCredentials() + ->withCookie('cart', $cart->ulid) + ->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => [], 'cart_item_id' => $cartItemId] + ) + ->assertOk(); + } + + public function test_owned_expired_cart_hold_is_excluded_until_cart_mutation_retires_it(): void + { + $product = $this->quoteableProduct(); + $cart = Cart::create(['currency_code' => 'USD']); + $cartItemId = DB::table('cart_items')->insertGetId([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'plan_id' => $product->plans()->value('id'), + 'config_options' => json_encode([]), + 'checkout_config' => json_encode([]), + 'quantity' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('ptero_resource_reservations')->insert([ + 'token' => 'owned-expired-cart-hold', + 'cart_item_id' => $cartItemId, + 'cart_item_guard_id' => $cartItemId, + 'node_id' => 5, + 'location_id' => 1, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + 'calculated_price' => 0, + 'pricing_breakdown' => json_encode([]), + 'status' => 'pending', + 'expires_at' => now()->subMinute(), + 'created_at' => now()->subMinutes(16), + 'updated_at' => now()->subMinutes(16), + ]); + + $quotes = Mockery::mock(ResourceQuoteService::class); + $quotes->shouldReceive('quote') + ->once() + ->with( + Mockery::on(fn (Product $resolved): bool => $resolved->is($product)), + [], + 'owned-expired-cart-hold' + ) + ->andReturn([ + 'available' => true, + 'adjusted' => false, + 'selection' => ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], + 'bounds' => [], + ]); + $this->app->instance(ResourceQuoteService::class, $quotes); + + $this->withCredentials() + ->withCookie('cart', $cart->ulid) + ->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => [], 'cart_item_id' => $cartItemId] + ) + ->assertOk(); + } + + public function test_cart_item_from_another_cart_cannot_be_used_for_exclusion(): void + { + $product = $this->quoteableProduct(); + $ownedCart = Cart::create(['currency_code' => 'USD']); + $otherCart = Cart::create(['currency_code' => 'USD']); + $otherItemId = DB::table('cart_items')->insertGetId([ + 'cart_id' => $otherCart->id, + 'product_id' => $product->id, + 'plan_id' => $product->plans()->value('id'), + 'config_options' => json_encode([]), + 'checkout_config' => json_encode([]), + 'quantity' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->withCredentials() + ->withCookie('cart', $ownedCart->ulid) + ->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => [], 'cart_item_id' => $otherItemId] + ) + ->assertStatus(422) + ->assertJson([ + 'message' => 'The cart item is not available for this resource quote.', + ]); + } + + public function test_hidden_product_is_rejected_before_quote_service(): void + { + $product = $this->quoteableProduct(); + $product->update(['hidden' => true]); + + $this->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => []] + )->assertStatus(404); + } + + public function test_out_of_stock_product_is_rejected_before_quote_service(): void + { + $product = $this->quoteableProduct(); + $product->update(['stock' => 0]); + + $this->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => []] + )->assertStatus(404); + } + + public function test_unpriced_product_is_rejected_before_quote_service(): void + { + $product = $this->quoteableProduct(); + $product->plans()->delete(); + + $this->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => []] + )->assertStatus(404); + } + + public function test_non_pterodactyl_product_is_rejected_before_panel_read(): void + { + $product = $this->quoteableProduct(); + $product->server->update(['extension' => 'OtherServer']); + + $this->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => []] + )->assertStatus(404); + } + + public function test_non_dynamic_product_is_rejected_before_panel_read(): void + { + $product = $this->quoteableProduct(); + DB::table('config_option_products') + ->where('product_id', $product->id) + ->delete(); + + $this->postJson( + "/api/dynamic-pterodactyl/products/{$product->id}/resource-quote", + ['config_options' => []] + )->assertStatus(404); + } + + private function quoteableProduct(): Product + { + $server = Server::create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $product = Product::factory()->create([ + 'server_id' => $server->id, + 'hidden' => false, + 'stock' => null, + ]); + $product->plans()->create([ + 'name' => 'Free', + 'type' => 'free', + 'billing_period' => 1, + 'billing_unit' => 'month', + ]); + $slider = ConfigOption::create([ + 'name' => 'Memory', + 'env_variable' => 'memory', + 'type' => 'dynamic_slider', + 'sort' => 0, + 'hidden' => false, + 'upgradable' => false, + 'metadata' => [ + 'resource_type' => 'memory', + 'min' => 1024, + 'max' => 32768, + 'step' => 1024, + 'default' => 4096, + ], + ]); + DB::table('config_option_products')->insert([ + 'product_id' => $product->id, + 'config_option_id' => $slider->id, + ]); + + return $product; + } + + private function registerQuoteLimiter( + int $perIp = 10, + int $global = 60, + string $panelUrl = 'https://panel.example.com' + ): void { + (new QuoteRateLimiterService( + new QuoteRateLimitConfigurationService([ + 'pterodactyl_url' => $panelUrl, + 'quote_rate_limit_per_ip' => $perIp, + 'quote_rate_limit_global' => $global, + ]) + ))->register(); + } +} diff --git a/tests/Feature/SetupWizardValidationTest.php b/tests/Feature/SetupWizardValidationTest.php index 0834247..f736c3d 100644 --- a/tests/Feature/SetupWizardValidationTest.php +++ b/tests/Feature/SetupWizardValidationTest.php @@ -3,6 +3,7 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Tests\Feature; use App\Models\Product; +use App\Models\Server; use App\Models\User; use Filament\Notifications\Notification; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -11,6 +12,7 @@ use Mockery; use Paymenter\Extensions\Others\DynamicPterodactyl\Admin\Pages\SetupWizard; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ConfigOptionSetupService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\PterodactylInventoryService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ResourceCalculationService; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\LaravelTestCase; @@ -31,7 +33,13 @@ protected function setUp(): void $resourceService->shouldReceive('getLocations')->zeroOrMoreTimes()->andReturn($locations); $this->app->instance(ResourceCalculationService::class, $resourceService); - $this->app['view']->addNamespace('dynamic-pterodactyl', __DIR__ . '/../../resources/views'); + $inventory = Mockery::mock(PterodactylInventoryService::class); + $inventory->shouldReceive('locations')->zeroOrMoreTimes()->andReturn($locations); + $inventory->shouldReceive('panelIdentity') + ->zeroOrMoreTimes() + ->andReturn(hash('sha256', 'https://panel.example')); + $this->app->instance(PterodactylInventoryService::class, $inventory); + $this->app['view']->addNamespace('dynamic-pterodactyl', __DIR__.'/../../resources/views'); } protected function tearDown(): void @@ -45,7 +53,7 @@ public function test_wizard_rejects_invalid_pricing_with_form_error(): void { $this->actingAsAdmin(); - $product = Product::factory()->create(); + $product = $this->eligibleProduct(); Livewire::test(SetupWizard::class) ->fillForm([ @@ -68,7 +76,7 @@ public function test_wizard_creates_three_sliders_and_location_on_valid_submissi { $this->actingAsAdmin(); - $product = Product::factory()->create(); + $product = $this->eligibleProduct(); // Filament harness fallback — see dp-13 plan commit 4 notes. app(ConfigOptionSetupService::class)->createDynamicSliderOptions( @@ -96,7 +104,7 @@ public function test_wizard_rollback_on_validator_failure_mid_batch(): void { $this->actingAsAdmin(); - $product = Product::factory()->create(); + $product = $this->eligibleProduct(); // Filament harness fallback — see dp-13 plan commit 4 notes. try { @@ -113,7 +121,8 @@ public function test_wizard_rollback_on_validator_failure_mid_batch(): void ['up_to' => null, 'rate' => 2.00], ], 'disk_tiers' => [], - ] + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] ); $this->fail('Expected the setup service to reject the invalid disk tiers.'); @@ -148,7 +157,7 @@ private function baseWizardData(int $productId): array 'memory_rate' => 0.50, 'cpu_rate' => 2.00, 'disk_rate' => 0.02, - 'locations' => [], + 'locations' => [1], ]; } @@ -169,4 +178,25 @@ private function actingAsAdmin(): User return $admin; } + + private function eligibleProduct(): Product + { + $server = Server::create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $server->settings()->create([ + 'key' => 'host', + 'value' => 'https://panel.example', + 'type' => 'string', + 'encrypted' => false, + ]); + + return Product::factory()->create([ + 'server_id' => $server->id, + 'hidden' => false, + ]); + } } diff --git a/tests/LaravelTestCase.php b/tests/LaravelTestCase.php index aac8b77..a2baace 100644 --- a/tests/LaravelTestCase.php +++ b/tests/LaravelTestCase.php @@ -2,6 +2,8 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Tests; +use App\Models\User; +use App\Models\UserSession; use Illuminate\Contracts\Console\Kernel; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; @@ -12,9 +14,9 @@ abstract class LaravelTestCase extends BaseTestCase */ public function createApplication() { - $bootstrap = __DIR__ . '/../../../../bootstrap/app.php'; + $bootstrap = __DIR__.'/../../../../bootstrap/app.php'; - if (!file_exists($bootstrap)) { + if (! file_exists($bootstrap)) { $bootstrap = '/var/www/paymenter/bootstrap/app.php'; } @@ -24,6 +26,24 @@ public function createApplication() return $app; } + /** + * Create the persisted session required by Paymenter's web middleware. + * + * @return array{user_session: string} + */ + protected function loginUser(User $user): array + { + $userSession = UserSession::create([ + 'user_id' => $user->id, + 'ip_address' => request()->ip(), + 'user_agent' => substr(request()->userAgent() ?? '', 0, 512), + 'last_activity' => now(), + 'expires_at' => null, + ]); + + return ['user_session' => $userSession->ulid]; + } + /** * Create a mock ConfigOption object for dynamic_slider type */ @@ -163,8 +183,14 @@ protected function createNodeData( 'node_id' => $nodeId, 'name' => $name, 'maintenance_mode' => $maintenance, + 'eligible' => ! $maintenance, 'total' => $total, 'available' => $available, + 'available_allocations' => [[ + 'id' => ($nodeId * 1000) + 1, + 'ip' => '192.0.2.'.$nodeId, + 'port' => 25565, + ]], ]; } diff --git a/tests/TestCase.php b/tests/TestCase.php index 69e0716..4c08bde 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -44,8 +44,14 @@ protected function createNodeData( 'node_id' => $nodeId, 'name' => $name, 'maintenance_mode' => $maintenance, + 'eligible' => ! $maintenance, 'total' => $total, 'available' => $available, + 'available_allocations' => [[ + 'id' => ($nodeId * 1000) + 1, + 'ip' => '192.0.2.'.$nodeId, + 'port' => 25565, + ]], ]; } diff --git a/tests/Unit/AlertServiceTest.php b/tests/Unit/AlertServiceTest.php index ec73f3b..735b95f 100644 --- a/tests/Unit/AlertServiceTest.php +++ b/tests/Unit/AlertServiceTest.php @@ -3,20 +3,26 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Tests\Unit; use App\Models\User; +use Illuminate\Container\Container; +use Illuminate\Events\Dispatcher; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Http\Client\Factory; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Notification; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Notification; use Mockery; +use Mockery\MockInterface; use Paymenter\Extensions\Others\DynamicPterodactyl\Events\AlertDeliveryFailed; use Paymenter\Extensions\Others\DynamicPterodactyl\Models\AlertConfig; use Paymenter\Extensions\Others\DynamicPterodactyl\Notifications\CapacityAlertNotification; +use Paymenter\Extensions\Others\DynamicPterodactyl\Notifications\ProvisioningFailedNotification; use Paymenter\Extensions\Others\DynamicPterodactyl\Notifications\ReservationShortfallNotification; -use Paymenter\Extensions\Others\DynamicPterodactyl\Services\AuditLogService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\AlertService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\AuditLogService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ResourceCalculationService; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\LaravelTestCase; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\TestCase; @@ -35,8 +41,8 @@ protected function setUp(): void private function setFacadeApplication(?object $dispatcher = null): void { - $app = new \Illuminate\Container\Container; - $app->instance('events', $dispatcher ?? new \Illuminate\Events\Dispatcher($app)); + $app = new Container; + $app->instance('events', $dispatcher ?? new Dispatcher($app)); $app->instance('log', new class { public function emergency(...$arguments): void {} @@ -57,11 +63,11 @@ public function debug(...$arguments): void {} public function log(...$arguments): void {} }); - $app->instance('http', new \Illuminate\Http\Client\Factory); - \Illuminate\Support\Facades\Facade::setFacadeApplication($app); + $app->instance('http', new Factory); + Facade::setFacadeApplication($app); } - private function bindEventDispatcherMock(): \Mockery\MockInterface + private function bindEventDispatcherMock(): MockInterface { $dispatcher = Mockery::spy(\Illuminate\Contracts\Events\Dispatcher::class); $this->setFacadeApplication($dispatcher); @@ -84,6 +90,61 @@ private function invokeSendNotifications(AlertService $service, object $config, return $method->invoke($service, $config, $availability, $alerts); } + public function test_capacity_thresholds_include_holds_and_authoritative_cpu(): void + { + $method = new \ReflectionMethod( + AlertService::class, + 'checkThresholds' + ); + $method->setAccessible(true); + $alerts = $method->invoke( + $this->makeService(), + [ + 'total_capacity' => [ + 'memory' => 100, + 'cpu' => 100, + 'disk' => 100, + ], + // Only ten is provisioned; the other 85 is held capacity. + 'total_allocated' => [ + 'memory' => 10, + 'cpu' => 10, + 'disk' => 10, + ], + 'total_available' => [ + 'memory' => 5, + 'cpu' => 15, + 'disk' => 90, + ], + ], + (object) [ + 'memory_warning_threshold' => 80, + 'memory_critical_threshold' => 90, + 'cpu_warning_threshold' => 80, + 'cpu_critical_threshold' => 90, + 'disk_warning_threshold' => 80, + 'disk_critical_threshold' => 90, + ] + ); + + $this->assertSame([ + [ + 'type' => 'critical', + 'resource' => 'memory', + 'utilization' => 95.0, + 'usage_percent' => 95.0, + 'threshold' => 90, + ], + [ + 'type' => 'warning', + 'resource' => 'cpu', + 'utilization' => 85.0, + 'usage_percent' => 85.0, + 'threshold' => 80, + ], + ], $alerts); + } + public function test_notify_shortfall_emails_all_admins(): void { $recipientA = new class @@ -161,7 +222,7 @@ public function test_notify_shortfall_no_admins_logs_warning(): void Log::spy(); $query = Mockery::mock(); - $query->shouldReceive('get')->once()->andReturn(new Collection()); + $query->shouldReceive('get')->once()->andReturn(new Collection); $user = Mockery::mock('alias:App\\Models\\User'); $user->shouldReceive('whereNotNull')->once()->with('role_id')->andReturn($query); @@ -177,10 +238,15 @@ public function test_notify_shortfall_no_admins_logs_warning(): void public function test_capacity_alert_email_fans_out_to_all_admins(): void { - $recipientA = new class + $query = Mockery::mock(); + $user = Mockery::mock('alias:App\\Models\\User'); + + $recipientA = new class extends User { public int $id = 101; + public string $email = 'admin-a@example.com'; + public array $notifications = []; public function notify($notification): void @@ -189,10 +255,12 @@ public function notify($notification): void } }; - $recipientB = new class + $recipientB = new class extends User { public int $id = 202; + public string $email = 'admin-b@example.com'; + public array $notifications = []; public function notify($notification): void @@ -201,10 +269,7 @@ public function notify($notification): void } }; - $query = Mockery::mock(); $query->shouldReceive('get')->once()->andReturn(new Collection([$recipientA, $recipientB])); - - $user = Mockery::mock('alias:App\\Models\\User'); $user->shouldReceive('whereNotNull')->once()->with('role_id')->andReturn($query); $service = $this->makeService(); @@ -215,7 +280,7 @@ public function notify($notification): void 'location_id' => 9, 'location_name' => 'AMS-1', 'email_notifications' => true, - 'notification_emails' => json_encode(['ops@example.com']), + 'notification_emails' => json_encode([]), 'webhook_notifications' => false, 'webhook_url' => null, ], @@ -234,12 +299,12 @@ public function notify($notification): void $this->assertSame('AMS-1', $recipientA->notifications[0]->alertConfig->location_name); } - public function test_capacity_alert_email_logs_warning_when_no_admins(): void + public function test_capacity_alert_email_logs_warning_when_no_recipients(): void { Log::spy(); $query = Mockery::mock(); - $query->shouldReceive('get')->once()->andReturn(new Collection()); + $query->shouldReceive('get')->once()->andReturn(new Collection); $user = Mockery::mock('alias:App\\Models\\User'); $user->shouldReceive('whereNotNull')->once()->with('role_id')->andReturn($query); @@ -252,7 +317,7 @@ public function test_capacity_alert_email_logs_warning_when_no_admins(): void 'location_id' => null, 'location_name' => null, 'email_notifications' => true, - 'notification_emails' => json_encode(['ops@example.com']), + 'notification_emails' => json_encode([]), 'webhook_notifications' => false, 'webhook_url' => null, ], @@ -266,17 +331,22 @@ public function test_capacity_alert_email_logs_warning_when_no_admins(): void ); Log::shouldHaveReceived('warning')->with( - 'No admin recipients configured for capacity alert', + 'No email recipients configured for capacity alert', Mockery::on(fn (array $context) => $context['alert_config_id'] === 99) ); } public function test_capacity_alert_email_uses_evaluated_location_for_global_scope(): void { - $recipient = new class + $query = Mockery::mock(); + $user = Mockery::mock('alias:App\\Models\\User'); + + $recipient = new class extends User { public int $id = 303; + public string $email = 'admin@example.com'; + public array $notifications = []; public function notify($notification): void @@ -285,10 +355,7 @@ public function notify($notification): void } }; - $query = Mockery::mock(); $query->shouldReceive('get')->once()->andReturn(new Collection([$recipient])); - - $user = Mockery::mock('alias:App\\Models\\User'); $user->shouldReceive('whereNotNull')->once()->with('role_id')->andReturn($query); $service = $this->makeService(); @@ -299,7 +366,7 @@ public function notify($notification): void 'location_id' => null, 'location_name' => null, 'email_notifications' => true, - 'notification_emails' => json_encode(['ops@example.com']), + 'notification_emails' => json_encode([]), 'webhook_notifications' => false, 'webhook_url' => null, ], @@ -323,20 +390,27 @@ public function test_capacity_alert_email_logged_on_dispatch_failure(): void { Log::spy(); - $failingRecipient = new class + $query = Mockery::mock(); + $user = Mockery::mock('alias:App\\Models\\User'); + + $failingRecipient = new class extends User { public int $id = 7; + public string $email = 'failing-admin@example.com'; + public function notify($notification): void { throw new \RuntimeException('smtp down'); } }; - $healthyRecipient = new class + $healthyRecipient = new class extends User { public int $id = 8; + public string $email = 'healthy-admin@example.com'; + public array $notifications = []; public function notify($notification): void @@ -345,10 +419,7 @@ public function notify($notification): void } }; - $query = Mockery::mock(); $query->shouldReceive('get')->once()->andReturn(new Collection([$failingRecipient, $healthyRecipient])); - - $user = Mockery::mock('alias:App\\Models\\User'); $user->shouldReceive('whereNotNull')->once()->with('role_id')->andReturn($query); $service = $this->makeService(); @@ -359,7 +430,7 @@ public function notify($notification): void 'location_id' => 1, 'location_name' => 'DFW-1', 'email_notifications' => true, - 'notification_emails' => json_encode(['ops@example.com']), + 'notification_emails' => json_encode([]), 'webhook_notifications' => false, 'webhook_url' => null, ], @@ -383,7 +454,7 @@ public function test_alert_with_both_channels_failed_dispatches_alert_delivery_f Http::fake(['*' => Http::response('Server Error', 500)]); $query = Mockery::mock(); - $query->shouldReceive('get')->once()->andReturn(new Collection()); + $query->shouldReceive('get')->once()->andReturn(new Collection); $user = Mockery::mock('alias:App\Models\User'); $user->shouldReceive('whereNotNull')->once()->with('role_id')->andReturn($query); @@ -395,7 +466,7 @@ public function test_alert_with_both_channels_failed_dispatches_alert_delivery_f 'location_id' => 1, 'location_name' => 'Test', 'email_notifications' => true, - 'notification_emails' => json_encode(['ops@example.com']), + 'notification_emails' => json_encode([]), 'webhook_notifications' => true, 'webhook_url' => 'https://hooks.example.com/test', ], @@ -438,7 +509,7 @@ public function test_alert_with_no_recipients_does_not_deliver_email(): void Log::spy(); $query = Mockery::mock(); - $query->shouldReceive('get')->once()->andReturn(new Collection()); + $query->shouldReceive('get')->once()->andReturn(new Collection); $user = Mockery::mock('alias:App\Models\User'); $user->shouldReceive('whereNotNull')->once()->with('role_id')->andReturn($query); @@ -450,7 +521,7 @@ public function test_alert_with_no_recipients_does_not_deliver_email(): void 'location_id' => null, 'location_name' => null, 'email_notifications' => true, - 'notification_emails' => json_encode(['ops@example.com']), + 'notification_emails' => json_encode([]), 'webhook_notifications' => false, 'webhook_url' => null, ], @@ -460,9 +531,8 @@ public function test_alert_with_no_recipients_does_not_deliver_email(): void $this->assertFalse($result); $dispatcher->shouldHaveReceived('dispatch')->with(Mockery::type(AlertDeliveryFailed::class)); - Log::shouldHaveReceived('warning')->with('No admin recipients configured for capacity alert', Mockery::any()); + Log::shouldHaveReceived('warning')->with('No email recipients configured for capacity alert', Mockery::any()); } - } class AlertServiceAuditTest extends LaravelTestCase @@ -481,6 +551,51 @@ private function runCheck(AlertService $service, AlertConfig $alertConfig): void $method->invoke($service, $alertConfig); } + public function test_terminal_upgrade_failure_notifies_and_audits_full_identity(): void + { + Notification::fake(); + $admin = User::factory()->create(['role_id' => 1]); + $service = $this->makeService( + Mockery::mock(ResourceCalculationService::class) + ); + $snapshot = [ + 'operation' => 'upgrade', + 'upgrade_id' => 41, + 'service_id' => 42, + 'invoice_id' => 43, + 'reservation_id' => 44, + 'node_id' => 45, + 'attempts' => 5, + 'error' => 'Panel rejected the build.', + ]; + + $service->notifyUpgradeFailure($snapshot); + + Notification::assertSentTo( + $admin, + ProvisioningFailedNotification::class, + fn (ProvisioningFailedNotification $notification): bool => $notification->snapshot === $snapshot + ); + $this->assertDatabaseHas('ptero_audit_logs', [ + 'action' => 'upgrade_failure_alerted', + 'entity_type' => 'resource_reservation', + 'entity_id' => 44, + ]); + $audit = DB::table('ptero_audit_logs') + ->where('action', 'upgrade_failure_alerted') + ->where('entity_id', 44) + ->latest('id') + ->first(); + $this->assertSame([ + 'upgrade_id' => 41, + 'service_id' => 42, + 'invoice_id' => 43, + 'reservation_id' => 44, + 'attempts' => 5, + 'error' => 'Panel rejected the build.', + ], json_decode($audit->new_values, true)); + } + public function test_capacity_alert_writes_audit_row_on_successful_send(): void { Notification::fake(); @@ -663,7 +778,7 @@ public function test_capacity_alert_does_not_audit_failed_webhook_delivery(): vo Event::assertDispatched(AlertDeliveryFailed::class); } - public function test_capacity_alert_no_admins_dispatches_delivery_failed_event(): void + public function test_configured_email_delivers_without_an_admin_account(): void { Event::fake(); Notification::fake(); @@ -693,7 +808,16 @@ public function test_capacity_alert_no_admins_dispatches_delivery_failed_event() $service = $this->makeService($resourceService); $this->runCheck($service, $alertConfig); - Event::assertDispatched(AlertDeliveryFailed::class); + Notification::assertSentOnDemand( + CapacityAlertNotification::class, + fn ( + CapacityAlertNotification $notification, + array $channels, + object $notifiable + ): bool => $notifiable->routes['mail'] === 'ops@example.com' + && $channels === ['mail'] + ); + Event::assertNotDispatched(AlertDeliveryFailed::class); } public function test_capacity_alert_success_does_not_dispatch_delivery_failed_event(): void diff --git a/tests/Unit/AllocationSelectionServiceTest.php b/tests/Unit/AllocationSelectionServiceTest.php new file mode 100644 index 0000000..44e7d3b --- /dev/null +++ b/tests/Unit/AllocationSelectionServiceTest.php @@ -0,0 +1,141 @@ +select([ + [ + 'id' => 1, + 'ip' => '192.0.2.10', + 'port' => 25565, + 'ip_in_use' => true, + ], + [ + 'id' => 2, + 'ip' => '192.0.2.10', + 'port' => 25566, + 'ip_in_use' => false, + ], + [ + 'id' => 3, + 'ip' => '192.0.2.11', + 'port' => 25565, + 'ip_in_use' => false, + ], + [ + 'id' => 4, + 'ip' => '192.0.2.11', + 'port' => 25566, + 'ip_in_use' => false, + ], + ], 2, dedicatedIp: true); + + $this->assertSame([3, 4], array_column($selected, 'id')); + } + + public function test_equivalent_ipv6_spellings_share_one_dedicated_ip_group(): void + { + $selected = (new AllocationSelectionService)->select([ + [ + 'id' => 10, + 'ip' => '2001:db8::1', + 'port' => 25565, + 'ip_in_use' => false, + ], + [ + 'id' => 11, + 'ip' => '2001:0db8:0:0:0:0:0:1', + 'port' => 25566, + 'ip_in_use' => false, + ], + ], 2, dedicatedIp: true); + + $this->assertSame([10, 11], array_column($selected, 'id')); + } + + public function test_port_range_constrains_primary_but_not_preclaimed_extras(): void + { + $selected = (new AllocationSelectionService)->select([ + [ + 'id' => 20, + 'ip' => '192.0.2.20', + 'port' => 20000, + 'ip_in_use' => false, + ], + [ + 'id' => 21, + 'ip' => '192.0.2.20', + 'port' => 25565, + 'ip_in_use' => false, + ], + ], 2, allowedPortRanges: [ + ['from' => 25560, 'to' => 25570], + ]); + + $this->assertSame([21, 20], array_column($selected, 'id')); + } + + public function test_dedicated_fixed_ports_must_exist_on_the_same_ip(): void + { + $selected = (new AllocationSelectionService)->select([ + [ + 'id' => 30, + 'ip' => '192.0.2.30', + 'port' => 25565, + 'ip_in_use' => false, + ], + [ + 'id' => 31, + 'ip' => '192.0.2.31', + 'port' => 25566, + 'ip_in_use' => false, + ], + ], 2, [25565, 25566], dedicatedIp: true); + + $this->assertNull($selected); + } + + public function test_repeated_port_across_ips_uses_lowest_allocation_id(): void + { + $selected = (new AllocationSelectionService)->select([ + [ + 'id' => 101, + 'ip' => '192.0.2.11', + 'port' => 25570, + ], + [ + 'id' => 100, + 'ip' => '192.0.2.10', + 'port' => 25570, + ], + ], 1, [25570]); + + $this->assertSame([100], array_column($selected, 'id')); + } + + public function test_dedicated_repeated_port_uses_lowest_eligible_ip_group(): void + { + $selected = (new AllocationSelectionService)->select([ + [ + 'id' => 202, + 'ip' => '192.0.2.22', + 'port' => 25570, + 'ip_in_use' => false, + ], + [ + 'id' => 201, + 'ip' => '192.0.2.21', + 'port' => 25570, + 'ip_in_use' => false, + ], + ], 1, [25570], dedicatedIp: true); + + $this->assertSame([201], array_column($selected, 'id')); + } +} diff --git a/tests/Unit/CartItemCreatedListenerTest.php b/tests/Unit/CartItemCreatedListenerTest.php new file mode 100644 index 0000000..2d83b49 --- /dev/null +++ b/tests/Unit/CartItemCreatedListenerTest.php @@ -0,0 +1,106 @@ +cartItem(); + $listener = $this->listenerExpectingReservation($cartItem); + + $listener->handle(new Created($cartItem)); + + $this->addToAssertionCount(1); + } + + public function test_updated_dynamic_cart_item_replaces_or_refreshes_its_hold(): void + { + $cartItem = $this->cartItem(); + $listener = $this->listenerExpectingReservation($cartItem); + + $listener->handle(new Updated($cartItem)); + + $this->addToAssertionCount(1); + } + + public function test_product_without_dynamic_resources_does_not_reserve_or_log(): void + { + $cartItem = $this->cartItem(); + $reservations = Mockery::mock(ReservationService::class); + $reservations->shouldReceive('reserveForCartItem')->never(); + $configuration = Mockery::mock( + ReservationConfigurationService::class + ); + $configuration->shouldReceive('requiresReservation') + ->once() + ->with(91) + ->andReturn(false); + Log::shouldReceive('info')->never(); + + (new CartItemCreatedListener($reservations, $configuration)) + ->handle(new Created($cartItem)); + + $this->addToAssertionCount(1); + } + + private function listenerExpectingReservation( + CartItem $cartItem + ): CartItemCreatedListener { + $configuration = Mockery::mock( + ReservationConfigurationService::class + ); + $configuration->shouldReceive('requiresReservation') + ->once() + ->with(91) + ->andReturn(true); + $reservations = Mockery::mock(ReservationService::class); + $reservations->shouldReceive('reserveForCartItem') + ->once() + ->with($cartItem) + ->andReturn([ + 'id' => 73, + 'node_id' => 7, + 'expires_at' => '2026-07-27T04:30:00+00:00', + 'status' => 'pending', + ]); + Log::shouldReceive('info') + ->once() + ->with('Capacity reserved for cart item', [ + 'cart_item_id' => 42, + 'reservation_id' => 73, + 'node_id' => 7, + 'expires_at' => '2026-07-27T04:30:00+00:00', + ]); + + return new CartItemCreatedListener( + $reservations, + $configuration + ); + } + + private function cartItem(): CartItem + { + $cartItem = new CartItem; + $cartItem->id = 42; + $cartItem->product_id = 91; + + return $cartItem; + } +} diff --git a/tests/Unit/CartItemDeletedListenerTest.php b/tests/Unit/CartItemDeletedListenerTest.php index 769285c..0d5c64a 100644 --- a/tests/Unit/CartItemDeletedListenerTest.php +++ b/tests/Unit/CartItemDeletedListenerTest.php @@ -2,9 +2,10 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Tests\Unit; -use App\Events\CartItem\Deleted; +use App\Events\CartItem\Deleting; use App\Models\CartItem; use Illuminate\Support\Facades\Log; +use Mockery; use Paymenter\Extensions\Others\DynamicPterodactyl\Listeners\CartItemDeletedListener; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ReservationService; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\LaravelTestCase; @@ -13,123 +14,49 @@ class CartItemDeletedListenerTest extends LaravelTestCase { protected function tearDown(): void { - \Mockery::close(); - + Mockery::close(); parent::tearDown(); } - public function test_no_token_returns_early_without_cancelling(): void + public function test_cart_item_deletion_cancels_by_relationship_without_a_token(): void { - $reservationService = \Mockery::mock(ReservationService::class); - $reservationService->shouldNotReceive('cancel'); - $this->app->instance(ReservationService::class, $reservationService); - - $listener = new CartItemDeletedListener; - $listener->handle(new Deleted($this->makeCartItem())); - - $this->addToAssertionCount(1); - } - - public function test_skip_checkout_path_does_not_cancel_and_logs_debug(): void - { - $token = 'checkout_token_123'; - $propertyQuery = \Mockery::mock(); - $propertyQuery->shouldReceive('where')->once()->with('value', $token)->andReturnSelf(); - - $serviceQuery = \Mockery::mock(); - $serviceQuery->shouldReceive('exists')->once()->andReturn(true); - - $serviceModel = \Mockery::mock('alias:App\\Models\\Service'); - $serviceModel->shouldReceive('whereHas') + $reservationService = Mockery::mock(ReservationService::class); + $reservationService->shouldReceive('cancelForCartItem') ->once() - ->with('properties', \Mockery::on(function ($closure) use ($propertyQuery) { - $propertyQuery->shouldReceive('where')->once()->with('key', '_reservation_token')->andReturn($propertyQuery); - $closure($propertyQuery); - - return true; - })) - ->andReturn($serviceQuery); - - $reservationService = \Mockery::mock(ReservationService::class); - $reservationService->shouldNotReceive('cancel'); - $this->app->instance(ReservationService::class, $reservationService); - - Log::shouldReceive('debug') - ->once() - ->with('Skipping reservation cancel: cart item consumed by checkout', \Mockery::on(function ($context) use ($token) { - return $context['cart_item_id'] === 1 - && $context['reservation_token'] === substr($token, 0, 8) . '...'; - })); - - $listener = new CartItemDeletedListener; - $listener->handle(new Deleted($this->makeCartItem($token))); - - $this->addToAssertionCount(1); - } - - public function test_abandonment_path_cancels_reservation(): void - { - $token = 'abandon_token_123'; - - $serviceQuery = \Mockery::mock(); - $serviceQuery->shouldReceive('exists')->once()->andReturn(false); - - $serviceModel = \Mockery::mock('alias:App\\Models\\Service'); - $serviceModel->shouldReceive('whereHas')->once()->andReturn($serviceQuery); - - $reservationService = \Mockery::mock(ReservationService::class); - $reservationService->shouldReceive('cancel')->once()->with($token, null, 'cart_deleted', null); - $this->app->instance(ReservationService::class, $reservationService); + ->with(42) + ->andReturn(true); Log::shouldReceive('info') ->once() - ->with('Cancelled reservation for deleted cart item', \Mockery::on(function ($context) use ($token) { - return $context['cart_item_id'] === 1 - && $context['reservation_token'] === substr($token, 0, 8) . '...'; - })); + ->with('Cancelled capacity reservation for removed cart item', [ + 'cart_item_id' => 42, + ]); - $listener = new CartItemDeletedListener; - $listener->handle(new Deleted($this->makeCartItem($token))); + $cartItem = new CartItem; + $cartItem->id = 42; + + (new CartItemDeletedListener($reservationService)) + ->handle(new Deleting($cartItem)); $this->addToAssertionCount(1); } - public function test_exception_path_logs_error_without_rethrowing(): void + public function test_bound_or_missing_hold_needs_no_cancellation_log(): void { - $token = 'exception_token_123'; - - $serviceQuery = \Mockery::mock(); - $serviceQuery->shouldReceive('exists')->once()->andReturn(false); - - $serviceModel = \Mockery::mock('alias:App\\Models\\Service'); - $serviceModel->shouldReceive('whereHas')->once()->andReturn($serviceQuery); - - $reservationService = \Mockery::mock(ReservationService::class); - $reservationService->shouldReceive('cancel') + $reservationService = Mockery::mock(ReservationService::class); + $reservationService->shouldReceive('cancelForCartItem') ->once() - ->with($token, null, 'cart_deleted', null) - ->andThrow(new \RuntimeException('boom')); - $this->app->instance(ReservationService::class, $reservationService); + ->with(42) + ->andReturn(false); - Log::shouldReceive('error') - ->once() - ->with('Failed to cancel reservation', \Mockery::on(function ($context) use ($token) { - return $context['token'] === substr($token, 0, 8) . '...' - && $context['error'] === 'boom'; - })); - - $listener = new CartItemDeletedListener; - $listener->handle(new Deleted($this->makeCartItem($token))); - - $this->addToAssertionCount(1); - } + Log::shouldReceive('info')->never(); - private function makeCartItem(?string $token = null): CartItem - { $cartItem = new CartItem; - $cartItem->id = 1; - $cartItem->checkout_config = $token ? ['_reservation_token' => $token] : []; + $cartItem->id = 42; - return $cartItem; + (new CartItemDeletedListener($reservationService)) + ->handle(new Deleting($cartItem)); + + $this->addToAssertionCount(1); } } diff --git a/tests/Unit/ConfigOptionSetupServiceTest.php b/tests/Unit/ConfigOptionSetupServiceTest.php index a7f165e..5038cf1 100644 --- a/tests/Unit/ConfigOptionSetupServiceTest.php +++ b/tests/Unit/ConfigOptionSetupServiceTest.php @@ -3,18 +3,40 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Tests\Unit; use App\Models\ConfigOption; +use App\Models\Invoice; use App\Models\Product; +use App\Models\Server; +use App\Models\Service; +use App\Models\User; +use App\Services\Service\CapacityServiceCreationCoordinator; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Mockery; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\AuditLogService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ConfigOptionSetupService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\PterodactylInventoryService; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\LaravelTestCase; class ConfigOptionSetupServiceTest extends LaravelTestCase { use DatabaseTransactions; + protected function setUp(): void + { + parent::setUp(); + + $inventory = Mockery::mock(PterodactylInventoryService::class); + $inventory->shouldReceive('locations')->zeroOrMoreTimes()->andReturn([ + ['id' => 1, 'short' => 'nyc', 'long' => 'New York'], + ['id' => 2, 'short' => 'lon', 'long' => 'London'], + ]); + $inventory->shouldReceive('panelIdentity') + ->zeroOrMoreTimes() + ->andReturn(hash('sha256', 'https://panel.example')); + app()->instance(PterodactylInventoryService::class, $inventory); + } + protected function tearDown(): void { Mockery::close(); @@ -22,9 +44,9 @@ protected function tearDown(): void parent::tearDown(); } - public function test_createDynamicSliderOptions_rolls_back_on_mid_batch_failure(): void + public function test_create_dynamic_slider_options_rolls_back_on_mid_batch_failure(): void { - $product = Product::factory()->create(); + $product = $this->eligibleProduct(); try { app(ConfigOptionSetupService::class)->createDynamicSliderOptions( @@ -39,7 +61,8 @@ public function test_createDynamicSliderOptions_rolls_back_on_mid_batch_failure( ['up_to' => null, 'rate' => 2.00], ], 'disk_tiers' => [], - ] + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] ); $this->fail('Expected setup to reject the invalid disk pricing tiers.'); @@ -50,9 +73,9 @@ public function test_createDynamicSliderOptions_rolls_back_on_mid_batch_failure( $this->assertSame(0, $this->countOptionsForProduct($product->id)); } - public function test_createDynamicSliderOptions_happy_path_creates_all_four(): void + public function test_create_dynamic_slider_options_happy_path_creates_all_four(): void { - $product = Product::factory()->create(); + $product = $this->eligibleProduct(); $created = app(ConfigOptionSetupService::class)->createDynamicSliderOptions( $product->id, @@ -77,7 +100,7 @@ public function test_createDynamicSliderOptions_happy_path_creates_all_four(): v public function test_setup_run_audit_still_fires_on_successful_transaction(): void { - $product = Product::factory()->create(); + $product = $this->eligibleProduct(); $audit = Mockery::mock(AuditLogService::class); $audit->shouldReceive('log') @@ -105,6 +128,236 @@ public function test_setup_run_audit_still_fires_on_successful_transaction(): vo $this->assertSame(4, $this->countParentOptionsForProduct($product->id)); } + public function test_fractional_display_values_scale_exactly_without_truncation(): void + { + $product = $this->eligibleProduct(); + + $created = app(ConfigOptionSetupService::class) + ->createDynamicSliderOptions( + $product->id, + [ + 'pricing_model' => 'linear', + 'memory_min' => '0.5', + 'memory_max' => '2', + 'memory_step' => '0.5', + 'memory_default' => '1', + 'memory_rate' => 1, + 'cpu_rate' => 1, + 'disk_rate' => 1, + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] + ); + + $this->assertSame(512, $created['memory']->metadata['min']); + $this->assertSame(512, $created['memory']->metadata['step']); + $this->assertSame(1024, $created['memory']->metadata['default']); + $this->assertSame(2048, $created['memory']->metadata['max']); + } + + public function test_fractional_display_value_that_cannot_scale_exactly_is_rejected(): void + { + $product = $this->eligibleProduct(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('represented exactly'); + app(ConfigOptionSetupService::class)->createDynamicSliderOptions( + $product->id, + [ + 'pricing_model' => 'linear', + 'memory_min' => '0.0001', + 'memory_rate' => 1, + 'cpu_rate' => 1, + 'disk_rate' => 1, + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] + ); + } + + public function test_zero_resource_minimum_is_rejected_before_it_can_mean_unlimited(): void + { + $product = $this->eligibleProduct(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Memory minimum must be greater than zero.' + ); + + app(ConfigOptionSetupService::class)->createDynamicSliderOptions( + $product->id, + [ + 'pricing_model' => 'linear', + 'memory_min' => 0, + 'memory_rate' => 1, + 'cpu_rate' => 1, + 'disk_rate' => 1, + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] + ); + } + + public function test_scaled_value_overflow_is_rejected(): void + { + $product = $this->eligibleProduct(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('outside the supported range'); + app(ConfigOptionSetupService::class)->createDynamicSliderOptions( + $product->id, + [ + 'pricing_model' => 'linear', + 'memory_max' => (string) PHP_INT_MAX, + 'memory_rate' => 1, + 'cpu_rate' => 1, + 'disk_rate' => 1, + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] + ); + } + + public function test_unmanaged_resource_name_collision_fails_closed(): void + { + $product = $this->eligibleProduct(); + $option = ConfigOption::create([ + 'name' => 'Memory', + 'env_variable' => 'memory', + 'type' => 'number', + 'hidden' => false, + ]); + $option->products()->attach($product->id); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('unmanaged memory'); + app(ConfigOptionSetupService::class)->createDynamicSliderOptions( + $product->id, + [ + 'pricing_model' => 'linear', + 'memory_rate' => 1, + 'cpu_rate' => 1, + 'disk_rate' => 1, + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] + ); + } + + public function test_setup_rejects_a_product_on_a_different_panel(): void + { + $product = $this->eligibleProduct([ + 'provisioner_host' => 'https://other-panel.example', + ]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('same panel'); + app(ConfigOptionSetupService::class)->createDynamicSliderOptions( + $product->id, + [ + 'pricing_model' => 'linear', + 'memory_rate' => 1, + 'cpu_rate' => 1, + 'disk_rate' => 1, + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] + ); + } + + public function test_wizard_rerun_preserves_bound_invoice_guarantee_snapshot(): void + { + $product = $this->eligibleProduct(); + $service = app(ConfigOptionSetupService::class); + $service->createDynamicSliderOptions( + $product->id, + [ + 'pricing_model' => 'linear', + 'memory_max' => 32, + 'memory_rate' => 1, + 'cpu_rate' => 1, + 'disk_rate' => 1, + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] + ); + + $user = User::factory()->create(); + $boundService = CapacityServiceCreationCoordinator::run( + fn () => Service::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + 'quantity' => 1, + 'price' => 10, + 'currency_code' => 'USD', + 'status' => Service::STATUS_PENDING, + ]) + ); + $invoice = Invoice::factory()->create([ + 'user_id' => $user->id, + 'currency_code' => 'USD', + 'status' => Invoice::STATUS_PENDING, + 'due_at' => now()->addDays(4), + ]); + $payload = [ + 'product_id' => $product->id, + 'memory' => 32768, + 'cpu' => 200, + 'disk' => 20480, + ]; + $reservationId = DB::table('ptero_resource_reservations')->insertGetId([ + 'token' => Str::random(64), + 'purpose' => 'checkout', + 'idempotency_key' => hash('sha256', Str::random()), + 'server_extension_id' => $product->server_id, + 'panel_identity' => str_repeat('a', 64), + 'service_id' => $boundService->id, + 'service_guard_id' => $boundService->id, + 'invoice_id' => $invoice->id, + 'user_id' => $user->id, + 'product_id' => $product->id, + 'quantity' => 1, + 'currency_code' => 'USD', + 'configuration_fingerprint' => hash( + 'sha256', + json_encode($payload, JSON_THROW_ON_ERROR) + ), + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'pricing_version' => str_repeat('b', 64), + 'formula_version' => 'test-v1', + 'node_id' => 1, + 'location_id' => 1, + 'memory' => 32768, + 'cpu' => 200, + 'disk' => 20480, + 'calculated_price' => 10, + 'pricing_breakdown' => json_encode([], JSON_THROW_ON_ERROR), + 'status' => 'pending', + 'expires_at' => now()->addDays(4), + 'guaranteed_until' => now()->addDays(4), + 'created_at' => now()->subDays(3), + 'updated_at' => now(), + ]); + + $service->createDynamicSliderOptions( + $product->id, + [ + 'pricing_model' => 'linear', + 'memory_max' => 64, + 'memory_rate' => 2, + 'cpu_rate' => 1, + 'disk_rate' => 1, + ], + [['id' => 1, 'short' => 'nyc', 'long' => 'New York']] + ); + + $reservation = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->first(); + $this->assertSame('pending', $reservation->status); + $this->assertSame( + $payload, + json_decode($reservation->configuration_payload, true) + ); + $this->assertSame(Invoice::STATUS_PENDING, $invoice->fresh()->status); + } + private function countOptionsForProduct(int $productId): int { return DB::table('config_options') @@ -113,6 +366,32 @@ private function countOptionsForProduct(int $productId): int ->count(); } + private function eligibleProduct(array $attributes = []): Product + { + $host = (string) ( + $attributes['provisioner_host'] + ?? 'https://panel.example' + ); + unset($attributes['provisioner_host']); + $server = Server::create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $server->settings()->create([ + 'key' => 'host', + 'value' => $host, + 'type' => 'string', + 'encrypted' => false, + ]); + + return Product::factory()->create(array_merge([ + 'server_id' => $server->id, + 'hidden' => false, + ], $attributes)); + } + private function countParentOptionsForProduct(int $productId): int { return DB::table('config_options') diff --git a/tests/Unit/InvoicePaidListenerTest.php b/tests/Unit/InvoicePaidListenerTest.php deleted file mode 100644 index 1a248aa..0000000 --- a/tests/Unit/InvoicePaidListenerTest.php +++ /dev/null @@ -1,131 +0,0 @@ - 50, - 'node_id' => 7, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - ]; - - $reservationService = Mockery::mock(ReservationService::class); - $reservationService->shouldReceive('getByToken')->once()->with($token)->andReturn($reservation); - $this->app->instance(ReservationService::class, $reservationService); - - $resourceService = Mockery::mock(ResourceCalculationService::class); - $resourceService->shouldReceive('verifyAvailability')->once()->with(7, [ - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - ], $token)->andReturn(false); - $this->app->instance(ResourceCalculationService::class, $resourceService); - - $alertService = Mockery::mock(AlertService::class); - $alertService->shouldReceive('notifyShortfall')->once()->with( - 10, - 20, - ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], - 'insufficient_resources' - ); - $this->app->instance(AlertService::class, $alertService); - - $listener = new InvoicePaidListener; - $listener->handle(new Paid($this->makeInvoiceEventData(serviceId: 10, invoiceId: 20, token: $token))); - - $this->addToAssertionCount(1); - } - - public function test_state_drift_triggers_notification(): void - { - $token = 'state-drift-token'; - $reservation = (object) [ - 'id' => 51, - 'node_id' => 8, - 'memory' => 2048, - 'cpu' => 150, - 'disk' => 25600, - ]; - $current = (object) ['status' => 'expired']; - - $reservationService = Mockery::mock(ReservationService::class); - $reservationService->shouldReceive('getByToken')->once()->with($token)->andReturn($reservation); - $reservationService->shouldReceive('confirm')->once()->with($token, 11, null)->andReturn(false); - $reservationService->shouldReceive('getByToken')->once()->with($token)->andReturn($current); - $this->app->instance(ReservationService::class, $reservationService); - - $resourceService = Mockery::mock(ResourceCalculationService::class); - $resourceService->shouldReceive('verifyAvailability')->once()->with(8, [ - 'memory' => 2048, - 'cpu' => 150, - 'disk' => 25600, - ], $token)->andReturn(true); - $this->app->instance(ResourceCalculationService::class, $resourceService); - - $alertService = Mockery::mock(AlertService::class); - $alertService->shouldReceive('notifyShortfall')->once()->with( - 11, - 21, - ['memory' => 2048, 'cpu' => 150, 'disk' => 25600], - 'state_drift:expired' - ); - $this->app->instance(AlertService::class, $alertService); - - $listener = new InvoicePaidListener; - $listener->handle(new Paid($this->makeInvoiceEventData(serviceId: 11, invoiceId: 21, token: $token))); - - $this->addToAssertionCount(1); - } - - private function makeInvoiceEventData(int $serviceId, int $invoiceId, string $token): Invoice - { - $propertyQuery = Mockery::mock(); - $propertyQuery->shouldReceive('where')->once()->with('key', '_reservation_token')->andReturnSelf(); - $propertyQuery->shouldReceive('value')->once()->with('value')->andReturn($token); - - $service = new class($propertyQuery) extends Service - { - public function __construct(private object $propertyQuery) {} - - public function properties() - { - return $this->propertyQuery; - } - }; - $service->id = $serviceId; - - $item = new InvoiceItem; - $item->reference_type = Service::class; - $item->setRelation('reference', $service); - - $invoice = new Invoice; - $invoice->id = $invoiceId; - $invoice->setRelation('items', collect([$item])); - - return $invoice; - } -} diff --git a/tests/Unit/NodeCapacityPolicyTest.php b/tests/Unit/NodeCapacityPolicyTest.php new file mode 100644 index 0000000..cd0aaf5 --- /dev/null +++ b/tests/Unit/NodeCapacityPolicyTest.php @@ -0,0 +1,32 @@ + 800, + 'cpu_overcommit_bps' => 15000, + 'enabled' => true, + ]); + + $this->assertSame(1200, $policy->effectiveCpuCapacity()); + } + + public function test_policy_bounds_prevent_overflowing_capacity_math(): void + { + $policy = new NodeCapacityPolicy([ + 'cpu_capacity_percent' => NodeCapacityPolicy::MAX_CPU_CAPACITY_PERCENT + 1, + 'cpu_overcommit_bps' => 10000, + 'enabled' => true, + ]); + + $this->expectException(\InvalidArgumentException::class); + $policy->effectiveCpuCapacity(); + } +} diff --git a/tests/Unit/NodeSelectionServiceTest.php b/tests/Unit/NodeSelectionServiceTest.php index 8c16ad3..7f9b5c5 100644 --- a/tests/Unit/NodeSelectionServiceTest.php +++ b/tests/Unit/NodeSelectionServiceTest.php @@ -3,6 +3,7 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Tests\Unit; use Mockery; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\AllocationSelectionService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\NodeSelectionService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ResourceCalculationService; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\LaravelTestCase; @@ -10,6 +11,7 @@ class NodeSelectionServiceTest extends LaravelTestCase { private NodeSelectionService $service; + private $mockResourceService; protected function setUp(): void @@ -17,7 +19,10 @@ protected function setUp(): void parent::setUp(); $this->mockResourceService = Mockery::mock(ResourceCalculationService::class); - $this->service = new NodeSelectionService($this->mockResourceService); + $this->service = new NodeSelectionService( + $this->mockResourceService, + new AllocationSelectionService + ); } protected function tearDown(): void @@ -32,7 +37,7 @@ protected function tearDown(): void public function test_selects_node_with_most_headroom(): void { $this->mockResourceService->shouldReceive('getLocationAvailability') - ->with(1) + ->with(1, null) ->andReturn([ 'nodes' => [ $this->createNodeData(1, 'Node 1', [ @@ -75,7 +80,7 @@ public function test_selects_node_with_most_headroom(): void public function test_skips_nodes_in_maintenance(): void { $this->mockResourceService->shouldReceive('getLocationAvailability') - ->with(1) + ->with(1, null) ->andReturn([ 'nodes' => [ $this->createNodeData(1, 'Node 1 (Maint)', [ @@ -117,7 +122,7 @@ public function test_skips_nodes_in_maintenance(): void public function test_skips_nodes_with_insufficient_memory(): void { $this->mockResourceService->shouldReceive('getLocationAvailability') - ->with(1) + ->with(1, null) ->andReturn([ 'nodes' => [ $this->createNodeData(1, 'Low Memory Node', [ @@ -153,13 +158,10 @@ public function test_skips_nodes_with_insufficient_memory(): void $this->assertEquals(2, $result['node_id']); } - /** - * Test that nodes with insufficient CPU are skipped. - */ - public function test_skips_nodes_with_insufficient_cpu(): void + public function test_skips_node_with_insufficient_authoritative_cpu(): void { $this->mockResourceService->shouldReceive('getLocationAvailability') - ->with(1) + ->with(1, null) ->andReturn([ 'nodes' => [ $this->createNodeData(1, 'Low CPU Node', [ @@ -200,7 +202,7 @@ public function test_skips_nodes_with_insufficient_cpu(): void public function test_skips_nodes_with_insufficient_disk(): void { $this->mockResourceService->shouldReceive('getLocationAvailability') - ->with(1) + ->with(1, null) ->andReturn([ 'nodes' => [ $this->createNodeData(1, 'Low Disk Node', [ @@ -241,7 +243,7 @@ public function test_skips_nodes_with_insufficient_disk(): void public function test_returns_null_when_no_nodes_available(): void { $this->mockResourceService->shouldReceive('getLocationAvailability') - ->with(1) + ->with(1, null) ->andReturn([ 'nodes' => [ $this->createNodeData(1, 'Full Node', [ @@ -272,7 +274,7 @@ public function test_returns_null_when_no_nodes_available(): void public function test_weighted_scoring_prefers_memory_headroom(): void { $this->mockResourceService->shouldReceive('getLocationAvailability') - ->with(1) + ->with(1, null) ->andReturn([ 'nodes' => [ // Node 1: Better disk/cpu but less memory headroom @@ -339,7 +341,7 @@ public function test_get_max_available_returns_location_max(): void public function test_empty_nodes_returns_null(): void { $this->mockResourceService->shouldReceive('getLocationAvailability') - ->with(1) + ->with(1, null) ->andReturn([ 'nodes' => [], 'max_available' => [], @@ -349,4 +351,135 @@ public function test_empty_nodes_returns_null(): void $this->assertNull($result); } + + public function test_requires_requested_number_of_free_allocations(): void + { + $this->mockResourceService->shouldReceive('getLocationAvailability') + ->with(1, null) + ->andReturn([ + 'nodes' => [ + $this->createNodeData(1, 'Node 1', [ + 'memory' => 16384, + 'cpu' => 800, + 'disk' => 102400, + ], [ + 'memory' => 8192, + 'cpu' => 400, + 'disk' => 51200, + ]), + ], + 'max_available' => [], + ]); + + $result = $this->service->selectBestNodeWithAllocations( + 1, + ['memory' => 4096, 'cpu' => 100, 'disk' => 20480], + allocationCount: 2 + ); + + $this->assertNull($result); + } + + public function test_explicit_port_requirement_is_part_of_node_eligibility(): void + { + $first = $this->createNodeData(1, 'Wrong Port', [ + 'memory' => 16384, + 'cpu' => 800, + 'disk' => 102400, + ], [ + 'memory' => 12288, + 'cpu' => 600, + 'disk' => 76800, + ]); + $second = $this->createNodeData(2, 'Requested Port', [ + 'memory' => 16384, + 'cpu' => 800, + 'disk' => 102400, + ], [ + 'memory' => 8192, + 'cpu' => 400, + 'disk' => 51200, + ]); + $second['available_allocations'][0]['port'] = 25570; + + $this->mockResourceService->shouldReceive('getLocationAvailability') + ->with(1, null) + ->andReturn([ + 'nodes' => [$first, $second], + 'max_available' => [], + ]); + + $result = $this->service->selectBestNodeWithAllocations( + 1, + ['memory' => 4096, 'cpu' => 100, 'disk' => 20480], + 1, + null, + [25570] + ); + + $this->assertSame(2, $result['node_id']); + } + + public function test_required_port_is_selected_even_when_later_in_inventory(): void + { + $node = $this->createNodeData(1, 'Node 1', [ + 'memory' => 16384, + 'cpu' => 800, + 'disk' => 102400, + ], [ + 'memory' => 8192, + 'cpu' => 400, + 'disk' => 51200, + ]); + $node['available_allocations'] = [ + ['id' => 100, 'ip' => '192.0.2.1', 'port' => 25565], + ['id' => 101, 'ip' => '192.0.2.1', 'port' => 25570], + ]; + $this->mockResourceService->shouldReceive('getLocationAvailability') + ->with(1, null) + ->andReturn(['nodes' => [$node], 'max_available' => []]); + + $result = $this->service->selectBestNodeWithAllocations( + 1, + ['memory' => 4096, 'cpu' => 100, 'disk' => 20480], + 1, + null, + [25570] + ); + + $this->assertSame([101], array_column($result['selected_allocations'], 'id')); + } + + public function test_duplicate_port_on_multiple_ips_selects_lowest_allocation_id(): void + { + $node = $this->createNodeData(1, 'Node 1', [ + 'memory' => 16384, + 'cpu' => 800, + 'disk' => 102400, + ], [ + 'memory' => 8192, + 'cpu' => 400, + 'disk' => 51200, + ]); + $node['available_allocations'] = [ + ['id' => 100, 'ip' => '192.0.2.1', 'port' => 25570], + ['id' => 101, 'ip' => '192.0.2.2', 'port' => 25570], + ]; + $this->mockResourceService->shouldReceive('getLocationAvailability') + ->with(1, null) + ->andReturn(['nodes' => [$node], 'max_available' => []]); + + $result = $this->service->selectBestNodeWithAllocations( + 1, + ['memory' => 4096, 'cpu' => 100, 'disk' => 20480], + 1, + null, + [25570] + ); + + $this->assertSame( + [100], + array_column($result['selected_allocations'], 'id') + ); + } } diff --git a/tests/Unit/ProductResourceConfigurationServiceTest.php b/tests/Unit/ProductResourceConfigurationServiceTest.php new file mode 100644 index 0000000..76d0635 --- /dev/null +++ b/tests/Unit/ProductResourceConfigurationServiceTest.php @@ -0,0 +1,542 @@ +dynamicProduct(); + $configuration = $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + + $this->assertSame(1, $configuration['location_id']); + $this->assertSame([ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + ], $configuration['resources']); + $this->assertSame($options['memory']->id, $configuration['sliders']['memory']['config_option_id']); + $this->assertSame(1, $configuration['allocation_count']); + $this->assertSame([], $configuration['required_ports']); + $this->assertSame([[ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => null, + 'is_primary' => true, + ]], $configuration['allocation_mappings']); + } + + public function test_decimal_and_off_step_values_are_rejected(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $base = [ + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]; + + foreach (['2048.5', 1536] as $forgedValue) { + try { + $this->service()->forQuote( + $product, + [$options['memory']->id => $forgedValue] + $base + ); + $this->fail('Expected forged slider value to be rejected.'); + } catch (InvalidResourceSelectionException $exception) { + $this->assertStringContainsString('memory', strtolower($exception->getMessage())); + } + } + } + + public function test_location_must_be_a_child_of_products_location_option(): void + { + [$product, $options] = $this->dynamicProduct(); + $foreignChild = ConfigOption::create([ + 'name' => 'Foreign', + 'env_variable' => '99', + 'type' => 'select', + 'sort' => 0, + 'hidden' => false, + 'upgradable' => false, + 'parent_id' => null, + ]); + + $this->expectException(InvalidResourceSelectionException::class); + + $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $foreignChild->id, + ]); + } + + public function test_hidden_location_is_not_quoteable(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $locationChild->update(['hidden' => true]); + + $this->expectException(InvalidResourceSelectionException::class); + + $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + } + + public function test_hidden_retired_root_options_are_ignored_and_cannot_be_submitted(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $retiredMemory = $this->slider( + $product, + 'Retired Memory', + 'memory', + 1024, + 65536, + 1024, + 8192 + ); + $retiredLocation = ConfigOption::create([ + 'name' => 'Location', + 'env_variable' => 'location', + 'type' => 'select', + 'sort' => 10, + 'hidden' => false, + 'upgradable' => false, + ]); + DB::table('config_option_products')->insert([ + 'product_id' => $product->id, + 'config_option_id' => $retiredLocation->id, + ]); + + // Simulate the checkout product being loaded before a wizard rerun + // retires obsolete options. The resolver must use current DB state. + $product->load('configOptions.children'); + $retiredMemory->update(['hidden' => true]); + $retiredLocation->update(['hidden' => true]); + + $activeSelection = [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]; + + $configuration = $this->service()->forQuote($product, $activeSelection); + + $this->assertSame(4096, $configuration['resources']['memory']); + $this->assertSame( + $options['memory']->id, + $configuration['sliders']['memory']['config_option_id'] + ); + $this->assertSame(1, $configuration['location_id']); + + foreach ([ + $retiredMemory->id => 8192, + $retiredLocation->id => $locationChild->id, + ] as $retiredOptionId => $value) { + try { + $this->service()->forQuote( + $product, + $activeSelection + [$retiredOptionId => $value] + ); + $this->fail('Expected a retired option submission to be rejected.'); + } catch (InvalidResourceSelectionException $exception) { + $this->assertStringContainsString( + 'not available', + strtolower($exception->getMessage()) + ); + } + } + } + + public function test_attached_child_option_cannot_be_submitted_as_a_root_option(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + DB::table('config_option_products')->insert([ + 'product_id' => $product->id, + 'config_option_id' => $locationChild->id, + ]); + + $this->expectException(InvalidResourceSelectionException::class); + $this->expectExceptionMessage('not available'); + + $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + $locationChild->id => 1, + ]); + } + + public function test_overflowing_selection_is_rejected_before_cast(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + + $this->expectException(InvalidResourceSelectionException::class); + + $this->service()->forQuote($product, [ + $options['memory']->id => '999999999999999999999999999999', + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + } + + public function test_non_empty_port_mapping_requires_server_port(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $product->settings()->create([ + 'key' => 'port_array', + 'value' => json_encode(['QUERY_PORT' => 25566]), + ]); + + $this->expectException( + InvalidStockConfigurationException::class + ); + $this->expectExceptionMessage('SERVER_PORT'); + + $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + } + + public function test_port_mapping_preserves_environment_and_primary_identity(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $product->settings()->create([ + 'key' => 'port_array', + 'value' => json_encode([ + 'SERVER_PORT' => 25570, + 'QUERY_PORT' => 25571, + 'NONE' => [25572, 25573], + ]), + ]); + + $configuration = $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + + $this->assertSame(4, $configuration['allocation_count']); + $this->assertSame( + [25570, 25571, 25572, 25573], + $configuration['required_ports'] + ); + $this->assertSame([ + [ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => 25570, + 'is_primary' => true, + ], + [ + 'environment_key' => 'QUERY_PORT', + 'requested_port' => 25571, + 'is_primary' => false, + ], + [ + 'environment_key' => 'NONE', + 'requested_port' => 25572, + 'is_primary' => false, + ], + [ + 'environment_key' => 'NONE', + 'requested_port' => 25573, + 'is_primary' => false, + ], + ], $configuration['allocation_mappings']); + } + + public function test_port_mapping_rejects_multiple_ports_for_one_egg_environment_key(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $product->settings()->create([ + 'key' => 'port_array', + 'value' => json_encode([ + 'SERVER_PORT' => 25570, + 'QUERY_PORT' => [25571, 25572], + ]), + ]); + + $this->expectException( + InvalidStockConfigurationException::class + ); + $this->expectExceptionMessage( + 'may assign exactly one port to QUERY_PORT' + ); + + $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + } + + public function test_additional_allocations_are_preclaimed_without_fake_environment_keys(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $product->settings()->create([ + 'key' => 'additional_allocations', + 'value' => '2', + ]); + + $configuration = $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + + $this->assertSame(3, $configuration['allocation_count']); + $this->assertSame([ + [ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => null, + 'is_primary' => true, + ], + [ + 'environment_key' => 'NONE', + 'requested_port' => null, + 'is_primary' => false, + ], + [ + 'environment_key' => 'NONE', + 'requested_port' => null, + 'is_primary' => false, + ], + ], $configuration['allocation_mappings']); + } + + public function test_dedicated_ip_and_port_ranges_are_strictly_normalized(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $product->settings()->create([ + 'key' => 'dedicated_ip', + 'value' => 'true', + ]); + $product->settings()->create([ + 'key' => 'port_range', + 'value' => json_encode([ + '25580-25590', + '25565', + '25566-25579', + ]), + ]); + + $configuration = $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + + $this->assertTrue($configuration['dedicated_ip']); + $this->assertSame([ + ['from' => 25565, 'to' => 25590], + ], $configuration['allowed_port_ranges']); + } + + public function test_port_array_cannot_silently_bypass_deployment_constraints(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $product->settings()->createMany([ + [ + 'key' => 'port_array', + 'value' => json_encode(['SERVER_PORT' => 25565]), + ], + ['key' => 'dedicated_ip', 'value' => '1'], + ]); + + $this->expectException( + InvalidStockConfigurationException::class + ); + $this->expectExceptionMessage('cannot combine'); + + $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + } + + public function test_static_node_and_cpu_pinning_cannot_bypass_dynamic_placement(): void + { + foreach ([ + ['key' => 'node', 'value' => '12', 'message' => 'must not be pinned'], + [ + 'key' => 'cpu_pinning', + 'value' => '0,2-3', + 'message' => 'CPU pinning', + ], + ] as $case) { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $product->settings()->create([ + 'key' => $case['key'], + 'value' => $case['value'], + ]); + + try { + $this->service()->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + $this->fail( + "Expected {$case['key']} to fail dynamic stock closed." + ); + } catch ( + InvalidStockConfigurationException + $exception + ) { + $this->assertStringContainsString( + $case['message'], + $exception->getMessage() + ); + } + } + } + + public function test_unconfirmed_exclusive_provisioning_control_fails_quote_configuration(): void + { + [$product, $options, $locationChild] = $this->dynamicProduct(); + $inventory = Mockery::mock(PterodactylInventoryService::class); + $inventory->shouldReceive('panelIdentity') + ->andReturn(hash('sha256', 'https://panel.example.com')); + $inventory->shouldReceive('assertExclusiveProvisioningControl') + ->once() + ->andThrow(new \RuntimeException('exclusive pool required')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('exclusive pool required'); + + (new ProductResourceConfigurationService($inventory))->forQuote($product, [ + $options['memory']->id => 4096, + $options['cpu']->id => 200, + $options['disk']->id => 51200, + $options['location']->id => $locationChild->id, + ]); + } + + private function service(): ProductResourceConfigurationService + { + $inventory = Mockery::mock(PterodactylInventoryService::class); + $inventory->shouldReceive('panelIdentity') + ->zeroOrMoreTimes() + ->andReturn(hash('sha256', 'https://panel.example.com')); + $inventory->shouldReceive('assertExclusiveProvisioningControl') + ->zeroOrMoreTimes(); + + return new ProductResourceConfigurationService($inventory); + } + + /** + * @return array{Product, array, ConfigOption} + */ + private function dynamicProduct(): array + { + $server = Server::create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $server->settings()->create([ + 'key' => 'host', + 'value' => 'https://panel.example.com/', + ]); + $product = Product::factory()->create([ + 'server_id' => $server->id, + 'hidden' => false, + ]); + + $options = [ + 'memory' => $this->slider($product, 'Memory', 'memory', 1024, 32768, 1024, 4096), + 'cpu' => $this->slider($product, 'CPU', 'cpu', 100, 1600, 100, 200), + 'disk' => $this->slider($product, 'Disk', 'disk', 10240, 512000, 10240, 51200), + ]; + $options['location'] = ConfigOption::create([ + 'name' => 'Location', + 'env_variable' => 'location', + 'type' => 'select', + 'sort' => 3, + 'hidden' => false, + 'upgradable' => false, + ]); + $locationChild = ConfigOption::create([ + 'name' => 'Melbourne', + 'env_variable' => '1', + 'type' => 'select', + 'sort' => 0, + 'hidden' => false, + 'upgradable' => false, + 'parent_id' => $options['location']->id, + ]); + DB::table('config_option_products')->insert([ + 'product_id' => $product->id, + 'config_option_id' => $options['location']->id, + ]); + + return [$product, $options, $locationChild]; + } + + private function slider( + Product $product, + string $name, + string $resource, + int $minimum, + int $maximum, + int $step, + int $default + ): ConfigOption { + $option = ConfigOption::create([ + 'name' => $name, + 'env_variable' => strtoupper($resource), + 'type' => 'dynamic_slider', + 'sort' => 0, + 'hidden' => false, + 'upgradable' => false, + 'metadata' => [ + 'resource_type' => $resource, + 'min' => $minimum, + 'max' => $maximum, + 'step' => $step, + 'default' => $default, + ], + ]); + DB::table('config_option_products')->insert([ + 'product_id' => $product->id, + 'config_option_id' => $option->id, + ]); + + return $option; + } +} diff --git a/tests/Unit/PterodactylInventoryServiceTest.php b/tests/Unit/PterodactylInventoryServiceTest.php new file mode 100644 index 0000000..6b8d3f5 --- /dev/null +++ b/tests/Unit/PterodactylInventoryServiceTest.php @@ -0,0 +1,811 @@ +inventory = new PterodactylInventoryService([ + 'pterodactyl_url' => 'https://panel.example.com/', + 'pterodactyl_api_key' => 'application-key', + 'exclusive_provisioning_control' => true, + ]); + } + + public function test_panel_identity_preserves_case_sensitive_path(): void + { + $inventory = new PterodactylInventoryService([ + 'pterodactyl_url' => 'HTTPS://Panel.Example.com:443/PanelA/', + 'pterodactyl_api_key' => 'application-key', + 'exclusive_provisioning_control' => true, + ]); + + $this->assertSame( + PanelEndpointIdentity::hash( + 'https://panel.example.com/PanelA' + ), + $inventory->panelIdentity() + ); + $this->assertNotSame( + PanelEndpointIdentity::hash( + 'https://panel.example.com/panela' + ), + $inventory->panelIdentity() + ); + } + + public function test_missing_credentials_fail_only_when_inventory_is_used(): void + { + $inventory = new PterodactylInventoryService([]); + + $this->assertFalse($inventory->hasExclusiveProvisioningControl()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'Pterodactyl inventory credentials are not configured.' + ); + + $inventory->panelIdentity(); + } + + public function test_nodes_are_paginated_and_location_is_filtered_locally(): void + { + Http::fake(function ($request) { + $query = []; + parse_str(parse_url($request->url(), PHP_URL_QUERY) ?? '', $query); + $this->assertArrayNotHasKey('filter', $query); + $this->assertSame('allocations', $query['include'] ?? null); + $this->assertSame('100', $query['per_page'] ?? null); + $page = (int) ($query['page'] ?? 1); + $data = $page === 1 + ? [$this->nodeResource(10, 1), $this->nodeResource(20, 2)] + : [$this->nodeResource(30, 1)]; + + return Http::response($this->paginatedPayload( + $data, + currentPage: $page, + total: 3, + perPage: 2 + )); + }); + + $nodes = $this->inventory->nodesInLocation(1); + + $this->assertSame([10, 30], array_column($nodes, 'id')); + $this->assertSame( + ['memory' => 1024, 'disk' => 2048], + $nodes[0]['allocated_resources'] + ); + $this->assertSame( + [10001], + array_column( + $this->inventory->availableAllocationsForNode(10), + 'id' + ) + ); + $this->assertSame( + [30001], + array_column( + $this->inventory->availableAllocationsForNode(30), + 'id' + ) + ); + Http::assertSentCount(2); + } + + public function test_last_page_alias_is_accepted_as_a_complete_termination_proof(): void + { + $payload = $this->paginatedPayload([ + $this->locationResource(1), + ]); + $payload['meta']['pagination']['last_page'] = + $payload['meta']['pagination']['total_pages']; + unset( + $payload['meta']['pagination']['count'], + $payload['meta']['pagination']['total_pages'] + ); + + Http::fake([ + '*' => Http::response($payload), + ]); + + $this->assertSame([1], array_column( + $this->inventory->locations(), + 'id' + )); + } + + public function test_missing_pagination_metadata_fails_inventory_closed(): void + { + Http::fake([ + '*' => Http::response([ + 'data' => [$this->locationResource(1)], + ]), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('invalid pagination metadata'); + + $this->inventory->locations(); + } + + public function test_incomplete_pagination_metadata_fails_inventory_closed(): void + { + $payload = $this->paginatedPayload([ + $this->locationResource(1), + ]); + unset($payload['meta']['pagination']['total']); + + Http::fake([ + '*' => Http::response($payload), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('pagination.total'); + + $this->inventory->locations(); + } + + public function test_pagination_rejects_a_skipped_first_page(): void + { + Http::fake([ + '*' => Http::response($this->paginatedPayload( + [$this->locationResource(1)], + currentPage: 2, + total: 2, + perPage: 1 + )), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('skipped, repeated, or returned an unexpected page'); + + $this->inventory->locations(); + } + + public function test_pagination_rejects_a_repeated_page(): void + { + Http::fakeSequence() + ->push($this->paginatedPayload( + [$this->locationResource(1)], + currentPage: 1, + total: 2, + perPage: 1 + )) + ->push($this->paginatedPayload( + [$this->locationResource(2)], + currentPage: 1, + total: 2, + perPage: 1 + )); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('skipped, repeated, or returned an unexpected page'); + + $this->inventory->locations(); + } + + public function test_pagination_rejects_changed_totals_between_pages(): void + { + Http::fakeSequence() + ->push($this->paginatedPayload( + [$this->locationResource(1)], + currentPage: 1, + total: 2, + perPage: 1 + )) + ->push($this->paginatedPayload( + [$this->locationResource(2)], + currentPage: 2, + total: 3, + perPage: 1 + )); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('metadata changed during the inventory read'); + + $this->inventory->locations(); + } + + public function test_pagination_rejects_duplicate_resources_across_pages(): void + { + Http::fakeSequence() + ->push($this->paginatedPayload( + [$this->locationResource(1)], + currentPage: 1, + total: 2, + perPage: 1 + )) + ->push($this->paginatedPayload( + [$this->locationResource(1)], + currentPage: 2, + total: 2, + perPage: 1 + )); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('duplicate resource'); + + $this->inventory->locations(); + } + + public function test_pagination_rejects_a_short_non_final_page(): void + { + Http::fake([ + '*' => Http::response($this->paginatedPayload( + [$this->locationResource(1)], + currentPage: 1, + total: 3, + perPage: 2 + )), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('page size is inconsistent'); + + $this->inventory->locations(); + } + + public function test_pagination_rejects_an_incorrect_advertised_count(): void + { + $payload = $this->paginatedPayload([ + $this->locationResource(1), + ]); + $payload['meta']['pagination']['count'] = 0; + + Http::fake([ + '*' => Http::response($payload), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('count does not match'); + + $this->inventory->locations(); + } + + public function test_pagination_rejects_conflicting_final_page_fields(): void + { + $payload = $this->paginatedPayload([ + $this->locationResource(1), + ]); + $payload['meta']['pagination']['last_page'] = 2; + + Http::fake([ + '*' => Http::response($payload), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('conflicting final pages'); + + $this->inventory->locations(); + } + + public function test_pagination_rejects_a_final_page_inconsistent_with_total(): void + { + $payload = $this->paginatedPayload([ + $this->locationResource(1), + ]); + $payload['meta']['pagination']['total_pages'] = 2; + + Http::fake([ + '*' => Http::response($payload), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('total is inconsistent'); + + $this->inventory->locations(); + } + + public function test_pagination_enforces_a_safe_page_limit_before_following_pages(): void + { + Http::fake([ + '*' => Http::response($this->paginatedPayload( + [$this->locationResource(1)], + currentPage: 1, + total: 1001, + perPage: 1 + )), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('safe page limit'); + + try { + $this->inventory->locations(); + } finally { + Http::assertSentCount(1); + } + } + + public function test_locations_use_stock_application_api_shape(): void + { + Http::fake([ + '*' => Http::response($this->paginatedPayload([[ + 'object' => 'location', + 'attributes' => [ + 'id' => 1, + 'short' => 'mel', + 'long' => 'Melbourne', + ], + ]])), + ]); + + $this->assertSame([[ + 'id' => 1, + 'short' => 'mel', + 'long' => 'Melbourne', + ]], $this->inventory->locations()); + } + + public function test_free_allocation_reports_when_its_ip_is_already_assigned(): void + { + $node = $this->nodeResource(10, 1); + $node['attributes']['relationships']['allocations']['data'][] = + $this->allocationResource(10002, true); + + Http::fake([ + '*' => Http::response($this->paginatedPayload([$node])), + ]); + + $allocations = $this->inventory->nodes()[0]['available_allocations']; + + $this->assertCount(1, $allocations); + $this->assertSame(10001, $allocations[0]['id']); + $this->assertTrue($allocations[0]['ip_in_use']); + } + + public function test_server_and_allocation_shapes_are_read_without_optimistic_defaults(): void + { + Http::fake(function ($request) { + $query = []; + parse_str(parse_url($request->url(), PHP_URL_QUERY) ?? '', $query); + $page = (int) ($query['page'] ?? 1); + + if (str_contains($request->url(), '/servers/external/paymenter-44')) { + return Http::response($this->serverResource( + 44, + 10, + memory: 4096, + cpu: 200, + disk: 20480, + allocation: 501 + )); + } + + if (str_contains($request->url(), '/api/application/users/44')) { + return Http::response([ + 'object' => 'user', + 'attributes' => [ + 'id' => 44, + 'external_id' => 'paymenter-user-30', + 'email' => 'customer@example.com', + ], + ]); + } + + if (str_contains($request->url(), '/api/application/servers')) { + $this->assertSame('allocations', $query['include'] ?? null); + $data = $page === 1 + ? [$this->serverResource(44, 10, 4096, 200, 20480, 501)] + : [$this->serverResource(45, 10, 2048, 100, 10240, 502)]; + + return Http::response($this->paginatedPayload( + $data, + currentPage: $page, + total: 2, + perPage: 1 + )); + } + + if (str_contains($request->url(), '/nodes/10/allocations')) { + $data = $page === 1 + ? [ + $this->allocationResource(501, true), + $this->allocationResource(502, false), + ] + : [$this->allocationResource(503, false)]; + + return Http::response($this->paginatedPayload( + $data, + currentPage: $page, + total: 3, + perPage: 2 + )); + } + + return Http::response([], 404); + }); + + $servers = $this->inventory->serversForNodes([10]); + $allocations = $this->inventory->availableAllocationsForNode(10); + $external = $this->inventory->serverByExternalId('paymenter-44'); + + $this->assertSame([ + 'id' => 44, + 'uuid' => '10000000-0000-4000-8000-000000000044', + 'identifier' => 'server-44', + 'external_id' => '44', + 'node' => 10, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + 'allocation_limit' => 1, + 'assigned_allocation_ids' => [501], + 'allocation_headroom' => 0, + ], $servers[10][0]); + $this->assertSame([44, 45], array_column($servers[10], 'id')); + $this->assertSame([502, 503], array_column($allocations, 'id')); + $this->assertSame(501, $external['allocation']); + $this->assertSame([501], $external['assigned_allocation_ids']); + $this->assertSame('paymenter-user-30', $external['user_external_id']); + $this->assertSame('customer@example.com', $external['user_email']); + $this->assertSame(1, $external['nest_id']); + $this->assertSame(2, $external['egg_id']); + } + + public function test_missing_overallocation_field_fails_inventory_closed(): void + { + $node = $this->nodeResource(10, 1); + unset($node['attributes']['memory_overallocate']); + + Http::fake([ + '*' => Http::response($this->paginatedPayload([$node])), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('memory_overallocate'); + + $this->inventory->nodes(); + } + + public function test_integer_overflow_is_rejected_instead_of_saturated(): void + { + $node = $this->nodeResource(10, 1); + $node['attributes']['memory'] = '999999999999999999999999999999'; + + Http::fake([ + '*' => Http::response($this->paginatedPayload([$node])), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('supported integer range'); + + $this->inventory->nodes(); + } + + public function test_customer_allocation_headroom_is_derived_from_assigned_relationship(): void + { + $server = $this->serverResource(44, 10, 4096, 200, 20480, 501); + $server['attributes']['feature_limits']['allocations'] = 2; + + Http::fake([ + '*' => Http::response($this->paginatedPayload([$server])), + ]); + + $inventory = $this->inventory->serversForNodes([10])[10][0]; + + $this->assertSame(2, $inventory['allocation_limit']); + $this->assertSame([501], $inventory['assigned_allocation_ids']); + $this->assertSame(1, $inventory['allocation_headroom']); + } + + public function test_missing_server_allocation_relationship_fails_inventory_closed(): void + { + $server = $this->serverResource(44, 10, 4096, 200, 20480, 501); + unset($server['attributes']['relationships']); + + Http::fake([ + '*' => Http::response($this->paginatedPayload([$server])), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('allocations relationship'); + + $this->inventory->serversForNodes([10]); + } + + public function test_official_nested_relationships_override_legacy_root_values(): void + { + $node = $this->nodeResource(10, 1); + $node['relationships']['allocations']['data'] = []; + $server = $this->serverResource(44, 10, 4096, 200, 20480, 501); + $server['relationships']['allocations']['data'] = []; + + Http::fake(function ($request) use ($node, $server) { + if (str_contains($request->url(), '/api/application/nodes')) { + return Http::response($this->paginatedPayload([$node])); + } + + return Http::response($this->paginatedPayload([$server])); + }); + + $this->assertSame( + [10001], + array_column( + $this->inventory->nodes()[0]['available_allocations'], + 'id' + ) + ); + $this->assertSame( + [501], + $this->inventory->serversForNodes([10])[10][0][ + 'assigned_allocation_ids' + ] + ); + } + + public function test_legacy_root_relationships_remain_an_explicit_fallback(): void + { + $node = $this->nodeResource(10, 1); + $node['relationships'] = $node['attributes']['relationships']; + unset($node['attributes']['relationships']); + $server = $this->serverResource(44, 10, 4096, 200, 20480, 501); + $server['relationships'] = $server['attributes']['relationships']; + unset($server['attributes']['relationships']); + + Http::fake(function ($request) use ($node, $server) { + if (str_contains($request->url(), '/api/application/nodes')) { + return Http::response($this->paginatedPayload([$node])); + } + + return Http::response($this->paginatedPayload([$server])); + }); + + $this->assertSame( + [10001], + array_column( + $this->inventory->nodes()[0]['available_allocations'], + 'id' + ) + ); + $this->assertSame( + [501], + $this->inventory->serversForNodes([10])[10][0][ + 'assigned_allocation_ids' + ] + ); + } + + public function test_malformed_official_relationship_is_not_masked_by_root_fallback(): void + { + $server = $this->serverResource(44, 10, 4096, 200, 20480, 501); + $server['relationships'] = $server['attributes']['relationships']; + $server['attributes']['relationships']['allocations']['data'] = + 'not-a-list'; + + Http::fake([ + '*' => Http::response($this->paginatedPayload([$server])), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('allocations relationship'); + + $this->inventory->serversForNodes([10]); + } + + public function test_missing_server_identity_fails_snapshot_proof_closed(): void + { + $server = $this->serverResource(44, 10, 4096, 200, 20480, 501); + unset($server['attributes']['uuid']); + + Http::fake([ + '*' => Http::response($this->paginatedPayload([$server])), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('server.uuid'); + + $this->inventory->serversForNodes([10]); + } + + public function test_connection_check_exercises_nodes_servers_and_allocations_permissions(): void + { + $paths = []; + $nodeIncludes = []; + + Http::fake(function ($request) use (&$paths, &$nodeIncludes) { + $paths[] = parse_url($request->url(), PHP_URL_PATH); + + if (str_ends_with(parse_url($request->url(), PHP_URL_PATH), '/nodes')) { + $query = []; + parse_str( + parse_url($request->url(), PHP_URL_QUERY) ?? '', + $query + ); + $nodeIncludes[] = $query['include'] ?? null; + + return Http::response($this->paginatedPayload([ + $this->nodeResource(10, 1), + ])); + } + if (str_ends_with(parse_url($request->url(), PHP_URL_PATH), '/servers')) { + return Http::response($this->paginatedPayload([])); + } + if (str_contains($request->url(), '/nodes/10/allocations')) { + return Http::response($this->paginatedPayload([ + $this->allocationResource(502, false), + ])); + } + + return Http::response([], 404); + }); + + $result = $this->inventory->testConnection(); + + $this->assertTrue($result['success']); + $this->assertContains('/api/application/nodes', $paths); + $this->assertContains('/api/application/servers', $paths); + $this->assertSame(['allocations'], $nodeIncludes); + $this->assertNotContains( + '/api/application/nodes/10/allocations', + $paths, + 'The included allocation snapshot must avoid an N+1 node scan.' + ); + } + + public function test_legacy_allocation_only_acknowledgement_is_not_a_capacity_guarantee(): void + { + $inventory = new PterodactylInventoryService([ + 'pterodactyl_url' => 'https://panel.example.com', + 'pterodactyl_api_key' => 'application-key', + 'exclusive_allocation_pool' => true, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('exclusive provisioning control'); + + $inventory->assertExclusiveProvisioningControl(); + } + + /** + * @param list> $data + * @return array + */ + private function paginatedPayload( + array $data, + int $currentPage = 1, + ?int $total = null, + int $perPage = 100 + ): array { + $total ??= count($data); + $totalPages = max( + 1, + intdiv($total, $perPage) + ($total % $perPage === 0 ? 0 : 1) + ); + + return [ + 'data' => $data, + 'meta' => [ + 'pagination' => [ + 'total' => $total, + 'count' => count($data), + 'per_page' => $perPage, + 'current_page' => $currentPage, + 'total_pages' => $totalPages, + 'links' => [], + ], + ], + ]; + } + + private function locationResource(int $id): array + { + return [ + 'object' => 'location', + 'attributes' => [ + 'id' => $id, + 'short' => 'loc-'.$id, + 'long' => 'Location '.$id, + ], + ]; + } + + private function nodeResource(int $id, int $locationId): array + { + return [ + 'object' => 'node', + 'attributes' => [ + 'id' => $id, + 'uuid' => sprintf('00000000-0000-4000-8000-%012d', $id), + 'name' => 'Node '.$id, + 'fqdn' => 'node-'.$id.'.example.com', + 'public' => true, + 'maintenance_mode' => false, + 'location_id' => $locationId, + 'memory' => 32768, + 'disk' => 512000, + 'memory_overallocate' => 0, + 'disk_overallocate' => 0, + 'allocated_resources' => [ + 'memory' => 1024, + 'disk' => 2048, + ], + 'relationships' => [ + 'allocations' => [ + 'data' => [ + $this->allocationResource( + ($id * 1000) + 1, + false + ), + ], + ], + ], + ], + ]; + } + + private function serverResource( + int $id, + int $node, + int $memory, + int $cpu, + int $disk, + int $allocation + ): array { + return [ + 'object' => 'server', + 'attributes' => [ + 'id' => $id, + 'uuid' => sprintf( + '10000000-0000-4000-8000-%012d', + $id + ), + 'identifier' => 'server-'.$id, + 'external_id' => (string) $id, + 'user' => 44, + 'nest' => 1, + 'egg' => 2, + 'node' => $node, + 'allocation' => $allocation, + 'feature_limits' => [ + 'databases' => 2, + 'allocations' => 1, + 'backups' => 3, + ], + 'limits' => [ + 'memory' => $memory, + 'cpu' => $cpu, + 'disk' => $disk, + 'swap' => 0, + 'io' => 500, + 'threads' => null, + ], + 'relationships' => [ + 'allocations' => [ + 'data' => [[ + 'object' => 'allocation', + 'attributes' => [ + 'id' => $allocation, + ], + ]], + ], + ], + ], + ]; + } + + private function allocationResource(int $id, bool $assigned): array + { + return [ + 'object' => 'allocation', + 'attributes' => [ + 'id' => $id, + 'ip' => '192.0.2.10', + 'port' => 25000 + $id, + 'assigned' => $assigned, + ], + ]; + } +} diff --git a/tests/Unit/QuoteRateLimiterServiceTest.php b/tests/Unit/QuoteRateLimiterServiceTest.php new file mode 100644 index 0000000..50798db --- /dev/null +++ b/tests/Unit/QuoteRateLimiterServiceTest.php @@ -0,0 +1,147 @@ + 'HTTPS://Panel.Example.com:443/PanelA/', + 'pterodactyl_api_key' => 'must-not-be-in-a-rate-limit-key', + ]); + (new QuoteRateLimiterService($configuration))->register(); + + $limiter = RateLimiter::limiter(QuoteRateLimiterService::NAME); + $this->assertIsCallable($limiter); + $limits = $limiter(Request::create( + '/api/dynamic-pterodactyl/products/1/resource-quote', + 'POST', + server: ['REMOTE_ADDR' => '192.0.2.44'] + )); + $identity = PanelEndpointIdentity::hash( + 'https://panel.example.com/PanelA' + ); + + $this->assertCount(2, $limits); + $this->assertSame( + QuoteRateLimitConfigurationService::DEFAULT_PER_IP, + $limits[0]->maxAttempts + ); + $this->assertSame( + QuoteRateLimitConfigurationService::DEFAULT_GLOBAL, + $limits[1]->maxAttempts + ); + $this->assertSame(60, $limits[0]->decaySeconds); + $this->assertSame(60, $limits[1]->decaySeconds); + $this->assertSame( + "dynamic-pterodactyl:quotes:ip:{$identity}:192.0.2.44", + $limits[0]->key + ); + $this->assertSame( + "dynamic-pterodactyl:quotes:panel:{$identity}", + $limits[1]->key + ); + $this->assertStringNotContainsString( + 'must-not-be-in-a-rate-limit-key', + $limits[0]->key.$limits[1]->key + ); + } + + public function test_configured_budgets_are_bounded_and_applied(): void + { + $configuration = new QuoteRateLimitConfigurationService([ + 'pterodactyl_url' => 'https://panel.example.com', + 'quote_rate_limit_per_ip' => '7', + 'quote_rate_limit_global' => 42, + ]); + (new QuoteRateLimiterService($configuration))->register(); + + $limits = RateLimiter::limiter(QuoteRateLimiterService::NAME)( + Request::create('/', 'POST') + ); + + $this->assertSame(7, $limits[0]->maxAttempts); + $this->assertSame(42, $limits[1]->maxAttempts); + } + + #[DataProvider('invalidConfiguration')] + public function test_invalid_persisted_configuration_fails_closed( + array $configuration, + string $message + ): void { + $service = new QuoteRateLimitConfigurationService([ + 'pterodactyl_url' => 'https://panel.example.com', + ...$configuration, + ]); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage($message); + + $service->configuration(); + } + + public static function invalidConfiguration(): array + { + return [ + 'zero per IP' => [[ + 'quote_rate_limit_per_ip' => 0, + ], 'per-IP quote rate limit must be between'], + 'excessive per IP' => [[ + 'quote_rate_limit_per_ip' => 61, + ], 'per-IP quote rate limit must be between'], + 'fractional global' => [[ + 'quote_rate_limit_global' => '1.5', + ], 'global quote rate limit must be a whole number'], + 'excessive global' => [[ + 'quote_rate_limit_global' => 241, + ], 'global quote rate limit must be between'], + ]; + } + + public function test_invalid_panel_url_fails_closed_before_a_key_is_built(): void + { + $service = new QuoteRateLimitConfigurationService([ + 'pterodactyl_url' => 'not a panel URL', + ]); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage('valid Pterodactyl panel URL'); + + $service->configuration(); + } + + public function test_named_limiter_is_wired_only_to_customer_quote_routes(): void + { + require __DIR__.'/../../routes/api.php'; + + $checkout = Route::getRoutes()->match(Request::create( + '/api/dynamic-pterodactyl/products/1/resource-quote', + 'POST' + )); + $upgrade = Route::getRoutes()->match(Request::create( + '/api/dynamic-pterodactyl/services/1/upgrade-quote', + 'POST' + )); + $admin = Route::getRoutes()->match(Request::create( + '/api/dynamic-pterodactyl/admin/capacity', + 'GET' + )); + $middleware = 'throttle:'.QuoteRateLimiterService::NAME; + + $this->assertContains($middleware, $checkout->gatherMiddleware()); + $this->assertContains($middleware, $upgrade->gatherMiddleware()); + $this->assertNotContains($middleware, $admin->gatherMiddleware()); + $this->assertContains('throttle:30,1', $admin->gatherMiddleware()); + } +} diff --git a/tests/Unit/ReservationConfigurationServiceTest.php b/tests/Unit/ReservationConfigurationServiceTest.php new file mode 100644 index 0000000..3cb9b1d --- /dev/null +++ b/tests/Unit/ReservationConfigurationServiceTest.php @@ -0,0 +1,235 @@ +setAccessible(true); + $service = new ReservationConfigurationService; + $canonical = $normalizer->invoke( + $service, + 'HTTPS://Panel.Example.com:443/PanelA/' + ); + $differentPath = $normalizer->invoke( + $service, + 'https://panel.example.com/panela' + ); + + $this->assertSame( + PanelEndpointIdentity::canonicalUrl( + 'https://panel.example.com/PanelA' + ), + $canonical + ); + $this->assertNotSame($canonical, $differentPath); + $this->assertSame( + '', + $normalizer->invoke( + $service, + 'https://panel.example.com/PanelA?token=secret' + ) + ); + } + + public function test_fingerprint_is_stable_for_equivalent_key_order(): void + { + $service = new ReservationConfigurationService; + + $left = [ + 'product_id' => 10, + 'resources' => ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], + 'node_id' => 3, + ]; + $right = [ + 'node_id' => 3, + 'resources' => ['disk' => 51200, 'cpu' => 200, 'memory' => 4096], + 'product_id' => 10, + ]; + + $this->assertSame($service->fingerprint($left), $service->fingerprint($right)); + } + + public function test_integral_float_fingerprint_survives_json_round_trip(): void + { + $service = new ReservationConfigurationService; + $payload = [ + 'config_options' => [ + ['id' => 1, 'value' => 4096.0], + ['id' => 2, 'value' => 1.5], + ], + ]; + $stored = json_encode($payload, JSON_THROW_ON_ERROR); + $reloaded = json_decode( + $stored, + true, + 512, + JSON_THROW_ON_ERROR + ); + + $this->assertStringNotContainsString('4096.0', $stored); + $this->assertSame( + $service->fingerprint($payload), + $service->fingerprint($reloaded) + ); + } + + public function test_resource_or_node_change_changes_fingerprint(): void + { + $service = new ReservationConfigurationService; + $snapshot = [ + 'customer_id' => 30, + 'cart_id' => 1, + 'server_extension_id' => 5, + 'panel_identity' => str_repeat('c', 64), + 'product_id' => 10, + 'plan_id' => 20, + 'quantity' => 1, + 'currency_code' => 'AUD', + 'location_id' => 2, + 'resources' => ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], + 'calculated_price' => '25.00', + 'pricing_version' => str_repeat('a', 64), + 'formula_version' => ReservationConfigurationService::FORMULA_VERSION, + 'config_options' => [], + 'allocation_requirements' => [ + 'required_count' => 1, + 'mappings' => [[ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => null, + 'is_primary' => true, + ]], + 'allowed_port_ranges' => [], + 'dedicated_ip' => false, + ], + 'provisioning_identity' => [ + 'nest_id' => 1, + 'egg_id' => 2, + 'user_external_id' => 'paymenter-user-30', + 'user_email' => 'customer@example.com', + ], + ]; + + $original = $service->fingerprint($service->withNode($snapshot, 3)); + + $moreMemory = $snapshot; + $moreMemory['resources']['memory'] = 8192; + + $this->assertNotSame( + $original, + $service->fingerprint($service->withNode($moreMemory, 3)) + ); + $this->assertNotSame( + $original, + $service->fingerprint($service->withNode($snapshot, 4)) + ); + + $guestSnapshot = $snapshot; + $guestSnapshot['customer_id'] = null; + $guestSnapshot['provisioning_identity']['user_external_id'] = null; + $guestSnapshot['provisioning_identity']['user_email'] = null; + $guestPayload = $service->withNode($guestSnapshot, 3); + $this->assertNotSame( + $service->fingerprint($guestPayload), + $service->fingerprint($service->withCustomer( + $guestPayload, + 30, + 'Customer@Example.com' + )) + ); + } + + public function test_bound_service_uses_snapshot_when_product_defaults_change(): void + { + $configuration = new ReservationConfigurationService; + $payload = [ + 'customer_id' => 30, + 'cart_id' => 1, + 'server_extension_id' => 5, + 'panel_identity' => str_repeat('c', 64), + 'product_id' => 10, + 'plan_id' => 20, + 'quantity' => 1, + 'currency_code' => 'AUD', + 'location_id' => 2, + 'node_id' => 3, + 'resources' => [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + ], + 'provisioning_identity' => [ + 'nest_id' => 1, + 'egg_id' => 2, + 'user_external_id' => 'paymenter-user-30', + 'user_email' => 'customer@example.com', + ], + 'config_options' => [], + 'allocations' => [[ + 'allocation_id' => 91, + 'ip' => '192.0.2.10', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ]], + ]; + $service = new Service; + $service->setRawAttributes([ + 'id' => 40, + 'user_id' => 30, + 'product_id' => 10, + 'plan_id' => 20, + 'quantity' => 1, + 'currency_code' => 'AUD', + ], true); + $service->exists = true; + $service->setRelation('user', new User([ + 'id' => 30, + 'email' => 'renamed@example.com', + ])); + // These mutable defaults deliberately disagree with the hold. They + // must not reprice or invalidate the already-promised capacity. + $service->setRelation('product', new Product([ + 'server_id' => 999, + 'memory' => 65536, + 'cpu' => 900, + 'disk' => 999999, + ])); + $reservation = (object) [ + 'service_id' => 40, + 'user_id' => 30, + 'server_extension_id' => 5, + 'panel_identity' => str_repeat('c', 64), + 'product_id' => 10, + 'plan_id' => 20, + 'quantity' => 1, + 'currency_code' => 'AUD', + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + 'location_id' => 2, + 'node_id' => 3, + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'configuration_fingerprint' => $configuration->fingerprint($payload), + ]; + + $configuration->assertServiceMatches($service, $reservation); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/ReservationServiceTest.php b/tests/Unit/ReservationServiceTest.php index 482546a..a256772 100644 --- a/tests/Unit/ReservationServiceTest.php +++ b/tests/Unit/ReservationServiceTest.php @@ -2,41 +2,66 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Tests\Unit; +use App\Enums\InvoiceTransactionStatus; +use App\Exceptions\DisplayException; +use App\Exceptions\PermanentProvisioningException; +use App\Helpers\ExtensionHelper; +use App\Jobs\Server\CreateJob; +use App\Jobs\Server\SuspendJob; +use App\Models\Cart; +use App\Models\CartItem; +use App\Models\ConfigOption; +use App\Models\Extension; +use App\Models\Invoice; +use App\Models\Plan; +use App\Models\Product; +use App\Models\Server; +use App\Models\Service; use App\Models\User; - -use Illuminate\Auth\Access\AuthorizationException; +use App\Services\Extensions\ExtensionLifecycleGuard; +use App\Services\Invoice\CancelInvoiceService; +use App\Services\Invoice\MarkInvoicePaidService; +use App\Services\Service\DurableFulfillmentService; +use App\Services\Service\FulfillmentStatusTransitionService; +use App\Services\Service\RenewServiceService; +use Carbon\Carbon; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Str; use Mockery; use Paymenter\Extensions\Others\DynamicPterodactyl\Models\ResourceReservation; -use Paymenter\Extensions\Others\DynamicPterodactyl\Policies\ResourceReservationPolicy; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\AuditLogService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\NodeSelectionService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ProductResourceConfigurationService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ReservationConfigurationService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ReservationService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\SchedulerHealthService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\SchedulerOperatorAlertService; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\LaravelTestCase; class ReservationServiceTest extends LaravelTestCase { use DatabaseTransactions; - private $mockNodeService; + private NodeSelectionService $nodeService; - private $mockAuditService; + private ReservationConfigurationService $configurationService; protected function setUp(): void { parent::setUp(); - Config::set('settings.debug', false); - Gate::policy(ResourceReservation::class, ResourceReservationPolicy::class); - - $this->mockNodeService = Mockery::mock(NodeSelectionService::class); - $this->mockAuditService = Mockery::mock(AuditLogService::class); + $this->nodeService = Mockery::mock(NodeSelectionService::class); + $this->configurationService = Mockery::mock(ReservationConfigurationService::class) + ->makePartial(); - $this->app->instance(AuditLogService::class, $this->mockAuditService); + $audit = Mockery::mock(AuditLogService::class); + $audit->shouldReceive('log')->zeroOrMoreTimes(); + $this->app->instance(AuditLogService::class, $audit); } protected function tearDown(): void @@ -45,841 +70,2925 @@ protected function tearDown(): void parent::tearDown(); } - /** - * Create ReservationService with mocked dependencies. - * Uses reflection to bypass constructor ExtensionHelper call. - */ - private function createService(): ReservationService + public function test_guest_holds_transfer_with_cart_ownership(): void { - $reflection = new \ReflectionClass(ReservationService::class); - $service = $reflection->newInstanceWithoutConstructor(); - - foreach ([ - 'nodeService' => $this->mockNodeService, - 'ttlMinutes' => 15, - ] as $property => $value) { - $instanceProperty = $reflection->getProperty($property); - $instanceProperty->setAccessible(true); - $instanceProperty->setValue($service, $value); - } - - return $service; + $user = User::withoutEvents(fn () => User::factory()->create()); + $reservationId = $this->insertReservation( + cartId: 71, + userId: null + ); + $payload = json_decode( + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('configuration_payload'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $payload['config_options'] = [[ + 'id' => 1, + 'value' => 4096.0, + ]]; + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'configuration_fingerprint' => $this->configurationService->fingerprint($payload), + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + ]); + $persisted = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->first(); + $persistedPayload = json_decode( + $persisted->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + $this->assertSame( + $this->configurationService->fingerprint($persistedPayload), + $persisted->configuration_fingerprint, + 'The immutable fingerprint must survive a JSON-column numeric round trip before ownership transfer.' + ); + + $updated = $this->service()->transferCartOwnership(71, $user->id); + + $this->assertSame(1, $updated); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'cart_id' => 71, + 'user_id' => $user->id, + 'status' => 'pending', + ]); + $reservation = DB::table('ptero_resource_reservations')->where('cart_id', 71)->first(); + $payload = json_decode($reservation->configuration_payload, true, 512, JSON_THROW_ON_ERROR); + $this->assertIsInt($payload['config_options'][0]['value']); + $this->assertSame($user->id, $payload['customer_id']); + $this->assertSame( + $this->configurationService->fingerprint($payload), + $reservation->configuration_fingerprint + ); } - public function test_safeAudit_logs_warning_on_failure(): void + public function test_cart_reservation_rejects_resource_slider_added_after_checkout_render(): void { - $this->mockAuditService->shouldReceive('log') + $product = $this->pterodactylProduct(); + $memory = $this->resourceOption('Memory', 'memory'); + $cpu = $this->resourceOption('CPU', 'cpu'); + DB::table('config_option_products')->insert([ + [ + 'product_id' => $product->id, + 'config_option_id' => $memory->id, + ], + [ + 'product_id' => $product->id, + 'config_option_id' => $cpu->id, + ], + ]); + $plan = Plan::factory()->create([ + 'priceable_id' => $product->id, + 'priceable_type' => Product::class, + 'type' => 'free', + 'billing_unit' => null, + 'billing_period' => 1, + ]); + $cart = new Cart([ + 'currency_code' => 'USD', + ]); + $cart->id = 71; + $cart->exists = true; + $cartItem = new CartItem([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'plan_id' => $plan->id, + 'quantity' => 1, + 'config_options' => [[ + 'option_id' => $memory->id, + 'value' => 4096, + ]], + ]); + $cartItem->setRelation('cart', $cart); + $cartItem->setRelation('product', $product); + $cartItem->setRelation('plan', $plan); + + $stockConfiguration = Mockery::mock( + ProductResourceConfigurationService::class + ); + $stockConfiguration->shouldReceive('forQuote') ->once() - ->with('created', 'reservation', 99, ['token_prefix' => 'deadbeef...']) - ->andThrow(new \RuntimeException('audit backend unavailable')); + ->andReturn([ + 'sliders' => [ + 'memory' => ['config_option_id' => $memory->id], + 'cpu' => ['config_option_id' => $cpu->id], + ], + ]); + $this->app->instance( + ProductResourceConfigurationService::class, + $stockConfiguration + ); + + $this->expectException(DisplayException::class); + $this->expectExceptionMessage( + 'Reload checkout and explicitly select every resource again.' + ); + + (new ReservationConfigurationService)->forCartItem($cartItem); + } - Log::shouldReceive('warning') - ->once() - ->with('extension audit write failed', Mockery::on(function (array $context) { - return $context['action'] === 'created' - && $context['entity_type'] === 'reservation' - && $context['entity_id'] === 99 - && $context['error'] === 'audit backend unavailable'; - })); - Log::shouldReceive('error')->zeroOrMoreTimes(); - - $service = new class - { - use \Paymenter\Extensions\Others\DynamicPterodactyl\Services\Concerns\AuditsExtensionActions; + public function test_only_one_pending_hold_may_use_a_cart_item_guard(): void + { + $this->insertReservation(cartItemGuardId: 42); - public function writeAudit(): void - { - $this->safeAudit('created', 'reservation', 99, ['token_prefix' => 'deadbeef...']); - } - }; + $this->expectException(QueryException::class); - $service->writeAudit(); + $this->insertReservation(cartItemGuardId: 42); + } - $this->addToAssertionCount(1); + public function test_only_one_active_checkout_commitment_may_use_a_service(): void + { + $service = $this->makeService(); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed' + ); + + $this->expectException(QueryException::class); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'confirmed' + ); } - /** - * Test that confirm updates status to confirmed. - */ - public function test_confirm_updates_status(): void - { - $token = 'test_token_123'; - $serviceId = 42; - - DB::shouldReceive('table') - ->with('ptero_resource_reservations') - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('token', $token) - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('status', 'pending') - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('expires_at', '>', Mockery::any()) - ->andReturnSelf(); - DB::shouldReceive('first')->once()->andReturn((object) ['id' => 99, 'token' => $token]); - DB::shouldReceive('update') - ->with(Mockery::on(function ($data) use ($serviceId) { - return $data['status'] === 'confirmed' - && $data['service_id'] === $serviceId - && isset($data['updated_at']); - })) - ->andReturn(1); - $this->mockAuditService->shouldReceive('log') - ->once() - ->with('reservation_confirmed', 'resource_reservation', 99, Mockery::on(fn ($ctx) => $ctx['token_prefix'] === substr($token, 0, 8) - && $ctx['service_id'] === $serviceId - && array_key_exists('node_id', $ctx) - && $ctx['node_id'] === null)) - ->andReturn(1); + public function test_only_one_active_upgrade_commitment_may_use_an_upgrade_guard(): void + { + $this->insertReservation( + status: 'paid_committed', + purpose: 'upgrade', + upgradeGuardId: 991 + ); + + $this->expectException(QueryException::class); + $this->insertReservation( + status: 'pending', + purpose: 'upgrade', + upgradeGuardId: 991 + ); + } - $service = $this->createService(); - $result = $service->confirm($token, $serviceId); + public function test_only_one_active_hold_may_claim_an_allocation(): void + { + $first = $this->insertReservation(); + $second = $this->insertReservation(); + $this->insertAllocation($first, 7001); - $this->assertTrue($result); + $this->expectException(QueryException::class); + $this->insertAllocation($second, 7001); } - /** - * Test that confirm returns false for non-existent token. - */ - public function test_confirm_returns_false_for_nonexistent_token(): void + public function test_admin_cancellation_rolls_back_status_when_allocation_release_fails(): void { - DB::shouldReceive('table') - ->with('ptero_resource_reservations') - ->andReturnSelf(); - DB::shouldReceive('where')->times(4)->andReturnSelf(); - DB::shouldReceive('first')->once()->andReturn(null); - DB::shouldReceive('update')->andReturn(0); + $reservationId = $this->insertReservation(); + $this->insertAllocation($reservationId); + $token = (string) DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('token'); + $service = new class($this->nodeService, $this->configurationService) extends ReservationService + { + protected function releaseAllocationClaims( + int $reservationId + ): void { + throw new \RuntimeException( + 'Injected allocation release failure.' + ); + } + }; - $service = $this->createService(); - $result = $service->confirm('nonexistent', 1); + try { + $service->cancel($token, 'Atomic cancellation regression'); + $this->fail('The injected claim release failure must abort cancellation.'); + } catch (\RuntimeException $exception) { + $this->assertSame( + 'Injected allocation release failure.', + $exception->getMessage() + ); + } - $this->assertFalse($result); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'pending', + ]); + $this->assertNull( + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->value('released_at') + ); } - /** - * Test that expired reservations cannot be confirmed. - */ - public function test_expired_reservation_cannot_be_confirmed(): void + public function test_admin_extension_rejects_minutes_outside_the_short_hold_limit(): void { - $token = 'expired_token_123'; + $reservationId = $this->insertReservation(); + $token = (string) DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('token'); + $originalExpiry = now()->addMinutes(15); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'expires_at' => $originalExpiry, + 'guaranteed_until' => $originalExpiry, + ]); + + foreach ([-1, 0, 61] as $minutes) { + try { + $this->service()->extend($token, $minutes); + $this->fail( + "Expected {$minutes} extension minutes to be rejected." + ); + } catch (\InvalidArgumentException $exception) { + $this->assertSame( + 'A reservation extension must be between 1 and 60 minutes.', + $exception->getMessage() + ); + } + } - DB::shouldReceive('table') - ->with('ptero_resource_reservations') - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('token', $token) - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('status', 'pending') - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('expires_at', '>', Mockery::any()) - ->andReturnSelf(); - DB::shouldReceive('first')->once()->andReturn((object) ['id' => 5, 'token' => $token]); - DB::shouldReceive('update')->andReturn(0); + $reservation = ResourceReservation::query()->findOrFail( + $reservationId + ); + $this->assertSame( + $originalExpiry->toDateTimeString(), + $reservation->expires_at->toDateTimeString() + ); + $this->assertSame( + $originalExpiry->toDateTimeString(), + $reservation->guaranteed_until->toDateTimeString() + ); + } - $service = $this->createService(); - $result = $service->confirm($token, 1); + public function test_admin_extension_is_capped_to_a_sixty_minute_short_hold_horizon(): void + { + Carbon::setTestNow('2026-07-27 12:00:00'); - $this->assertFalse($result); + try { + $reservationId = $this->insertReservation(); + $token = (string) DB::table( + 'ptero_resource_reservations' + ) + ->where('id', $reservationId) + ->value('token'); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'expires_at' => now()->addMinutes(50), + 'guaranteed_until' => now()->addMinutes(50), + ]); + + $this->assertTrue($this->service()->extend($token, 30)); + + $reservation = ResourceReservation::query()->findOrFail( + $reservationId + ); + $maximum = now()->addMinutes(60); + $this->assertTrue( + $reservation->expires_at->equalTo($maximum) + ); + $this->assertTrue( + $reservation->guaranteed_until->equalTo($maximum) + ); + } finally { + Carbon::setTestNow(); + } } - /** - * Test that cancel updates status to cancelled. - */ - public function test_cancel_updates_status(): void + public function test_admin_extension_cannot_modify_a_bound_guarantee(): void { - $token = 'test_token_123'; - - $mockReservation = (object) [ - 'id' => 1, - 'token' => $token, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'status' => 'pending', - ]; + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'currency_code' => 'USD', + ]); + $guaranteedUntil = now()->addDays(7); + $reservationId = $this->insertReservation( + serviceId: $service->id, + invoiceId: $invoice->id, + userId: $service->user_id, + guaranteedUntil: $guaranteedUntil + ); + $token = (string) DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('token'); + + $this->assertFalse($this->service()->extend($token, 15)); + + $reservation = ResourceReservation::query()->findOrFail( + $reservationId + ); + $this->assertSame($service->id, $reservation->service_id); + $this->assertSame($invoice->id, $reservation->invoice_id); + $this->assertSame( + $guaranteedUntil->toDateTimeString(), + $reservation->guaranteed_until->toDateTimeString() + ); + } - // First call for getByToken - DB::shouldReceive('table') - ->with('ptero_resource_reservations') - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('token', $token) - ->andReturnSelf(); - DB::shouldReceive('first') - ->once() - ->andReturn($mockReservation); - - // Second call for update - DB::shouldReceive('where') - ->with('status', 'pending') - ->andReturnSelf(); - DB::shouldReceive('update') - ->with(Mockery::on(function ($data) { - return $data['status'] === 'cancelled' - && isset($data['updated_at']); - })) - ->andReturn(1); - $this->mockAuditService->shouldReceive('log') - ->once() - ->with('reservation_cancelled', 'resource_reservation', 1, Mockery::on(fn ($ctx) => $ctx['token_prefix'] === substr($token, 0, 8) - && array_key_exists('node_id', $ctx) - && $ctx['node_id'] === null)) - ->andReturn(1); + public function test_admin_extension_locks_rechecks_and_conditionally_updates(): void + { + $source = file_get_contents( + __DIR__.'/../../Services/ReservationService.php' + ); + $methodStart = strpos($source, 'public function extend('); + $methodEnd = strpos( + $source, + 'public function getByToken(', + $methodStart + ); + $method = substr( + $source, + $methodStart, + $methodEnd - $methodStart + ); + + $this->assertOrderedSourceMarkers($method, [ + 'DB::transaction(', + '->lockForUpdate()', + 'ResourceReservation::STATUS_PENDING', + "->whereNull('service_id')", + "->whereNull('invoice_id')", + '->update([', + ]); + } - $service = $this->createService(); - $result = $service->cancel($token); + public function test_reservation_snapshot_is_built_under_the_configuration_lock(): void + { + $source = file_get_contents( + __DIR__.'/../../Services/ReservationService.php' + ); + $method = strpos( + $source, + 'public function reserveForCartItem' + ); + $transaction = strpos( + $source, + 'return DB::transaction', + $method + ); + $configurationLock = strpos( + $source, + '->lockProduct(', + $transaction + ); + $snapshot = strpos( + $source, + '->forCartItem($cartItem)', + $configurationLock + ); + $insert = strpos( + $source, + "DB::table('ptero_resource_reservations')->insertGetId", + $snapshot + ); + + $this->assertNotFalse($method); + $this->assertNotFalse($transaction); + $this->assertNotFalse($configurationLock); + $this->assertNotFalse($snapshot); + $this->assertNotFalse($insert); + $this->assertLessThan($configurationLock, $transaction); + $this->assertLessThan($snapshot, $configurationLock); + $this->assertLessThan($insert, $snapshot); + $this->assertFalse( + strpos( + substr($source, $method, $transaction - $method), + '->forCartItem(' + ), + 'No mutable configuration snapshot may be built before the transaction.' + ); + } - $this->assertTrue($result); + public function test_checkout_binding_uses_global_capacity_invoice_lock_order(): void + { + $source = file_get_contents( + __DIR__.'/../../Services/ReservationService.php' + ); + $method = strpos( + $source, + 'public function bindCartItemToService' + ); + $nextMethod = strpos( + $source, + 'public function preflightPaidService', + $method + ); + $body = substr($source, $method, $nextMethod - $method); + $invoice = strpos($body, '$lockedInvoice = Invoice::query()'); + $service = strpos($body, '$lockedService = Service::query()'); + $reservation = strpos( + $body, + "DB::table('ptero_resource_reservations')" + ); + $items = strpos($body, "DB::table('invoice_items')"); + + $this->assertNotFalse($invoice); + $this->assertNotFalse($service); + $this->assertNotFalse($reservation); + $this->assertNotFalse($items); + $this->assertLessThan($service, $invoice); + $this->assertLessThan($reservation, $service); + $this->assertLessThan($items, $reservation); } - /** - * Test that cancel returns false for non-existent token. - */ - public function test_cancel_returns_false_for_nonexistent_token(): void + public function test_active_commitment_blocks_extension_deactivation(): void { - DB::shouldReceive('table') - ->with('ptero_resource_reservations') - ->andReturnSelf(); - DB::shouldReceive('where')->andReturnSelf(); - DB::shouldReceive('first')->andReturn(null); + $this->insertReservation(); - $service = $this->createService(); - $result = $service->cancel('nonexistent'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot be disabled, replaced, or uninstalled'); - $this->assertFalse($result); + app(ExtensionLifecycleGuard::class) + ->assertCanDeactivate(ExtensionLifecycleGuard::DYNAMIC_PTERODACTYL); } - /** - * Test that cleanup expired marks pending reservations as expired. - */ - public function test_cleanup_expired_marks_pending_as_expired(): void - { - DB::shouldReceive('table') - ->with('ptero_resource_reservations') - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('status', 'pending') - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('expires_at', '<', Mockery::any()) - ->andReturnSelf(); - DB::shouldReceive('update') - ->with(Mockery::on(function ($data) { - return $data['status'] === 'expired' - && isset($data['updated_at']); - })) - ->andReturn(5); - $this->mockAuditService->shouldReceive('log') - ->once() - ->with('reservations_expired_batch', 'resource_reservation', 0, Mockery::on(fn ($ctx) => $ctx['count'] === 5 && isset($ctx['run_at']))) - ->andReturn(1); + public function test_terminal_history_does_not_block_extension_deactivation(): void + { + $this->insertReservation(status: 'cancelled'); - $service = $this->createService(); - $result = $service->cleanupExpired(); + app(ExtensionLifecycleGuard::class) + ->assertCanDeactivate(ExtensionLifecycleGuard::DYNAMIC_PTERODACTYL); - $this->assertEquals(5, $result); + $this->addToAssertionCount(1); } - /** - * Test getByToken returns reservation. - */ - public function test_get_by_token_returns_reservation(): void + public function test_confirmed_active_service_blocks_extension_deactivation(): void { - $token = 'test_token_123'; - $expected = (object) [ - 'id' => 1, - 'token' => $token, - 'status' => 'pending', - ]; + $service = $this->makeService(Service::STATUS_ACTIVE); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'confirmed', + consumedAt: now() + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'cannot be disabled, replaced, or uninstalled' + ); + + app(ExtensionLifecycleGuard::class) + ->assertCanDeactivate( + ExtensionLifecycleGuard::DYNAMIC_PTERODACTYL + ); + } - DB::shouldReceive('table') - ->with('ptero_resource_reservations') - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('token', $token) - ->andReturnSelf(); - DB::shouldReceive('first') - ->andReturn($expected); + public function test_confirmed_active_service_allows_same_identity_upgrade_only_in_maintenance(): void + { + $service = $this->makeService(Service::STATUS_ACTIVE); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'confirmed', + consumedAt: now() + ); + $guard = app(ExtensionLifecycleGuard::class); - $service = $this->createService(); - $result = $service->getByToken($token); + try { + $guard->assertCanUpgrade( + ExtensionLifecycleGuard::DYNAMIC_PTERODACTYL + ); + $this->fail( + 'Live durable services must require deployment maintenance.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'deployment maintenance', + $exception->getMessage() + ); + } - $this->assertEquals($expected, $result); + Artisan::call('down'); + try { + $guard->assertCanUpgrade( + ExtensionLifecycleGuard::DYNAMIC_PTERODACTYL + ); + $this->addToAssertionCount(1); + } finally { + Artisan::call('up'); + } } - /** - * Test getByCartItem returns pending reservation. - */ - public function test_get_by_cart_item_returns_pending_reservation(): void + public function test_pending_commitment_keeps_its_snapshot_while_future_slider_metadata_changes(): void { - $cartItemId = 42; - $expected = (object) [ - 'id' => 1, - 'cart_item_id' => $cartItemId, + $product = Product::factory()->create(); + $option = $this->resourceOption('Memory', 'memory'); + $option->products()->attach($product->id); + $reservationId = $this->insertReservation(productId: $product->id); + $originalPayload = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('configuration_payload'); + $originalFingerprint = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('configuration_fingerprint'); + + $metadata = $option->metadata; + $metadata['max'] = 200000; + $option->metadata = $metadata; + $option->save(); + + $this->assertSame(200000, $option->fresh()->metadata['max']); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, 'status' => 'pending', - ]; + 'configuration_payload' => $originalPayload, + 'configuration_fingerprint' => $originalFingerprint, + ]); + } - DB::shouldReceive('table') - ->with('ptero_resource_reservations') - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('cart_item_id', $cartItemId) - ->andReturnSelf(); - DB::shouldReceive('where') - ->with('status', 'pending') - ->andReturnSelf(); - DB::shouldReceive('first') - ->andReturn($expected); + public function test_pending_commitment_keeps_its_snapshot_while_future_child_price_changes(): void + { + $product = Product::factory()->create(); + $root = ConfigOption::create([ + 'name' => 'Location', + 'env_variable' => 'location', + 'type' => 'select', + 'hidden' => false, + 'upgradable' => false, + ]); + $root->products()->attach($product->id); + $child = ConfigOption::create([ + 'name' => 'Melbourne', + 'env_variable' => 'location', + 'type' => 'select', + 'hidden' => false, + 'upgradable' => false, + 'parent_id' => $root->id, + ]); + $plan = $child->plans()->create([ + 'name' => 'Monthly', + 'type' => 'recurring', + 'billing_period' => 1, + 'billing_unit' => 'month', + ]); + $price = $plan->prices()->create([ + 'currency_code' => 'USD', + 'price' => 5, + 'setup_fee' => 0, + ]); + $this->insertReservation(productId: $product->id); - $service = $this->createService(); - $result = $service->getByCartItem($cartItemId); + $price->price = 7; + $price->save(); - $this->assertEquals($expected, $result); + $this->assertEquals(7.0, (float) $price->fresh()->price); } - public function test_create_logs_audit_entry(): void + public function test_pending_commitment_prevents_detaching_its_configuration(): void { - $this->mockNodeService->shouldReceive('selectBestNode') - ->once() - ->andReturn(['node_id' => 1, 'name' => 'Node 1']); + $product = Product::factory()->create(); + $option = $this->resourceOption('Disk', 'disk'); + $option->products()->attach($product->id); + $this->insertReservation(productId: $product->id); - DB::shouldReceive('transaction') - ->once() - ->andReturnUsing(fn ($callback) => $callback()); + try { + $option->products()->detach($product->id); + $this->fail('Expected the product assignment to remain frozen.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'required by an unresolved or active capacity commitment', + $exception->getMessage() + ); + } - DB::shouldReceive('table') - ->with('ptero_resource_reservations') - ->andReturnSelf(); - DB::shouldReceive('where')->andReturnSelf(); - DB::shouldReceive('lockForUpdate')->andReturnSelf(); - DB::shouldReceive('get')->andReturn(collect([])); - DB::shouldReceive('insertGetId')->andReturn(42); + $this->assertDatabaseHas('config_option_products', [ + 'config_option_id' => $option->id, + 'product_id' => $product->id, + ]); + } - $this->mockAuditService->shouldReceive('log') - ->once() - ->with('created', 'reservation', 42, Mockery::type('array')) - ->andReturn(1); + public function test_pending_commitment_prevents_reassigning_resource_identity(): void + { + $product = Product::factory()->create(); + $option = $this->resourceOption('Memory', 'memory'); + $option->products()->attach($product->id); + $this->insertReservation(productId: $product->id); + + $option->env_variable = 'different_memory'; - $service = $this->createService(); - $result = $service->create(1, 1, ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], 10, 5); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'required by an unresolved or active capacity commitment' + ); - $this->assertEquals(42, $result['id']); - $this->assertSame(0.0, $result['pricing']['total']); - $this->assertSame([], $result['pricing']['breakdown']); - $this->assertSame('stored', $result['pricing']['model']); + $option->save(); } - public function test_confirm_logs_audit_entry_on_success(): void + public function test_pending_commitment_prevents_deleting_its_product(): void { - DB::shouldReceive('table')->with('ptero_resource_reservations')->andReturnSelf(); - DB::shouldReceive('where')->times(4)->andReturnSelf(); - DB::shouldReceive('first')->once()->andReturn((object) ['id' => 42, 'token' => 'test_token']); - DB::shouldReceive('update')->andReturn(1); + $product = Product::factory()->create(); + $this->insertReservation(productId: $product->id); - $this->mockAuditService->shouldReceive('log') - ->once() - ->with('reservation_confirmed', 'resource_reservation', 42, Mockery::on(fn ($ctx) => $ctx['token_prefix'] === 'test_tok' && $ctx['service_id'] === 42)) - ->andReturn(1); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'required by an unresolved or active capacity commitment' + ); - $service = $this->createService(); - $result = $service->confirm('test_token', 42); + $product->delete(); + } - $this->assertTrue($result); + public function test_confirmed_active_service_keeps_its_configuration_identity(): void + { + $product = Product::factory()->create(); + $option = $this->resourceOption('Disk', 'disk'); + $option->products()->attach($product->id); + $service = $this->makeService(Service::STATUS_ACTIVE); + DB::table('services')->where('id', $service->id)->update([ + 'product_id' => $product->id, + ]); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + productId: $product->id, + status: 'confirmed', + consumedAt: now() + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'required by an unresolved or active capacity commitment' + ); + + $option->products()->detach($product->id); } - public function test_confirm_skips_audit_on_state_drift(): void + public function test_confirmed_cancelled_service_releases_configuration_identity(): void { - DB::shouldReceive('table')->with('ptero_resource_reservations')->andReturnSelf(); - DB::shouldReceive('where')->times(4)->andReturnSelf(); - DB::shouldReceive('first')->once()->andReturn(null); - DB::shouldReceive('update')->andReturn(0); + $product = Product::factory()->create(); + $option = $this->resourceOption('Disk', 'disk'); + $option->products()->attach($product->id); + $service = $this->makeService(Service::STATUS_CANCELLED); + DB::table('services')->where('id', $service->id)->update([ + 'product_id' => $product->id, + ]); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + productId: $product->id, + status: 'confirmed', + consumedAt: now() + ); + + $option->products()->detach($product->id); + + $this->assertDatabaseMissing('config_option_products', [ + 'config_option_id' => $option->id, + 'product_id' => $product->id, + ]); + } - $this->mockAuditService->shouldReceive('log')->never(); + public function test_dynamic_slider_cannot_be_attached_to_product_with_unmigrated_live_service(): void + { + $product = $this->pterodactylProduct(); + $service = $this->makeService(Service::STATUS_ACTIVE); + DB::table('services')->where('id', $service->id)->update([ + 'product_id' => $product->id, + ]); + $option = $this->resourceOption('Memory', 'memory'); - $service = $this->createService(); - $result = $service->confirm('expired_token', 42); + try { + $option->products()->attach($product->id); + $this->fail('Expected legacy service conversion to be rejected.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'has no confirmed checkout reservation', + $exception->getMessage() + ); + } - $this->assertFalse($result); + $this->assertDatabaseMissing('config_option_products', [ + 'config_option_id' => $option->id, + 'product_id' => $product->id, + ]); } - public function test_extend_logs_audit_entry_on_success(): void + public function test_attached_option_cannot_be_activated_as_dynamic_for_unmigrated_live_service(): void { - DB::shouldReceive('table')->with('ptero_resource_reservations')->andReturnSelf(); - DB::shouldReceive('where')->times(3)->andReturnSelf(); - DB::shouldReceive('first')->once()->andReturn((object) ['id' => 42, 'token' => 'test_token']); - DB::shouldReceive('raw')->once()->andReturn('DATE_ADD_SQL'); - DB::shouldReceive('update')->andReturn(1); - - $this->mockAuditService->shouldReceive('log') - ->once() - ->with('reservation_extended', 'resource_reservation', 42, Mockery::on(fn ($ctx) => $ctx['additional_minutes'] === 15 - && $ctx['token_prefix'] === substr('test_token', 0, 8) - && array_key_exists('node_id', $ctx) - && $ctx['node_id'] === null)) - ->andReturn(1); + $product = $this->pterodactylProduct(); + $service = $this->makeService(Service::STATUS_ACTIVE); + DB::table('services')->where('id', $service->id)->update([ + 'product_id' => $product->id, + ]); + $option = ConfigOption::create([ + 'name' => 'Memory', + 'env_variable' => 'memory', + 'type' => 'number', + 'hidden' => false, + 'upgradable' => false, + 'metadata' => ['resource_type' => 'memory'], + ]); + $option->products()->attach($product->id); + $option->type = 'dynamic_slider'; - $service = $this->createService(); - $result = $service->extend('test_token', 15); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'has no confirmed checkout reservation' + ); - $this->assertTrue($result); + $option->save(); } - public function test_cleanup_expired_logs_batch_count(): void + public function test_product_with_dynamic_slider_cannot_switch_to_pterodactyl_with_unmigrated_live_service(): void { - DB::shouldReceive('table')->with('ptero_resource_reservations')->andReturnSelf(); - DB::shouldReceive('where')->times(2)->andReturnSelf(); - DB::shouldReceive('update')->andReturn(5); + $legacyHost = Server::query()->create([ + 'name' => 'Legacy host', + 'extension' => 'LegacyHost', + 'type' => 'server', + 'enabled' => true, + ]); + $pterodactyl = Server::query()->create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $product = Product::factory()->create([ + 'server_id' => $legacyHost->id, + ]); + $option = $this->resourceOption('Memory', 'memory'); + $option->products()->attach($product->id); + $service = $this->makeService(Service::STATUS_ACTIVE); + DB::table('services')->where('id', $service->id)->update([ + 'product_id' => $product->id, + ]); + $product->server_id = $pterodactyl->id; - $this->mockAuditService->shouldReceive('log') - ->once() - ->with('reservations_expired_batch', 'resource_reservation', 0, Mockery::on(fn ($ctx) => $ctx['count'] === 5 && isset($ctx['run_at']))) - ->andReturn(1); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage( + 'has no confirmed checkout reservation' + ); - $service = $this->createService(); - $result = $service->cleanupExpired(); + $product->save(); + } - $this->assertEquals(5, $result); + public function test_confirmed_service_allows_future_dynamic_slider_attachment(): void + { + $product = $this->pterodactylProduct(); + $service = $this->makeService(Service::STATUS_ACTIVE); + DB::table('services')->where('id', $service->id)->update([ + 'product_id' => $product->id, + ]); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'confirmed', + consumedAt: now(), + productId: $product->id + ); + $option = $this->resourceOption('CPU', 'cpu'); + + $option->products()->attach($product->id); + + $this->assertDatabaseHas('config_option_products', [ + 'config_option_id' => $option->id, + 'product_id' => $product->id, + ]); } - public function test_cancel_audits_with_source_admin(): void + public function test_cancelled_legacy_service_allows_dynamic_slider_attachment(): void { - $token = 'admin_cancel_token'; - $mockReservation = (object) ['id' => 5, 'token' => $token, 'memory' => 4096, 'cpu' => 200, 'disk' => 51200, 'status' => 'pending']; + $product = $this->pterodactylProduct(); + $service = $this->makeService(Service::STATUS_CANCELLED); + DB::table('services')->where('id', $service->id)->update([ + 'product_id' => $product->id, + ]); + $option = $this->resourceOption('Disk', 'disk'); + + $option->products()->attach($product->id); + + $this->assertDatabaseHas('config_option_products', [ + 'config_option_id' => $option->id, + 'product_id' => $product->id, + ]); + } - DB::shouldReceive('table')->with('ptero_resource_reservations')->andReturnSelf(); - DB::shouldReceive('where')->with('token', $token)->andReturnSelf(); - DB::shouldReceive('first')->once()->andReturn($mockReservation); - DB::shouldReceive('where')->with('status', 'pending')->andReturnSelf(); - DB::shouldReceive('update')->andReturn(1); + public function test_fulfilled_history_allows_future_configuration_changes(): void + { + $product = Product::factory()->create(); + $option = $this->resourceOption('CPU', 'cpu'); + $option->products()->attach($product->id); + $this->insertReservation( + productId: $product->id, + status: 'confirmed', + consumedAt: now() + ); + + $metadata = $option->metadata; + $metadata['max'] = 200000; + $option->metadata = $metadata; + $option->save(); + + $this->assertSame(200000, $option->fresh()->metadata['max']); + } - $this->mockAuditService->shouldReceive('log') + public function test_begin_keeps_paid_commitment_reserved_until_verified_completion(): void + { + $service = $this->makeService(); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed' + ); + $this->insertAllocation($reservationId); + + $this->configurationService->shouldReceive('assertServiceMatches') ->once() - ->with('reservation_cancelled', 'resource_reservation', 5, Mockery::on(fn ($ctx) => $ctx['token_prefix'] === substr($token, 0, 8))) - ->andReturn(1); + ->with($service, Mockery::on(fn ($row) => (int) $row->id === $reservationId)); - $service = $this->createService(); - $service->cancel($token, 'admin override', 'admin'); + $context = $this->service()->beginProvisioning($service); - $this->addToAssertionCount(1); + $this->assertSame(4, $context['node_id']); + $this->assertFalse($context['already_consumed']); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'paid_committed', + ]); + $this->assertNotNull( + DB::table('ptero_resource_reservations')->where('id', $reservationId)->value('provisioning_started_at') + ); + + $this->assertNotNull($context['provisioning_lease_id']); + $this->assertTrue( + $this->service()->completeProvisioning( + $service->id, + $context['provisioning_lease_id'], + $this->externalServer() + ) + ); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'confirmed', + ]); + $this->assertNotNull( + DB::table('ptero_resource_reservations')->where('id', $reservationId)->value('consumed_at') + ); + $this->assertSame('active', $service->fresh()->status); + $this->assertNotNull( + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->value('released_at') + ); } - public function test_cancel_audits_with_source_customer(): void + public function test_active_provisioning_lease_rejects_a_second_worker(): void { - $token = 'system_cancel_token'; - $mockReservation = (object) ['id' => 6, 'token' => $token, 'memory' => 4096, 'cpu' => 200, 'disk' => 51200, 'status' => 'pending']; + $service = $this->makeService(); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed', + provisioningStartedAt: now() + ); + $this->insertAllocation($reservationId); - DB::shouldReceive('table')->with('ptero_resource_reservations')->andReturnSelf(); - DB::shouldReceive('where')->with('token', $token)->andReturnSelf(); - DB::shouldReceive('first')->once()->andReturn($mockReservation); - DB::shouldReceive('where')->with('status', 'pending')->andReturnSelf(); - DB::shouldReceive('update')->andReturn(1); + $this->configurationService->shouldNotReceive('assertServiceMatches'); - $this->mockAuditService->shouldReceive('log') - ->once() - ->with('reservation_cancelled', 'resource_reservation', 6, Mockery::on(fn ($ctx) => $ctx['token_prefix'] === substr($token, 0, 8))) - ->andReturn(1); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('already being provisioned'); - $service = $this->createService(); - $service->cancel($token, null, 'customer'); + $this->service()->beginProvisioning($service); + } - $this->addToAssertionCount(1); + public function test_failed_provisioning_releases_only_the_attempt_lease(): void + { + $service = $this->makeService(); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed', + provisioningStartedAt: now(), + provisioningLeaseId: 'active-lease' + ); + + $this->service()->failProvisioning( + $service->id, + 'active-lease', + new \RuntimeException('Pterodactyl unavailable') + ); + + $reservation = DB::table('ptero_resource_reservations')->where('id', $reservationId)->first(); + $this->assertSame('paid_committed', $reservation->status); + $this->assertNull($reservation->provisioning_started_at); + $this->assertSame('Pterodactyl unavailable', $reservation->last_provisioning_error); + $this->assertNotNull($reservation->next_provisioning_attempt_at); } - public function test_audit_failure_does_not_break_confirm(): void + + public function test_stale_worker_cannot_consume_or_clear_a_newer_lease(): void { - $token = 'audit_fail_token'; - $mockReservation = (object) ['id' => 77, 'token' => $token]; + $service = $this->makeService(); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed', + provisioningStartedAt: now(), + provisioningLeaseId: 'new-lease' + ); + + try { + $this->service()->completeProvisioning( + $service->id, + 'old-lease', + $this->externalServer() + ); + $this->fail('Expected stale provisioning lease rejection.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('no longer owns', $exception->getMessage()); + } - DB::shouldReceive('table')->with('ptero_resource_reservations')->andReturnSelf(); - DB::shouldReceive('where')->andReturnSelf(); - DB::shouldReceive('first')->once()->andReturn($mockReservation); - DB::shouldReceive('update')->once()->andReturn(1); + $this->service()->failProvisioning( + $service->id, + 'old-lease', + new \RuntimeException('stale worker failed') + ); + + $reservation = DB::table('ptero_resource_reservations')->where('id', $reservationId)->first(); + $this->assertSame('paid_committed', $reservation->status); + $this->assertSame('new-lease', $reservation->provisioning_lease_id); + $this->assertNotNull($reservation->provisioning_started_at); + $this->assertNull($reservation->last_provisioning_error); + } - $this->mockAuditService->shouldReceive('log')->once() - ->andThrow(new \RuntimeException('audit db down')); + public function test_completed_reservation_is_idempotent_for_server_reconciliation(): void + { + $service = $this->makeService(); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'confirmed', + consumedAt: now() + ); + $reservationId = (int) DB::table('ptero_resource_reservations') + ->where('service_id', $service->id) + ->value('id'); + $this->insertAllocation($reservationId); + + $context = $this->service()->beginProvisioning($service); + + $this->assertTrue($context['already_consumed']); + $this->assertTrue($this->service()->completeProvisioning($service->id)); + } + + public function test_bound_invoice_commits_even_if_current_metadata_no_longer_creates_new_holds(): void + { + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'currency_code' => 'USD', + ]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + guaranteedUntil: now()->addDays(7) + ); + $this->insertAllocation($reservationId); + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => 12.50, + 'quantity' => 1, + 'description' => 'Dynamic service', + ]); - $service = $this->createService(); + $this->configurationService->shouldNotReceive('requiresReservation'); + $this->configurationService->shouldReceive('assertExclusiveProvisioningControl')->once(); + $this->configurationService->shouldReceive('assertServiceMatches')->once(); - $this->assertTrue($service->confirm($token, 42)); + $this->assertTrue($this->service()->commitPaidService($service, $invoice)); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'invoice_id' => $invoice->id, + 'status' => 'paid_committed', + ]); + $this->assertSame('provisioning', $service->fresh()->status); } - public function test_create_with_idempotency_key_returns_existing_on_duplicate(): void + public function test_allocation_drift_rejects_payment_before_capacity_is_committed(): void { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - $service = $this->createService(); + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'currency_code' => 'USD', + ]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + guaranteedUntil: now()->addDays(7) + ); + $this->insertAllocation($reservationId); + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->update(['port' => 25566]); + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => 12.50, + 'quantity' => 1, + 'description' => 'Dynamic service', + ]); - $this->mockNodeService->shouldReceive('selectBestNode') - ->once() - ->andReturn(['node_id' => 1, 'name' => 'Node 1']); - $this->mockAuditService->shouldReceive('log') - ->once() - ->with('created', 'reservation', Mockery::type('int'), Mockery::type('array')); + $this->configurationService->shouldNotReceive('requiresReservation'); + $this->configurationService + ->shouldReceive('assertExclusiveProvisioningControl') + ->once(); + $this->configurationService + ->shouldReceive('assertServiceMatches') + ->once(); - $first = $service->create(1, 1, $this->standardResources(), null, $user->id, 'dup-key-123'); - $second = $service->create(1, 1, $this->standardResources(), null, $user->id, 'dup-key-123'); + try { + $this->service()->commitPaidService($service, $invoice); + $this->fail( + 'Expected allocation drift to reject payment commitment.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'Allocation claims no longer match', + $exception->getMessage() + ); + } - $this->assertSame($first['id'], $second['id']); - $this->assertSame($first['token'], $second['token']); - $this->assertSame(1, ResourceReservation::count()); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'pending', + ]); } - public function test_create_with_idempotency_key_creates_new_after_original_cancelled(): void + public function test_non_dynamic_service_does_not_require_stock_control_gate(): void { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - $service = $this->createService(); + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + ]); + $this->configurationService->shouldReceive('requiresReservation') + ->once() + ->with((int) $service->product_id) + ->andReturnFalse(); + $this->configurationService->shouldNotReceive( + 'assertExclusiveProvisioningControl' + ); + + $this->assertFalse( + $this->service()->commitPaidService($service, $invoice) + ); + } - $this->mockNodeService->shouldReceive('selectBestNode') - ->twice() - ->andReturn(['node_id' => 1, 'name' => 'Node 1']); - $this->mockAuditService->shouldReceive('log') - ->twice() - ->with('created', 'reservation', Mockery::type('int'), Mockery::type('array')); + public function test_tampered_invoice_line_cannot_consume_reserved_capacity(): void + { + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'currency_code' => 'USD', + ]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + guaranteedUntil: now()->addDays(7) + ); + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => 0.01, + 'quantity' => 1, + 'description' => 'Tampered dynamic service', + ]); - $first = $service->create(1, 1, $this->standardResources(), null, $user->id, 'cancelled-key-123'); - ResourceReservation::query()->findOrFail($first['id'])->update(['status' => 'cancelled']); + $this->configurationService->shouldNotReceive('requiresReservation'); + $this->configurationService->shouldReceive('assertExclusiveProvisioningControl')->once(); + $this->configurationService->shouldNotReceive('assertServiceMatches'); - $second = $service->create(1, 1, $this->standardResources(), null, $user->id, 'cancelled-key-123'); + try { + $this->service()->commitPaidService($service, $invoice); + $this->fail('Expected invoice-line drift to reject the payment commitment.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'invoice line no longer matches', + $exception->getMessage() + ); + } - $this->assertNotSame($first['id'], $second['id']); - $this->assertSame(2, ResourceReservation::count()); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'pending', + ]); } - public function test_confirm_writes_audit_row(): void + public function test_payment_after_the_seven_day_guarantee_fails_closed(): void { - $this->app->instance(AuditLogService::class, new AuditLogService); - $actor = User::withoutEvents(fn () => User::factory()->create()); - $this->actingAs($actor); - - $serviceId = DB::table('services')->insertGetId([ - 'user_id' => $actor->id, - 'status' => 'active', + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, 'currency_code' => 'USD', + ]); + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => 12.50, 'quantity' => 1, - 'price' => '0.00', - 'created_at' => now(), - 'updated_at' => now(), + 'description' => 'Dynamic server', ]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + invoiceId: $invoice->id, + userId: $service->user_id, + guaranteedUntil: now()->subSecond() + ); - $reservation = ResourceReservation::create([ - 'token' => 'confirm_token_12345678', - 'user_id' => null, - 'cart_item_id' => null, - 'node_id' => 11, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'calculated_price' => 0, - 'pricing_breakdown' => [], + $this->configurationService->shouldNotReceive('requiresReservation'); + $this->configurationService->shouldReceive('assertExclusiveProvisioningControl')->once(); + $this->configurationService->shouldNotReceive('assertServiceMatches'); + + try { + $this->service()->commitPaidService($service, $invoice); + $this->fail('Expected the expired capacity guarantee to reject payment.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString('seven-day capacity guarantee expired', $exception->getMessage()); + } + + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, 'status' => 'pending', - 'expires_at' => now()->addMinutes(15), ]); + $this->assertSame('pending', $service->fresh()->status); + } - $this->assertTrue($this->createService()->confirm($reservation->token, $serviceId)); + public function test_completion_requires_a_durable_external_identity(): void + { + $service = $this->makeService(); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed', + provisioningStartedAt: now(), + provisioningLeaseId: 'lease' + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('complete external server identity'); - $this->assertDatabaseHas('ptero_audit_logs', [ - 'action' => 'reservation_confirmed', - 'entity_type' => 'resource_reservation', - 'entity_id' => $reservation->id, + try { + $this->service()->completeProvisioning($service->id, 'lease', ['attributes' => ['id' => 8]]); + } finally { + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'paid_committed', + ]); + $this->assertSame('pending', $service->fresh()->status); + } + } + + public function test_cancellation_tombstone_wins_after_external_create(): void + { + $service = $this->makeService(); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed', + provisioningStartedAt: now(), + provisioningLeaseId: 'lease', + cancellationRequestedAt: now() + ); + $this->insertAllocation($reservationId); + + $this->assertFalse( + $this->service()->completeProvisioning( + $service, + 'lease', + $this->externalServer() + ) + ); + + $this->assertSame('cancellation_pending', $service->fresh()->status); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'paid_committed', + 'external_server_id' => 71, ]); + } - $log = DB::table('ptero_audit_logs')->where('action', 'reservation_confirmed')->latest('id')->first(); - $newValues = json_decode($log->new_values, true); + public function test_unpinned_paid_cancellation_exposes_only_the_signed_checkout_contract(): void + { + $service = $this->makeService('provisioning'); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed' + ); + $this->insertAllocation($reservationId); + $this->service()->requestServiceCancellation($service); + + $context = $this->service() + ->cancellationReconciliationContext($service); + + $this->assertSame($reservationId, $context['reservation_id']); + $this->assertSame( + (string) $service->id, + $context['external_server_external_id'] + ); + $this->assertSame( + "paymenter-user-{$service->user_id}", + $context['user_external_id'] + ); + $this->assertSame(4, $context['node_id']); + $this->assertSame(4096, $context['memory']); + $this->assertSame(200, $context['cpu']); + $this->assertSame(51200, $context['disk']); + $this->assertSame(0, $context['client_allocation_limit']); + $this->assertFalse($context['provisioning_in_flight']); + $this->assertSame([7001], array_column( + $context['allocations'], + 'allocation_id' + )); + $this->assertMatchesRegularExpression( + '/^[a-f0-9]{64}$/', + $context['configuration_fingerprint'] + ); + } - $this->assertSame(substr($reservation->token, 0, 8), $newValues['token_prefix']); - $this->assertSame($serviceId, $newValues['service_id']); - $this->assertSame(11, $newValues['node_id']); + public function test_absent_unpinned_server_completes_paid_cancellation(): void + { + $service = $this->makeService('provisioning'); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed' + ); + $this->insertAllocation($reservationId); + $this->service()->requestServiceCancellation($service); + + $this->assertTrue( + $this->service()->completeServiceCancellation($service) + ); + + $this->assertSame( + Service::STATUS_CANCELLED, + $service->fresh()->status + ); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'cancelled', + ]); + $this->assertNotNull( + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->value('released_at') + ); } - public function test_cancel_writes_audit_row(): void + public function test_paid_cancellation_context_marks_an_active_create_race(): void { - $this->app->instance(AuditLogService::class, new AuditLogService); - $actor = User::withoutEvents(fn () => User::factory()->create()); - $this->actingAs($actor); + $service = $this->makeService('provisioning'); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed', + provisioningStartedAt: now(), + provisioningLeaseId: 'active-create' + ); + $this->insertAllocation($reservationId); + $this->service()->requestServiceCancellation($service); + + $context = $this->service() + ->cancellationReconciliationContext($service); + + $this->assertTrue($context['provisioning_in_flight']); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('still in flight'); + + $this->service()->pinCancellationServerIdentity( + $service, + $this->cancellationServer($service->id), + 44 + ); + } - $reservation = ResourceReservation::create([ - 'token' => 'cancel_token_12345678', - 'user_id' => null, - 'cart_item_id' => null, - 'node_id' => 12, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'calculated_price' => 0, - 'pricing_breakdown' => [], - 'status' => 'pending', - 'expires_at' => now()->addMinutes(15), + public function test_timed_out_create_identity_is_pinned_idempotently_before_cancellation(): void + { + $service = $this->makeService('provisioning'); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed' + ); + $this->insertAllocation($reservationId); + $this->service()->requestServiceCancellation($service); + $server = $this->cancellationServer($service->id); + + $first = $this->service()->pinCancellationServerIdentity( + $service, + $server, + 44 + ); + $second = $this->service()->pinCancellationServerIdentity( + $service, + $server, + 44 + ); + + $this->assertSame(71, $first['external_server_id']); + $this->assertSame($first, $second); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'paid_committed', + 'external_server_id' => 71, + 'external_user_id' => 44, + 'external_server_identifier' => 'created', ]); + } - $this->assertTrue($this->createService()->cancel($reservation->token)); + public function test_mismatched_timed_out_create_is_never_pinned(): void + { + $service = $this->makeService('provisioning'); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed' + ); + $this->insertAllocation($reservationId); + $this->service()->requestServiceCancellation($service); + $server = $this->cancellationServer($service->id); + $server['attributes']['limits']['disk']++; - $this->assertDatabaseHas('ptero_audit_logs', [ - 'action' => 'reservation_cancelled', - 'entity_type' => 'resource_reservation', - 'entity_id' => $reservation->id, - ]); + try { + $this->service()->pinCancellationServerIdentity( + $service, + $server, + 44 + ); + $this->fail('Expected mismatched server rejection.'); + } catch (PermanentProvisioningException $exception) { + $this->assertStringContainsString( + 'reserved disk', + $exception->getMessage() + ); + } - $log = DB::table('ptero_audit_logs')->where('action', 'reservation_cancelled')->latest('id')->first(); - $newValues = json_decode($log->new_values, true); + $this->assertNull( + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('external_server_id') + ); + } - $this->assertSame(substr($reservation->token, 0, 8), $newValues['token_prefix']); - $this->assertSame(12, $newValues['node_id']); + public function test_unpaid_cancellation_returns_product_stock_exactly_once(): void + { + [$service, $product] = $this->makeStockedService(stock: 4); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id + ); + $this->insertAllocation($reservationId); + + $this->assertTrue($this->service()->requestServiceCancellation($service)); + $this->assertSame(5, $product->fresh()->stock); + $this->assertTrue( + app(DurableFulfillmentService::class) + ->cancellationIsDurablyComplete($service) + ); + $this->assertNotNull( + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('product_stock_released_at') + ); + + $this->assertFalse($this->service()->requestServiceCancellation($service)); + $this->assertSame(5, $product->fresh()->stock); } - public function test_cleanup_expired_writes_batch_audit_row_with_count(): void + public function test_paid_cancellation_returns_stock_only_after_external_absence(): void { - $this->app->instance(AuditLogService::class, new AuditLogService); - $actor = User::withoutEvents(fn () => User::factory()->create()); - $this->actingAs($actor); + [$service, $product] = $this->makeStockedService( + stock: 4, + status: Service::STATUS_ACTIVE + ); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'confirmed', + consumedAt: now() + ); + + $this->assertTrue($this->service()->requestServiceCancellation($service)); + $this->assertSame(4, $product->fresh()->stock); + $this->assertFalse( + app(DurableFulfillmentService::class) + ->cancellationIsDurablyComplete($service) + ); + $this->assertNull( + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('product_stock_released_at') + ); + + $this->assertTrue($this->service()->completeServiceCancellation($service)); + $this->assertTrue($this->service()->completeServiceCancellation($service)); + $this->assertSame(5, $product->fresh()->stock); + $this->assertTrue( + app(DurableFulfillmentService::class) + ->cancellationIsDurablyComplete($service) + ); + } - ResourceReservation::create([ - 'token' => 'expired_a_token_1234', - 'user_id' => null, - 'cart_item_id' => null, - 'node_id' => 21, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'calculated_price' => 0, - 'pricing_breakdown' => [], - 'status' => 'pending', - 'expires_at' => now()->subMinute(), + public function test_failed_external_cancellation_keeps_product_stock_committed(): void + { + [$service, $product] = $this->makeStockedService( + stock: 4, + status: Service::STATUS_ACTIVE + ); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'confirmed', + consumedAt: now() + ); + $this->service()->requestServiceCancellation($service); + + $this->service()->recordPermanentCancellationFailure( + $service, + new \RuntimeException('Pterodactyl delete failed') + ); + + $this->assertSame(4, $product->fresh()->stock); + $this->assertNull( + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('product_stock_released_at') + ); + } + + public function test_invoice_cancellation_atomically_releases_checkout_obligations(): void + { + [$service, $product] = $this->makeStockedService(4); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'currency_code' => 'USD', ]); + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => 12.50, + 'quantity' => 1, + 'description' => 'Dynamic server', + ]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + invoiceId: $invoice->id, + userId: $service->user_id + ); + $this->insertAllocation($reservationId); + + app(CancelInvoiceService::class)->handle($invoice); + + $this->assertSame(Invoice::STATUS_CANCELLED, $invoice->fresh()->status); + $this->assertSame(Service::STATUS_CANCELLED, $service->fresh()->status); + $this->assertSame(5, $product->fresh()->stock); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'cancelled', + ]); + $this->assertNotNull( + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->value('released_at') + ); + } - ResourceReservation::create([ - 'token' => 'expired_b_token_1234', - 'user_id' => null, - 'cart_item_id' => null, - 'node_id' => 22, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'calculated_price' => 0, - 'pricing_breakdown' => [], - 'status' => 'pending', - 'expires_at' => now()->subMinute(), + public function test_capacity_invoice_cannot_be_raw_cancelled_or_deleted(): void + { + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, ]); + $this->insertReservation( + serviceId: $service->id, + invoiceId: $invoice->id, + userId: $service->user_id + ); + + try { + $invoice->update(['status' => Invoice::STATUS_CANCELLED]); + $this->fail('Expected raw capacity invoice cancellation to fail.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'fulfillment coordinator', + $exception->getMessage() + ); + } + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot be deleted'); + $invoice->delete(); + } + + public function test_active_allocation_cannot_be_claimed_twice(): void + { + $first = $this->insertReservation(); + $second = $this->insertReservation(); + $this->insertAllocation($first, allocationId: 7001); + + $this->expectException(QueryException::class); + $this->insertAllocation($second, allocationId: 7001); + } + + public function test_dynamic_service_status_cannot_bypass_fulfillment_state_machine(): void + { + $service = $this->makeService(); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed' + ); + $service->status = 'active'; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('fulfillment state machine'); + + $service->save(); + } + + public function test_reservation_identity_guard_survives_mutable_product_classification(): void + { + $service = $this->makeService(); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id + ); + + $service->currency_code = 'AUD'; - $this->assertSame(2, $this->createService()->cleanupExpired()); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('capacity-aware fulfillment coordinator'); - $this->assertDatabaseHas('ptero_audit_logs', [ - 'action' => 'reservations_expired_batch', - 'entity_type' => 'resource_reservation', - 'entity_id' => 0, + $service->save(); + } + + public function test_reserved_panel_host_cannot_change_while_commitment_is_live(): void + { + $server = Server::query()->create([ + 'name' => 'Reserved Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, ]); + $host = $server->settings()->create([ + 'key' => 'host', + 'value' => 'https://panel.example.com', + 'type' => 'string', + 'encrypted' => false, + ]); + $reservationId = $this->insertReservation(); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update(['server_extension_id' => $server->id]); + + $host->value = 'https://different-panel.example.com'; - $log = DB::table('ptero_audit_logs')->where('action', 'reservations_expired_batch')->latest('id')->first(); - $newValues = json_decode($log->new_values, true); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('pinned by active services or upgrade commitments'); - $this->assertSame(2, $newValues['count']); - $this->assertArrayHasKey('run_at', $newValues); + $host->save(); } - public function test_create_without_idempotency_key_always_creates_new(): void + public function test_exclusive_provisioning_control_cannot_change_while_commitment_is_live(): void { - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - $service = $this->createService(); + $extension = Extension::query()->firstOrCreate( + [ + 'extension' => ExtensionLifecycleGuard::DYNAMIC_PTERODACTYL, + 'type' => 'other', + ], + [ + 'name' => 'Dynamic Pterodactyl', + 'enabled' => true, + ] + ); + $control = $extension->settings()->firstOrCreate( + ['key' => 'exclusive_provisioning_control'], + [ + 'value' => true, + 'type' => 'boolean', + 'encrypted' => false, + ] + ); + if (! (bool) $control->value) { + $control->value = true; + $control->save(); + } + $this->insertReservation(); - $this->mockNodeService->shouldReceive('selectBestNode') - ->twice() - ->andReturn(['node_id' => 1, 'name' => 'Node 1']); - $this->mockAuditService->shouldReceive('log') - ->twice() - ->with('created', 'reservation', Mockery::type('int'), Mockery::type('array')); + $control->value = false; - $first = $service->create(1, 1, $this->standardResources(), null, $user->id); - $second = $service->create(1, 1, $this->standardResources(), null, $user->id); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot be disabled, replaced, or uninstalled'); - $this->assertNotSame($first['id'], $second['id']); - $this->assertSame(2, ResourceReservation::count()); + $control->save(); } + public function test_stock_control_setting_cannot_be_deleted_while_commitment_is_live(): void + { + $extension = Extension::query()->firstOrCreate( + [ + 'extension' => ExtensionLifecycleGuard::DYNAMIC_PTERODACTYL, + 'type' => 'other', + ], + [ + 'name' => 'Dynamic Pterodactyl', + 'enabled' => true, + ] + ); + $control = $extension->settings()->firstOrCreate( + ['key' => 'exclusive_provisioning_control'], + [ + 'value' => true, + 'type' => 'boolean', + 'encrypted' => false, + ] + ); + $this->insertReservation(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot be disabled, replaced, or uninstalled'); + + $control->delete(); + } - private function createReservation(int $userId, string $token): ResourceReservation + public function test_resource_property_create_update_and_delete_bypasses_are_blocked(): void { - return ResourceReservation::create([ - 'token' => $token, - 'user_id' => $userId, - 'cart_item_id' => null, - 'node_id' => 1, - 'location_id' => 1, - 'memory' => 4096, - 'cpu' => 200, - 'disk' => 51200, - 'calculated_price' => 0, - 'pricing_breakdown' => [], - 'status' => 'pending', - 'expires_at' => now()->addMinutes(15), + $service = $this->makeService(); + $update = $service->properties()->create([ + 'key' => 'memory', + 'value' => 4096, + ]); + $delete = $service->properties()->create([ + 'key' => 'disk', + 'value' => 51200, ]); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id + ); + + $operations = [ + fn () => $service->properties()->create([ + 'key' => 'cpu', + 'value' => 300, + ]), + function () use ($update): void { + $update->value = 8192; + $update->save(); + }, + fn () => $delete->delete(), + ]; + foreach ($operations as $operation) { + try { + $operation(); + $this->fail('Expected resource property mutation to be blocked.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'capacity-aware fulfillment coordinator', + $exception->getMessage() + ); + } + } } - // ─── Commit-3: actor-aware authorization (cancel) ────────────────────────── + public function test_resource_config_create_update_and_delete_bypasses_are_blocked(): void + { + $service = $this->makeService(); + $memory = $this->resourceOption('Memory', 'memory'); + $cpu = $this->resourceOption('CPU', 'cpu'); + $disk = $this->resourceOption('Disk', 'disk'); + $update = $service->configs()->create([ + 'config_option_id' => $memory->id, + 'config_value_id' => null, + 'slider_value' => 4096, + ]); + $delete = $service->configs()->create([ + 'config_option_id' => $disk->id, + 'config_value_id' => null, + 'slider_value' => 51200, + ]); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id + ); + + $operations = [ + fn () => $service->configs()->create([ + 'config_option_id' => $cpu->id, + 'config_value_id' => null, + 'slider_value' => 300, + ]), + function () use ($update): void { + $update->slider_value = 8192; + $update->save(); + }, + fn () => $delete->delete(), + ]; + foreach ($operations as $operation) { + try { + $operation(); + $this->fail('Expected resource config mutation to be blocked.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'capacity-aware fulfillment coordinator', + $exception->getMessage() + ); + } + } + + FulfillmentStatusTransitionService::run( + $service, + function () use ($update): void { + $update->slider_value = 8192; + $update->save(); + } + ); + $this->assertSame(8192.0, (float) $update->fresh()->slider_value); + } - public function test_cancel_throws_when_actor_does_not_own_reservation(): void + public function test_reservation_backed_service_cannot_be_hard_deleted(): void { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $stranger = User::withoutEvents(fn () => User::factory()->create()); + $service = $this->makeService(); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'confirmed' + ); - $this->createReservation($owner->id, 'tok-cancel-deny'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cannot be hard deleted'); - $this->mockAuditService->shouldReceive('log')->never(); + $service->delete(); + } + + public function test_exact_guarantee_boundary_expires_order_and_blocks_payment(): void + { + Carbon::setTestNow('2026-07-26 12:00:00'); try { - $this->createService()->cancel('tok-cancel-deny', null, 'customer', $stranger); - $this->fail('Expected AuthorizationException was not thrown.'); - } catch (AuthorizationException) { - $this->addToAssertionCount(1); + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'due_at' => now(), + ]); + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => 10, + 'quantity' => 1, + 'description' => 'Dynamic server', + ]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + invoiceId: $invoice->id, + userId: $service->user_id, + guaranteedUntil: now() + ); + $this->insertAllocation($reservationId); + + $this->assertSame(1, $this->service()->cleanupExpired()); + $this->assertSame(Invoice::STATUS_CANCELLED, $invoice->fresh()->status); + $this->assertSame(Service::STATUS_CANCELLED, $service->fresh()->status); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'expired', + ]); + $this->assertNotNull( + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->value('released_at') + ); + try { + app(MarkInvoicePaidService::class)->handle($invoice); + $this->fail('Expected the cancelled day-eight invoice to reject payment.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'capacity guarantee deadline expired', + $exception->getMessage() + ); + } + } finally { + Carbon::setTestNow(); } } - public function test_cancel_succeeds_when_actor_is_owner(): void + public function test_cleanup_continues_after_corrupt_earlier_reservation(): void { - $owner = User::withoutEvents(fn () => User::factory()->create()); - - $this->createReservation($owner->id, 'tok-cancel-allow'); + $validService = $this->makeService(); + $paidInvoice = Invoice::factory()->create([ + 'user_id' => $validService->user_id, + 'status' => Invoice::STATUS_PAID, + 'due_at' => now()->subMinute(), + ]); + $corruptReservationId = $this->insertReservation( + invoiceId: $paidInvoice->id, + userId: $validService->user_id, + guaranteedUntil: now()->subMinute() + ); + $validReservationId = $this->insertReservation( + serviceId: $validService->id, + userId: $validService->user_id, + guaranteedUntil: now()->subMinute() + ); + $operatorAlerts = Mockery::mock(SchedulerOperatorAlertService::class); + $operatorAlerts->shouldReceive('notify') + ->once() + ->with(Mockery::on( + fn (array $context): bool => $context['entity_type'] + === 'resource_reservation' + && $context['entity_id'] === $corruptReservationId + )); + $this->app->instance( + SchedulerOperatorAlertService::class, + $operatorAlerts + ); + + $this->assertSame(1, $this->service()->cleanupExpired()); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $corruptReservationId, + 'status' => ResourceReservation::STATUS_PENDING, + ]); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $validReservationId, + 'status' => ResourceReservation::STATUS_EXPIRED, + ]); + $this->assertSame( + Service::STATUS_CANCELLED, + $validService->fresh()->status + ); + } - $this->mockAuditService->shouldReceive('log')->once(); + public function test_cleanup_cursor_reaches_valid_row_beyond_failed_page(): void + { + $validService = $this->makeService(); + $paidInvoice = Invoice::factory()->create([ + 'user_id' => $validService->user_id, + 'status' => Invoice::STATUS_PAID, + 'due_at' => now()->subMinute(), + ]); + $corruptReservationIds = []; + for ($index = 0; $index < 2; $index++) { + $corruptReservationIds[] = $this->insertReservation( + invoiceId: $paidInvoice->id, + userId: $validService->user_id, + guaranteedUntil: now()->subMinute() + ); + } + $validReservationId = $this->insertReservation( + serviceId: $validService->id, + userId: $validService->user_id, + guaranteedUntil: now()->subMinute() + ); + $operatorAlerts = Mockery::mock( + SchedulerOperatorAlertService::class + ); + $health = Mockery::mock( + SchedulerHealthService::class, + [$operatorAlerts] + )->makePartial(); + $health->shouldReceive('recordRowFailure')->times(2); + $this->app->instance(SchedulerHealthService::class, $health); + DB::table('ptero_scheduler_heartbeats') + ->where( + 'task_name', + SchedulerHealthService::TASK_EXPIRE_CHECKOUT + ) + ->update(['last_scanned_entity_id' => 0]); + + $this->assertSame(0, $this->service()->cleanupExpired(2)); + $this->assertSame(1, $this->service()->cleanupExpired(1)); + foreach ($corruptReservationIds as $corruptReservationId) { + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $corruptReservationId, + 'status' => ResourceReservation::STATUS_PENDING, + ]); + } + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $validReservationId, + 'status' => ResourceReservation::STATUS_EXPIRED, + ]); + $this->assertSame( + $validReservationId, + (int) DB::table('ptero_scheduler_heartbeats') + ->where( + 'task_name', + SchedulerHealthService::TASK_EXPIRE_CHECKOUT + ) + ->value('last_scanned_entity_id') + ); + $this->assertSame( + Service::STATUS_CANCELLED, + $validService->fresh()->status + ); + } - $result = $this->createService()->cancel('tok-cancel-allow', null, 'customer', $owner); + public function test_late_gateway_payment_is_preserved_for_refund_review(): void + { + Carbon::setTestNow('2026-07-26 12:00:01'); - $this->assertTrue($result); + try { + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'due_at' => now()->subSecond(), + 'currency_code' => 'USD', + ]); + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => 12.50, + 'quantity' => 1, + 'description' => 'Dynamic server', + ]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + invoiceId: $invoice->id, + userId: $service->user_id, + guaranteedUntil: now()->subSecond() + ); + + ExtensionHelper::addPayment( + $invoice->id, + null, + 12.50, + transactionId: 'late-gateway-transaction' + ); + + $this->assertSame(1, $invoice->transactions()->count()); + $this->assertSame(Invoice::STATUS_PENDING, $invoice->fresh()->status); + $this->assertNotNull( + $invoice->fresh()->payment_attention_required_at + ); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'pending', + ]); + } finally { + Carbon::setTestNow(); + } } - public function test_cancel_succeeds_when_actor_is_null(): void + public function test_partial_payment_expiry_releases_capacity_without_cancelling_payment_fact(): void { - $owner = User::withoutEvents(fn () => User::factory()->create()); - - $this->createReservation($owner->id, 'tok-cancel-null'); + Carbon::setTestNow('2026-07-26 12:00:00'); - $this->mockAuditService->shouldReceive('log')->once(); + try { + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'due_at' => now()->addDays(7), + 'currency_code' => 'USD', + ]); + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => 12.50, + 'quantity' => 1, + 'description' => 'Dynamic server', + ]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + invoiceId: $invoice->id, + userId: $service->user_id, + guaranteedUntil: now()->addDays(7) + ); + $this->insertAllocation($reservationId); + ExtensionHelper::addPayment( + $invoice->id, + null, + 5.00, + transactionId: 'partial-gateway-transaction' + ); + + Carbon::setTestNow(now()->addDays(7)); + $this->assertSame(1, $this->service()->cleanupExpired()); + + $this->assertSame(1, $invoice->transactions()->count()); + $this->assertSame(Invoice::STATUS_PENDING, $invoice->fresh()->status); + $this->assertNotNull( + $invoice->fresh()->payment_attention_required_at + ); + $this->assertSame( + Service::STATUS_PROVISIONING_FAILED, + $service->fresh()->status + ); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'expired', + ]); + } finally { + Carbon::setTestNow(); + } + } - $result = $this->createService()->cancel('tok-cancel-null', null, 'system', null); + public function test_mixed_invoice_expiry_releases_dynamic_and_static_stock_once(): void + { + Carbon::setTestNow('2026-07-26 12:00:00'); - $this->assertTrue($result); + try { + [$dynamicService, $dynamicProduct] = $this->makeStockedService(4); + [$staticService, $staticProduct] = $this->makeStockedService( + 7, + userId: $dynamicService->user_id + ); + $invoice = Invoice::factory()->create([ + 'user_id' => $dynamicService->user_id, + 'status' => Invoice::STATUS_PENDING, + 'due_at' => now(), + 'currency_code' => 'USD', + ]); + foreach ([$dynamicService, $staticService] as $linkedService) { + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $linkedService->id, + 'price' => 12.50, + 'quantity' => 1, + 'description' => 'Server', + ]); + } + $reservationId = $this->insertReservation( + serviceId: $dynamicService->id, + invoiceId: $invoice->id, + userId: $dynamicService->user_id, + guaranteedUntil: now() + ); + $this->insertAllocation($reservationId); + + $this->assertSame(1, $this->service()->cleanupExpired()); + $this->assertSame(0, $this->service()->cleanupExpired()); + + $this->assertSame(5, $dynamicProduct->fresh()->stock); + $this->assertSame(8, $staticProduct->fresh()->stock); + $this->assertSame( + Service::STATUS_CANCELLED, + $dynamicService->fresh()->status + ); + $this->assertSame( + Service::STATUS_CANCELLED, + $staticService->fresh()->status + ); + $this->assertSame( + Invoice::STATUS_CANCELLED, + $invoice->fresh()->status + ); + } finally { + Carbon::setTestNow(); + } } - // ─── Commit-3: actor-aware authorization (extend) ────────────────────────── - - public function test_extend_throws_when_actor_does_not_own_reservation(): void + public function test_mixed_invoice_payment_attention_releases_all_stock_once(): void { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $stranger = User::withoutEvents(fn () => User::factory()->create()); + Carbon::setTestNow('2026-07-26 12:00:00'); - $this->createReservation($owner->id, 'tok-extend-deny'); + try { + [$dynamicService, $dynamicProduct] = $this->makeStockedService(4); + [$staticService, $staticProduct] = $this->makeStockedService( + 7, + userId: $dynamicService->user_id + ); + $invoice = Invoice::factory()->create([ + 'user_id' => $dynamicService->user_id, + 'status' => Invoice::STATUS_PENDING, + 'due_at' => now()->addDays(7), + 'currency_code' => 'USD', + ]); + foreach ([$dynamicService, $staticService] as $linkedService) { + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $linkedService->id, + 'price' => 12.50, + 'quantity' => 1, + 'description' => 'Server', + ]); + } + $reservationId = $this->insertReservation( + serviceId: $dynamicService->id, + invoiceId: $invoice->id, + userId: $dynamicService->user_id, + guaranteedUntil: now()->addDays(7) + ); + $this->insertAllocation($reservationId); + ExtensionHelper::addPayment( + $invoice->id, + null, + 5, + transactionId: 'mixed-partial-payment' + ); + + Carbon::setTestNow(now()->addDays(7)); + $this->assertSame(1, $this->service()->cleanupExpired()); + $this->assertSame(0, $this->service()->cleanupExpired()); + + $this->assertSame(5, $dynamicProduct->fresh()->stock); + $this->assertSame(8, $staticProduct->fresh()->stock); + $this->assertSame( + Service::STATUS_PROVISIONING_FAILED, + $dynamicService->fresh()->status + ); + $this->assertSame( + Service::STATUS_PROVISIONING_FAILED, + $staticService->fresh()->status + ); + $this->assertSame( + Invoice::STATUS_PENDING, + $invoice->fresh()->status + ); + $this->assertNotNull( + $invoice->fresh()->payment_attention_required_at + ); + } finally { + Carbon::setTestNow(); + } + } - $this->mockAuditService->shouldReceive('log')->never(); + public function test_generic_order_age_cron_cannot_shorten_seven_day_capacity_guarantee(): void + { + Carbon::setTestNow('2026-07-26 12:00:00'); try { - $this->createService()->extend('tok-extend-deny', 15, $stranger); - $this->fail('Expected AuthorizationException was not thrown.'); - } catch (AuthorizationException) { - $this->addToAssertionCount(1); + config(['settings.cronjob_order_cancel' => 3]); + $service = $this->makeService(); + DB::table('services') + ->where('id', $service->id) + ->update(['created_at' => now()->subDays(4)]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + guaranteedUntil: now()->addDays(3) + ); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update(['expires_at' => now()->addDays(3)]); + $this->insertAllocation($reservationId); + + $this->artisan('app:cron-job')->assertExitCode(0); + $this->assertSame(Service::STATUS_PENDING, $service->fresh()->status); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => 'pending', + ]); + + Carbon::setTestNow(now()->addDays(3)); + $this->assertSame(1, $this->service()->cleanupExpired()); + $this->assertSame(Service::STATUS_CANCELLED, $service->fresh()->status); + } finally { + Carbon::setTestNow(); } } - public function test_extend_succeeds_when_actor_is_owner(): void + public function test_checkout_cleanup_never_reclaims_upgrade_reservations(): void { - $owner = User::withoutEvents(fn () => User::factory()->create()); + $service = $this->makeService(); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + guaranteedUntil: now()->subSecond() + ); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'purpose' => 'upgrade', + 'expires_at' => now()->subSecond(), + ]); + $this->insertAllocation($reservationId); + + $this->assertSame(0, $this->service()->cleanupExpired()); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'purpose' => 'upgrade', + 'status' => ResourceReservation::STATUS_PENDING, + ]); + $this->assertDatabaseHas('ptero_reservation_allocations', [ + 'reservation_id' => $reservationId, + 'released_at' => null, + ]); + } - $this->createReservation($owner->id, 'tok-extend-allow'); + public function test_cron_can_suspend_a_confirmed_dynamic_service_through_coordinator(): void + { + Queue::fake(); + config([ + 'settings.cronjob_order_suspend' => 2, + 'settings.cronjob_invoice' => 7, + ]); + $service = $this->makeBillableService(Service::STATUS_ACTIVE); + DB::table('services')->where('id', $service->id)->update([ + 'expires_at' => now()->subDays(3), + 'price' => 10, + ]); + $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'confirmed' + ); + + $this->artisan('app:cron-job')->assertExitCode(0); + + $this->assertSame(Service::STATUS_SUSPENDED, $service->fresh()->status); + Queue::assertPushed( + SuspendJob::class, + fn (SuspendJob $job): bool => (int) $job->service->id === (int) $service->id + ); + } - $this->mockAuditService->shouldReceive('log')->once(); + public function test_paid_renewal_can_reactivate_confirmed_dynamic_service(): void + { + Queue::fake(); + $service = $this->makeBillableService(Service::STATUS_SUSPENDED); + DB::table('services')->where('id', $service->id)->update([ + 'price' => '10.00', + ]); + $service = $service->fresh(); + $this->insertConfirmedCheckoutCommitment($service); + $renewalInvoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'currency_code' => $service->currency_code, + 'due_at' => $service->expires_at, + ]); + $renewalInvoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => $service->price, + 'quantity' => $service->quantity, + 'description' => 'Dynamic service renewal', + ]); - $result = $this->createService()->extend('tok-extend-allow', 15, $owner); + $payment = ExtensionHelper::addPayment( + $renewalInvoice, + null, + $service->price, + transactionId: 'paid-renewal-reactivation' + ); + + $this->assertSame( + InvoiceTransactionStatus::Succeeded, + $payment->fresh()->status + ); + $this->assertSame( + number_format( + (float) $renewalInvoice->fresh()->total, + 2, + '.', + '' + ), + number_format((float) $payment->fresh()->amount, 2, '.', '') + ); + $paidInvoice = $renewalInvoice->fresh(); + $this->assertNull( + $paidInvoice->payment_attention_required_at, + (string) $paidInvoice->payment_attention_reason + ); + $this->assertSame( + Invoice::STATUS_PAID, + $paidInvoice->status + ); + $this->assertSame(Service::STATUS_ACTIVE, $service->fresh()->status); + } - $this->assertTrue($result); + public function test_mixed_paid_invoice_routes_only_the_row_backed_service_through_dynamic_commit(): void + { + Bus::fake([CreateJob::class]); + $server = Server::query()->create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $dynamicProduct = Product::factory()->create(['server_id' => $server->id]); + $staticProduct = Product::factory()->create(['server_id' => $server->id]); + $dynamicPlan = $dynamicProduct->plans()->create([ + 'name' => 'Dynamic', + 'type' => 'recurring', + 'billing_period' => 1, + 'billing_unit' => 'month', + ]); + $staticPlan = $staticProduct->plans()->create([ + 'name' => 'Static', + 'type' => 'recurring', + 'billing_period' => 1, + 'billing_unit' => 'month', + ]); + $user = User::withoutEvents(fn () => User::factory()->create()); + $dynamicService = Service::withoutEvents(fn () => Service::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $dynamicProduct->id, + 'plan_id' => $dynamicPlan->id, + 'status' => Service::STATUS_PENDING, + 'currency_code' => 'USD', + ])); + $staticService = Service::withoutEvents(fn () => Service::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $staticProduct->id, + 'plan_id' => $staticPlan->id, + 'status' => Service::STATUS_PENDING, + 'currency_code' => 'USD', + ])); + $invoice = Invoice::factory()->create([ + 'user_id' => $user->id, + 'status' => Invoice::STATUS_PENDING, + 'currency_code' => 'USD', + ]); + foreach ([$dynamicService, $staticService] as $invoiceService) { + $invoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $invoiceService->id, + 'price' => 12.50, + 'quantity' => 1, + 'description' => 'Mixed checkout service', + ]); + } + $this->insertReservation( + serviceId: $dynamicService->id, + invoiceId: $invoice->id, + userId: $user->id, + guaranteedUntil: now()->addDays(7) + ); + + $reservationService = Mockery::mock(ReservationService::class); + $reservationService->shouldReceive('commitPaidService') + ->once() + ->withArgs( + fn (Service $candidate, Invoice $paidInvoice): bool => $candidate->is($dynamicService) && $paidInvoice->is($invoice) + ) + ->andReturnTrue(); + $this->app->instance(ReservationService::class, $reservationService); + + $fulfillment = app(DurableFulfillmentService::class); + $this->assertTrue($fulfillment->isReservationBacked($dynamicService)); + $this->assertFalse($fulfillment->isReservationBacked($staticService)); + + app(RenewServiceService::class)->handle($dynamicService, $invoice); + app(RenewServiceService::class)->handle($staticService, $invoice); + + $this->assertCount(2, Bus::dispatched(CreateJob::class)); + Bus::assertDispatched( + CreateJob::class, + fn (CreateJob $job): bool => $job->service->is($dynamicService) + ); + Bus::assertDispatched( + CreateJob::class, + fn (CreateJob $job): bool => $job->service->is($staticService) + ); + $this->assertSame(Service::STATUS_ACTIVE, $staticService->fresh()->status); } - public function test_extend_succeeds_when_actor_is_null(): void + public function test_missing_extension_runtime_cannot_fall_back_to_legacy_paid_provisioning(): void { - $owner = User::withoutEvents(fn () => User::factory()->create()); + Bus::fake([CreateJob::class]); + $service = $this->makeService(); + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PENDING, + 'currency_code' => 'USD', + ]); + $reservationId = $this->insertReservation( + serviceId: $service->id, + invoiceId: $invoice->id, + userId: $service->user_id, + guaranteedUntil: now()->addDays(7) + ); + $missingRuntime = new class extends DurableFulfillmentService + { + protected function reservationService(): ?object + { + return null; + } + }; + $this->app->instance( + DurableFulfillmentService::class, + $missingRuntime + ); + + try { + app(RenewServiceService::class)->handle($service, $invoice); + $this->fail('Expected missing durable runtime to block provisioning.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'durable fulfillment extension is unavailable', + $exception->getMessage() + ); + } + + Bus::assertNotDispatched(CreateJob::class); + $this->assertSame(Service::STATUS_PENDING, $service->fresh()->status); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => ResourceReservation::STATUS_PENDING, + ]); + } - $this->createReservation($owner->id, 'tok-extend-null'); + public function test_missing_extension_runtime_cannot_partially_cancel_or_release_stock(): void + { + [$service, $product] = $this->makeStockedService(4); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id + ); + $missingRuntime = new class extends DurableFulfillmentService + { + protected function reservationService(): ?object + { + return null; + } + }; - $this->mockAuditService->shouldReceive('log')->once(); + try { + $missingRuntime->requestCancellation($service); + $this->fail('Expected missing durable runtime to block cancellation.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'durable fulfillment extension is unavailable', + $exception->getMessage() + ); + } - $result = $this->createService()->extend('tok-extend-null', 15, null); + $this->assertSame(4, $product->fresh()->stock); + $this->assertSame(Service::STATUS_PENDING, $service->fresh()->status); + $this->assertNull($service->fresh()->product_stock_released_at); + $this->assertDatabaseHas('ptero_resource_reservations', [ + 'id' => $reservationId, + 'status' => ResourceReservation::STATUS_PENDING, + 'product_stock_released_at' => null, + ]); + } - $this->assertTrue($result); + public function test_allocation_row_drift_is_rejected_before_external_provisioning(): void + { + $service = $this->makeService(); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed' + ); + $this->insertAllocation($reservationId); + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->update(['port' => 25566]); + + $this->configurationService->shouldNotReceive('assertServiceMatches'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Allocation claims no longer match'); + + $this->service()->beginProvisioning($service); } - // ─── Commit-3: actor-aware authorization (confirm) ───────────────────────── + public function test_reconciler_recovers_a_stale_crashed_worker_lease(): void + { + Queue::fake(); + $service = $this->makeService(status: 'provisioning'); + $reservationId = $this->insertReservation( + serviceId: $service->id, + userId: $service->user_id, + status: 'paid_committed', + provisioningStartedAt: now()->subMinutes(6), + provisioningLeaseId: 'abandoned-lease' + ); + + $this->assertSame(1, $this->service()->reconcileStalledPaidCommitments()); + $reservation = DB::table('ptero_resource_reservations')->find($reservationId); + $this->assertNull($reservation->provisioning_started_at); + $this->assertNull($reservation->provisioning_lease_id); + $this->assertNotNull($reservation->next_provisioning_attempt_at); + Queue::assertPushed( + CreateJob::class, + fn (CreateJob $job): bool => $job->service->is($service) + && $job->dispatchId !== null + && $job->dispatchToken !== null + ); + $this->assertDatabaseHas('service_job_dispatches', [ + 'service_id' => $service->id, + 'action' => 'create', + ]); + } - public function test_confirm_throws_when_actor_does_not_own_reservation(): void + public function test_fixed_port_is_claimed_before_an_earlier_wildcard_mapping(): void { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $stranger = User::withoutEvents(fn () => User::factory()->create()); + $service = $this->service(); + $reflection = new \ReflectionClass($service); + $method = $reflection->getMethod('mapAllocationRequirements'); + $method->setAccessible(true); + + $mapped = $method->invoke($service, [ + ['id' => 7001, 'ip' => '192.0.2.10', 'port' => 27015], + ['id' => 7002, 'ip' => '192.0.2.10', 'port' => 25565], + ], [ + [ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => null, + 'is_primary' => true, + ], + [ + 'environment_key' => 'QUERY_PORT', + 'requested_port' => 27015, + 'is_primary' => false, + ], + ]); - $this->createReservation($owner->id, 'tok-confirm-deny'); + $this->assertSame([ + [ + 'allocation_id' => 7001, + 'ip' => '192.0.2.10', + 'port' => 27015, + 'environment_key' => 'QUERY_PORT', + 'is_primary' => false, + ], + [ + 'allocation_id' => 7002, + 'ip' => '192.0.2.10', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ], + ], $mapped); + } - $this->mockAuditService->shouldReceive('log')->never(); + /** + * @param array $markers + */ + private function assertOrderedSourceMarkers( + string $source, + array $markers + ): void { + $offset = 0; + + foreach ($markers as $marker) { + $position = strpos($source, $marker, $offset); + $this->assertNotFalse( + $position, + "Expected source marker [{$marker}] after offset {$offset}." + ); + $offset = $position + strlen($marker); + } + } - try { - $this->createService()->confirm('tok-confirm-deny', 42, $stranger); - $this->fail('Expected AuthorizationException was not thrown.'); - } catch (AuthorizationException) { - $this->addToAssertionCount(1); + private function service(): ReservationService + { + $reflection = new \ReflectionClass(ReservationService::class); + $service = $reflection->newInstanceWithoutConstructor(); + + foreach ([ + 'nodeService' => $this->nodeService, + 'configurationService' => $this->configurationService, + 'ttlMinutes' => 15, + ] as $property => $value) { + $instanceProperty = $reflection->getProperty($property); + $instanceProperty->setAccessible(true); + $instanceProperty->setValue($service, $value); } + + return $service; } - public function test_confirm_succeeds_when_actor_is_owner(): void + private function makeService(string $status = 'pending'): Service { - $owner = User::withoutEvents(fn () => User::factory()->create()); - // Direct DB insert avoids App\Models\Service alias-mock contamination from CartItemDeletedListenerTest. - $serviceId = DB::table('services')->insertGetId([ - 'user_id' => $owner->id, - 'status' => 'active', + $user = User::withoutEvents(fn () => User::factory()->create()); + $id = DB::table('services')->insertGetId([ + 'user_id' => $user->id, + 'status' => $status, 'currency_code' => 'USD', - 'quantity' => 1, - 'price' => '0.00', - 'created_at' => now(), - 'updated_at' => now(), + 'quantity' => 1, + 'price' => '0.00', + 'expires_at' => now()->addMonth(), + 'created_at' => now(), + 'updated_at' => now(), ]); - $this->createReservation($owner->id, 'tok-confirm-allow'); + return Service::query()->findOrFail($id); + } - $this->mockAuditService->shouldReceive('log')->once(); + private function makeBillableService(string $status): Service + { + $product = $this->pterodactylProduct(); + $plan = Plan::factory()->create([ + 'priceable_id' => $product->id, + 'priceable_type' => Product::class, + 'type' => 'recurring', + 'billing_unit' => 'month', + 'billing_period' => 1, + ]); + $service = $this->makeService($status); + DB::table('services') + ->where('id', $service->id) + ->update([ + 'product_id' => $product->id, + 'plan_id' => $plan->id, + ]); + + return $service->fresh(); + } - $result = $this->createService()->confirm('tok-confirm-allow', $serviceId, $owner); + /** + * @return array{Service, Product} + */ + private function makeStockedService( + int $stock, + string $status = Service::STATUS_PENDING, + ?int $userId = null + ): array { + $product = Product::factory()->create(['stock' => $stock]); + $service = $this->makeService($status); + DB::table('services') + ->where('id', $service->id) + ->update(array_filter([ + 'product_id' => $product->id, + 'user_id' => $userId, + ], fn ($value): bool => $value !== null)); + + return [$service->fresh(), $product]; + } - $this->assertTrue($result); + private function resourceOption(string $name, string $resource): ConfigOption + { + return ConfigOption::create([ + 'name' => $name, + 'env_variable' => $resource, + 'type' => 'dynamic_slider', + 'hidden' => false, + 'upgradable' => true, + 'metadata' => [ + 'resource_type' => $resource, + 'min' => 1, + 'max' => 100000, + 'step' => 1, + 'default' => 1, + 'display_divisor' => 1, + 'pricing' => [ + 'model' => 'linear', + 'rate_per_unit' => 0, + ], + ], + ]); } - public function test_confirm_succeeds_when_actor_is_null(): void + private function pterodactylProduct(): Product { - $owner = User::withoutEvents(fn () => User::factory()->create()); - $serviceId = DB::table('services')->insertGetId([ - 'user_id' => $owner->id, - 'status' => 'active', - 'currency_code' => 'USD', - 'quantity' => 1, - 'price' => '0.00', - 'created_at' => now(), - 'updated_at' => now(), + $server = Server::query()->create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, ]); - $this->createReservation($owner->id, 'tok-confirm-null'); + return Product::factory()->create(['server_id' => $server->id]); + } + + private function insertReservation( + ?int $serviceId = null, + ?int $invoiceId = null, + ?int $cartId = null, + ?int $cartItemGuardId = null, + ?int $userId = null, + string $status = 'pending', + mixed $provisioningStartedAt = null, + ?string $provisioningLeaseId = null, + mixed $consumedAt = null, + mixed $cancellationRequestedAt = null, + mixed $guaranteedUntil = null, + ?int $productId = null, + ?int $planId = null, + string $purpose = 'checkout', + ?int $upgradeGuardId = null + ): int { + $payload = [ + 'customer_id' => $userId, + 'cart_id' => $cartId, + 'server_extension_id' => 0, + 'product_id' => (int) $productId, + 'plan_id' => (int) $planId, + 'quantity' => 1, + 'currency_code' => 'USD', + 'panel_identity' => str_repeat('c', 64), + 'node_id' => 4, + 'location_id' => 2, + 'resources' => [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + ], + 'provisioning_identity' => [ + 'nest_id' => 1, + 'egg_id' => 2, + 'user_external_id' => $userId !== null + ? "paymenter-user-{$userId}" + : null, + 'user_email' => $userId !== null + ? (string) User::query()->whereKey($userId)->value('email') + : null, + ], + 'allocation_requirements' => [ + 'required_count' => 1, + 'mappings' => [[ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => null, + 'is_primary' => true, + ]], + 'allowed_port_ranges' => [], + 'dedicated_ip' => false, + ], + 'allocations' => [[ + 'allocation_id' => 7001, + 'ip' => '192.0.2.10', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ]], + ]; + + return DB::table('ptero_resource_reservations')->insertGetId([ + 'token' => Str::random(64), + 'purpose' => $purpose, + 'cart_item_id' => null, + 'cart_item_guard_id' => $cartItemGuardId, + 'cart_id' => $cartId, + 'server_extension_id' => null, + 'panel_identity' => str_repeat('c', 64), + 'service_id' => $serviceId, + 'service_guard_id' => $serviceId, + 'upgrade_guard_id' => $upgradeGuardId, + 'invoice_id' => $invoiceId, + 'user_id' => $userId, + 'product_id' => $productId, + 'plan_id' => $planId, + 'quantity' => 1, + 'currency_code' => 'USD', + 'configuration_fingerprint' => $this->configurationService->fingerprint($payload), + 'configuration_payload' => json_encode($payload), + 'pricing_version' => str_repeat('b', 64), + 'formula_version' => ReservationConfigurationService::FORMULA_VERSION, + 'node_id' => 4, + 'location_id' => 2, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + 'calculated_price' => 12.50, + 'pricing_breakdown' => json_encode([]), + 'status' => $status, + 'expires_at' => now()->addDay(), + 'guaranteed_until' => $guaranteedUntil ?? now()->addDay(), + 'provisioning_started_at' => $provisioningStartedAt, + 'provisioning_lease_id' => $provisioningLeaseId, + 'cancellation_requested_at' => $cancellationRequestedAt, + 'consumed_at' => $consumedAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } - $this->mockAuditService->shouldReceive('log')->once(); + private function insertAllocation(int $reservationId, int $allocationId = 7001): void + { + $status = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('status'); + + DB::table('ptero_reservation_allocations')->insert([ + 'reservation_id' => $reservationId, + 'panel_identity' => str_repeat('c', 64), + 'node_id' => 4, + 'allocation_id' => $allocationId, + 'ip' => '192.0.2.10', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + 'released_at' => $status === 'confirmed' ? now() : null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } - $result = $this->createService()->confirm('tok-confirm-null', $serviceId, null); + private function insertConfirmedCheckoutCommitment( + Service $service + ): int { + $checkoutInvoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => Invoice::STATUS_PAID, + 'currency_code' => $service->currency_code, + 'due_at' => now()->subMonth(), + ]); + $checkoutInvoice->items()->create([ + 'reference_type' => Service::class, + 'reference_id' => $service->id, + 'price' => $service->price, + 'quantity' => $service->quantity, + 'description' => 'Original dynamic checkout', + ]); - $this->assertTrue($result); + $reservationId = $this->insertReservation( + serviceId: $service->id, + invoiceId: $checkoutInvoice->id, + userId: $service->user_id, + status: ResourceReservation::STATUS_CONFIRMED, + consumedAt: now()->subMonth(), + productId: $service->product_id, + planId: $service->plan_id + ); + $serverExtensionId = (int) Product::query() + ->whereKey($service->product_id) + ->value('server_id'); + $payload = json_decode( + (string) DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('configuration_payload'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $payload['server_extension_id'] = $serverExtensionId; + $checkoutPrice = number_format( + (float) $service->price, + 2, + '.', + '' + ); + $payload['calculated_price'] = $checkoutPrice; + + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'server_extension_id' => $serverExtensionId, + 'calculated_price' => $checkoutPrice, + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'configuration_fingerprint' => $this->configurationService->fingerprint($payload), + 'paid_committed_at' => now()->subMonth(), + 'external_server_id' => 71, + 'external_user_id' => 44, + 'external_server_uuid' => '2f4f28b0-0f36-4e6b-a2aa-a686c3466696', + 'external_server_identifier' => 'created', + 'updated_at' => now(), + ]); + $this->insertAllocation($reservationId); + + return $reservationId; } - // ─── Commit-3: admin bypass via ResourceReservationPolicy::before() ───────── + /** + * @return array + */ + private function externalServer(): array + { + return [ + 'attributes' => [ + 'id' => 71, + 'uuid' => '2f4f28b0-0f36-4e6b-a2aa-a686c3466696', + 'identifier' => 'created', + 'user' => 44, + ], + ]; + } - public function test_policy_before_grants_admin_bypass(): void + /** + * @return array + */ + private function cancellationServer(int $serviceId): array { - $this->markTestSkipped('Requires full Filament panel registration'); + return [ + 'attributes' => [ + 'id' => 71, + 'uuid' => '2f4f28b0-0f36-4e6b-a2aa-a686c3466696', + 'identifier' => 'created', + 'external_id' => (string) $serviceId, + 'user' => 44, + 'node' => 4, + 'nest' => 1, + 'egg' => 2, + 'allocation' => 7001, + 'limits' => [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 51200, + ], + 'feature_limits' => [ + 'allocations' => 0, + ], + 'relationships' => [ + 'allocations' => [ + 'data' => [[ + 'attributes' => ['id' => 7001], + ]], + ], + ], + ], + ]; } } diff --git a/tests/Unit/ResourceCalculationServiceTest.php b/tests/Unit/ResourceCalculationServiceTest.php index 0b38c69..97c16ab 100644 --- a/tests/Unit/ResourceCalculationServiceTest.php +++ b/tests/Unit/ResourceCalculationServiceTest.php @@ -2,467 +2,1540 @@ namespace Paymenter\Extensions\Others\DynamicPterodactyl\Tests\Unit; +use App\Models\Invoice; +use App\Models\Plan; +use App\Models\Product; +use App\Models\Server; +use App\Models\Service; +use App\Models\User; use Illuminate\Foundation\Testing\DatabaseTransactions; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Http; +use Mockery; +use Paymenter\Extensions\Others\DynamicPterodactyl\Exceptions\InvalidStockConfigurationException; +use Paymenter\Extensions\Others\DynamicPterodactyl\Models\NodeCapacityPolicy; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\PterodactylInventoryService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ReservationConfigurationService; use Paymenter\Extensions\Others\DynamicPterodactyl\Services\ResourceCalculationService; +use Paymenter\Extensions\Others\DynamicPterodactyl\Services\UpgradeReservationIntegrityService; use Paymenter\Extensions\Others\DynamicPterodactyl\Tests\LaravelTestCase; +use PHPUnit\Framework\Attributes\DataProvider; class ResourceCalculationServiceTest extends LaravelTestCase { use DatabaseTransactions; - private ResourceCalculationService $service; + private const PANEL_IDENTITY = '7c6e2c72d2dd80adbf79bdce3fd8931102831742545776c0df049b9e2daef06f'; - protected function setUp(): void + public function test_cpu_policy_mutation_uses_scope_lock_and_rejects_live_commitment(): void { - parent::setUp(); - - Config::set('settings.debug', false); - Http::preventStrayRequests(); - - $reflection = new \ReflectionClass(ResourceCalculationService::class); - $this->service = $reflection->newInstanceWithoutConstructor(); + $policy = $this->createCpuPolicy(); + $this->assertDatabaseHas('ptero_capacity_scopes', [ + 'panel_identity' => self::PANEL_IDENTITY, + 'location_id' => 1, + ]); + $this->insertReservation('cpu-policy-hold', 'pending', [ + 'memory' => 1024, + 'cpu' => 100, + 'disk' => 10240, + ]); - foreach (['apiUrl' => 'https://panel.example.com', 'apiKey' => 'test-api-key'] as $property => $value) { - $propertyReflection = $reflection->getProperty($property); - $propertyReflection->setAccessible(true); - $propertyReflection->setValue($this->service, $value); + try { + $policy->update(['cpu_capacity_percent' => 1600]); + $this->fail('A live capacity hold must serialize and block policy mutation.'); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'live capacity commitment', + $exception->getMessage() + ); } + + $this->assertSame(800, $policy->fresh()->cpu_capacity_percent); } - public function test_get_locations_returns_parsed_array(): void + public function test_node_move_fails_closed_until_cpu_policy_identity_is_resynced(): void { - Http::fake([ - 'panel.example.com/*' => Http::response([ - 'data' => [ - ['attributes' => ['id' => 1, 'short' => 'us', 'long' => 'US East']], - ], - ], 200), - ]); - - $result = $this->service->getLocations(); - - Http::assertSentCount(1); - $this->assertCount(1, $result); - $this->assertEquals(1, $result[0]['id']); - $this->assertEquals('us', $result[0]['short']); + $this->createCpuPolicy(); + + $node = $this->service( + node: $this->node(['location_id' => 2]) + )->getNodeAvailability(5); + + $this->assertNotNull($node); + $this->assertFalse($node['eligible']); + $this->assertSame(0, $node['total']['cpu']); + $this->assertContains( + 'cpu_policy_identity_mismatch', + $node['ineligible_reasons'] + ); } - public function test_429_throws_runtime_exception_with_rate_limit_message(): void + public function test_calculation_uses_conservative_maximum_across_node_and_server_snapshots(): void { - Http::fake([ - 'panel.example.com/*' => Http::response([], 429), - ]); + $this->createCpuPolicy(capacity: 800, overcommit: 15000); + $service = $this->service( + node: $this->node([ + 'memory' => 1001, + 'disk' => 2001, + 'memory_overallocate' => 10, + 'disk_overallocate' => 50, + 'allocated_resources' => ['memory' => 1000, 'disk' => 200], + ]), + servers: [[ + 'id' => 81, + 'node' => 5, + // Node wins for memory; the newer server list wins for disk. + 'memory' => 999, + 'cpu' => 100, + 'disk' => 999, + ]] + ); + + $result = $service->getLocationAvailability(1); + $node = $result['nodes'][0]; + + $this->assertSame(['memory' => 1101, 'cpu' => 1200, 'disk' => 3001], $node['total']); + $this->assertSame(['memory' => 1000, 'cpu' => 100, 'disk' => 999], $node['allocated']); + $this->assertSame(['memory' => 101, 'cpu' => 1100, 'disk' => 2002], $node['available']); + $this->assertTrue($node['eligible']); + $this->assertTrue($node['cpu_capacity_enforced']); + } - try { - $this->service->getLocations(); - $this->fail('Expected RuntimeException for 429 response'); - } catch (\RuntimeException $e) { - $this->assertMatchesRegularExpression('/rate limit/i', $e->getMessage()); - } finally { - Http::assertSentCount(1); // 429 must NOT retry + public function test_any_unbounded_existing_server_limit_makes_node_ineligible(): void + { + $this->createCpuPolicy(); + + foreach (['memory', 'cpu', 'disk'] as $unboundedResource) { + $server = [ + 'id' => 81, + 'node' => 5, + 'memory' => 1024, + 'cpu' => 100, + 'disk' => 10240, + ]; + $server[$unboundedResource] = 0; + + $node = $this->service(servers: [$server]) + ->getLocationAvailability(1)['nodes'][0]; + + $this->assertFalse( + $node['eligible'], + "A zero {$unboundedResource} limit must fail the node closed." + ); + $this->assertContains('unlimited_existing_resource', $node['ineligible_reasons']); } } - public function test_500_throws_sanitized_runtime_exception(): void + public function test_unbounded_node_overallocation_settings_fail_closed(): void { - Http::fake([ - 'panel.example.com/*' => Http::response(['error' => 'panel down'], 500), - ]); - - try { - $this->service->getLocations(); - $this->fail('Expected RuntimeException for 500 response'); - } catch (\RuntimeException $e) { - $this->assertMatchesRegularExpression('/500/', $e->getMessage()); - $this->assertStringNotContainsString('panel down', $e->getMessage()); - } finally { - Http::assertSentCount(1); // 500 must NOT retry + $this->createCpuPolicy(); + + foreach ([ + 'memory_overallocate' => 'unbounded_memory_overallocation', + 'disk_overallocate' => 'unbounded_disk_overallocation', + ] as $setting => $reason) { + $node = $this->service(node: $this->node([$setting => -1])) + ->getLocationAvailability(1)['nodes'][0]; + + $this->assertFalse( + $node['eligible'], + "A Pterodactyl {$setting} value of -1 must fail the node closed." + ); + $this->assertContains($reason, $node['ineligible_reasons']); + $this->assertSame( + $this->node()[$setting === 'memory_overallocate' ? 'memory' : 'disk'], + $node['total'][$setting === 'memory_overallocate' ? 'memory' : 'disk'] + ); } } - public function test_connection_exception_retries_and_throws(): void + public function test_any_customer_allocation_capability_makes_node_ineligible(): void { - $attempts = 0; - - Http::fake(function () use (&$attempts) { - $attempts++; - throw new \Illuminate\Http\Client\ConnectionException('timed out'); - }); - - $this->expectException(\RuntimeException::class); - - try { - $this->service->getLocations(); - } catch (\RuntimeException $e) { - $this->assertSame(2, $attempts); // retry(2) = 2 total attempts (1 original + 1 retry) - - throw $e; - } + $this->createCpuPolicy(); + $node = $this->service(servers: [[ + 'id' => 81, + 'node' => 5, + 'memory' => 1024, + 'cpu' => 100, + 'disk' => 10240, + 'allocation_limit' => 1, + 'assigned_allocation_ids' => [501], + 'allocation_headroom' => 0, + ]])->getLocationAvailability(1)['nodes'][0]; + + $this->assertFalse($node['eligible']); + $this->assertContains( + 'customer_allocation_management', + $node['ineligible_reasons'] + ); } - public function test_connection_failure_message_is_sanitized(): void + public function test_missing_cpu_policy_makes_node_ineligible_and_advertises_zero_cpu(): void { - Http::fake(function () { - throw new \Illuminate\Http\Client\ConnectionException( - 'cURL error 7: Failed to connect to panel-internal-host:8080' - ); - }); + $node = $this->service()->getLocationAvailability(1)['nodes'][0]; - try { - $this->service->getLocations(); - $this->fail('Expected RuntimeException on connection failure'); - } catch (\RuntimeException $e) { - $this->assertStringContainsString('connection failed', $e->getMessage()); - $this->assertStringNotContainsString('panel-internal-host', $e->getMessage()); - $this->assertStringNotContainsString('8080', $e->getMessage()); - $this->assertStringNotContainsString('cURL', $e->getMessage()); - } + $this->assertFalse($node['eligible']); + $this->assertSame(0, $node['total']['cpu']); + $this->assertSame(0, $node['available']['cpu']); + $this->assertContains('cpu_policy_missing', $node['ineligible_reasons']); } - public function test_malformed_json_body_throws_runtime_exception(): void + public function test_cpu_policy_cannot_change_during_a_live_commitment(): void { - Http::fake([ - 'panel.example.com/*' => Http::response('not json', 200), - ]); + $policy = $this->createCpuPolicy(); + $this->insertReservation( + 'policy-hold', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240] + ); + $policy->cpu_overcommit_bps = 20000; - try { - $this->service->getLocations(); - $this->fail('Expected RuntimeException for invalid JSON'); - } catch (\RuntimeException $e) { - $this->assertMatchesRegularExpression('/invalid JSON payload/i', $e->getMessage()); - } finally { - Http::assertSentCount(1); - } + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('live capacity commitment'); + + $policy->save(); } - public function test_get_node_location_throws_when_location_id_missing(): void + public function test_cpu_policy_cannot_be_deleted_during_a_live_commitment(): void { - Http::fake([ - 'panel.example.com/*' => Http::response([ - 'attributes' => [ - 'id' => 5, - // location_id intentionally absent - ], - ], 200), - ]); - - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('getNodeLocation'); - $method->setAccessible(true); + $policy = $this->createCpuPolicy(); + $this->insertReservation( + 'policy-delete-hold', + 'paid_committed', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240] + ); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessageMatches('/missing location_id/'); + $this->expectExceptionMessage('live capacity commitment'); - $method->invoke($this->service, 5); + $policy->delete(); } - public function test_get_location_availability_excludes_given_reservation_token(): void + public function test_private_maintenance_and_allocationless_nodes_are_excluded(): void { - $this->insertPendingReservation('token-a', 5, 1, ['memory' => 2048, 'cpu' => 100, 'disk' => 10240]); - $this->insertPendingReservation('token-b', 5, 1, ['memory' => 1024, 'cpu' => 50, 'disk' => 5120]); - - Http::fake($this->availabilityHttpFake(nodeId: 5, locationId: 1, totalMemory: 8192, totalDisk: 51200, totalCpuThreads: 4)); + $this->createCpuPolicy(); + $node = $this->service( + node: $this->node(['public' => false, 'maintenance_mode' => true]), + allocations: [] + )->getLocationAvailability(1)['nodes'][0]; + + $this->assertFalse($node['eligible']); + $this->assertSame([ + 'private_node', + 'maintenance_mode', + 'no_available_allocation', + ], $node['ineligible_reasons']); + } - $result = $this->service->getLocationAvailability(1, 'token-a'); + public function test_pending_and_paid_committed_capacity_is_counted_with_self_exclusion(): void + { + $this->createCpuPolicy(); + $this->insertReservation('pending-self', 'pending', [ + 'memory' => 2048, + 'cpu' => 100, + 'disk' => 10240, + ], purpose: 'checkout'); + $this->insertReservation('paid-other', 'paid_committed', [ + 'memory' => 1024, + 'cpu' => 50, + 'disk' => 5120, + ], expiresAt: now()->subDay(), purpose: 'checkout'); + + $node = $this->service() + ->getLocationAvailability(1, 'pending-self')['nodes'][0]; + + $this->assertSame( + ['memory' => 1024, 'cpu' => 50, 'disk' => 5120], + $node['reserved'] + ); + } - $this->assertSame(['memory' => 1024, 'cpu' => 50, 'disk' => 5120], $result['nodes'][0]['reserved']); - $this->assertSame(['memory' => 7168, 'cpu' => 350, 'disk' => 46080], $result['nodes'][0]['available']); + public function test_upgrade_holds_count_only_positive_reserved_deltas(): void + { + $this->createCpuPolicy(); + $this->insertUpgradeReservation( + 'upgrade-delta', + 'pending', + ['memory' => 6144, 'cpu' => 300, 'disk' => 40960], + // Target values must not be double-counted as newly reserved stock. + ['memory' => 8192, 'cpu' => 400, 'disk' => 51200], + ['memory' => 2048, 'cpu' => 100, 'disk' => 10240] + ); + + $node = $this->service()->getLocationAvailability(1)['nodes'][0]; + + $this->assertSame([ + 'memory' => 2048, + 'cpu' => 100, + 'disk' => 10240, + ], $node['reserved']); } - public function test_verify_availability_with_self_exclusion_succeeds_on_edge_fit(): void + public function test_upgrade_delta_row_drift_fails_stock_closed(): void { - $resources = ['memory' => 4096, 'cpu' => 200, 'disk' => 10240]; - $this->insertPendingReservation('edge-fit', 5, 1, $resources); + $this->createCpuPolicy(); + $reservationId = $this->insertUpgradeReservation( + 'upgrade-delta-drift', + 'pending', + ['memory' => 6144, 'cpu' => 300, 'disk' => 40960], + ['memory' => 8192, 'cpu' => 400, 'disk' => 51200], + ['memory' => 2048, 'cpu' => 100, 'disk' => 10240] + ); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update(['reserved_memory' => 0]); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'upgrade capacity snapshot failed its immutable integrity check' + ); + + $this->service()->getLocationAvailability(1); + } - Http::fake($this->availabilityHttpFake(nodeId: 5, locationId: 1, totalMemory: 4096, totalDisk: 10240, totalCpuThreads: 2)); + public function test_upgrade_invoice_lifecycle_drift_fails_stock_closed(): void + { + $this->createCpuPolicy(); + $reservationId = $this->insertUpgradeReservation( + 'upgrade-invoice-drift', + 'pending', + ['memory' => 6144, 'cpu' => 300, 'disk' => 40960], + ['memory' => 8192, 'cpu' => 400, 'disk' => 51200], + ['memory' => 2048, 'cpu' => 100, 'disk' => 10240] + ); + $invoiceId = DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('invoice_id'); + $this->assertNotNull($invoiceId); + DB::table('invoices') + ->where('id', $invoiceId) + ->update(['status' => Invoice::STATUS_PAID]); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'upgrade capacity snapshot failed its immutable integrity check' + ); + + $this->service()->getLocationAvailability(1); + } - $this->assertTrue($this->service->verifyAvailability(5, $resources, 'edge-fit')); + public function test_confirmed_checkout_stays_overlaid_until_same_snapshot_proves_target(): void + { + $this->createCpuPolicy(); + $serviceRecord = Service::factory()->create([ + 'user_id' => User::factory()->create()->id, + ]); + DB::table('services')->where('id', $serviceRecord->id)->update([ + 'status' => Service::STATUS_ACTIVE, + ]); + $reservationId = $this->insertReservation( + 'confirmed-checkout-overlay', + 'confirmed', + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + purpose: 'checkout' + ); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'purpose' => 'checkout', + 'service_id' => $serviceRecord->id, + 'external_server_id' => 81, + 'external_server_uuid' => '10000000-0000-4000-8000-000000000081', + 'external_server_identifier' => 'server-81', + 'consumed_at' => now(), + ]); + + $stale = $this->service() + ->getLocationAvailability(1)['nodes'][0]; + $this->assertSame( + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + $stale['reserved'] + ); + + $reflected = $this->service(servers: [[ + 'id' => 81, + 'uuid' => '10000000-0000-4000-8000-000000000081', + 'identifier' => 'server-81', + 'external_id' => (string) $serviceRecord->id, + 'node' => 5, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + ]])->getLocationAvailability(1)['nodes'][0]; + $this->assertSame( + ['memory' => 0, 'cpu' => 0, 'disk' => 0], + $reflected['reserved'] + ); } - public function test_verify_availability_without_exclusion_fails_on_edge_fit(): void + public function test_confirmed_upgrade_supersedes_checkout_and_overlays_only_snapshot_deficit(): void { - $resources = ['memory' => 4096, 'cpu' => 200, 'disk' => 10240]; - $this->insertPendingReservation('edge-fit', 5, 1, $resources); + $this->createCpuPolicy(); + $serviceRecord = Service::factory()->create([ + 'user_id' => User::factory()->create()->id, + ]); + DB::table('services')->where('id', $serviceRecord->id)->update([ + 'status' => Service::STATUS_ACTIVE, + ]); + $checkoutId = $this->insertReservation( + 'confirmed-checkout-before-upgrade', + 'confirmed', + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + purpose: 'checkout' + ); + DB::table('ptero_resource_reservations') + ->where('id', $checkoutId) + ->update([ + 'purpose' => 'checkout', + 'service_id' => $serviceRecord->id, + 'external_server_id' => 81, + 'external_server_uuid' => '10000000-0000-4000-8000-000000000081', + 'external_server_identifier' => 'server-81', + 'consumed_at' => now()->subHour(), + ]); + $this->insertUpgradeReservation( + 'confirmed-upgrade-target', + 'confirmed', + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + ['memory' => 8192, 'cpu' => 400, 'disk' => 40960], + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + service: $serviceRecord, + externalServerId: 81, + consumedAt: now() + ); + + $reflected = $this->service(servers: [[ + 'id' => 81, + 'uuid' => '10000000-0000-4000-8000-000000000081', + 'identifier' => 'server-81', + 'external_id' => (string) $serviceRecord->id, + 'node' => 5, + 'memory' => 8192, + 'cpu' => 400, + 'disk' => 40960, + ]])->getLocationAvailability(1)['nodes'][0]; + $this->assertSame( + ['memory' => 0, 'cpu' => 0, 'disk' => 0], + $reflected['reserved'], + 'The obsolete checkout vector must not remain overlaid.' + ); + + $stale = $this->service(servers: [[ + 'id' => 81, + 'uuid' => '10000000-0000-4000-8000-000000000081', + 'identifier' => 'server-81', + 'external_id' => (string) $serviceRecord->id, + 'node' => 5, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + ]])->getLocationAvailability(1)['nodes'][0]; + $this->assertSame( + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + $stale['reserved'] + ); + } - Http::fake($this->availabilityHttpFake(nodeId: 5, locationId: 1, totalMemory: 4096, totalDisk: 10240, totalCpuThreads: 2)); + public function test_multiple_confirmed_upgrades_use_immutable_upgrade_order_not_mutable_timestamps(): void + { + $this->createCpuPolicy(); + $serviceRecord = Service::factory()->create([ + 'user_id' => User::factory()->create()->id, + ]); + DB::table('services')->where('id', $serviceRecord->id)->update([ + 'status' => Service::STATUS_ACTIVE, + ]); - $this->assertFalse($this->service->verifyAvailability(5, $resources)); + $checkoutId = $this->insertReservation( + 'confirmed-base', + 'confirmed', + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + purpose: 'checkout' + ); + DB::table('ptero_resource_reservations') + ->where('id', $checkoutId) + ->update([ + 'service_id' => $serviceRecord->id, + 'external_server_id' => 81, + 'external_server_uuid' => '10000000-0000-4000-8000-000000000081', + 'external_server_identifier' => 'server-81', + 'consumed_at' => now()->subHours(2), + ]); + $this->insertUpgradeReservation( + 'confirmed-upgrade-eight', + 'confirmed', + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + ['memory' => 8192, 'cpu' => 400, 'disk' => 40960], + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + service: $serviceRecord, + externalServerId: 81, + consumedAt: now() + ); + $this->insertUpgradeReservation( + 'confirmed-upgrade-six', + 'confirmed', + ['memory' => 8192, 'cpu' => 400, 'disk' => 40960], + ['memory' => 6144, 'cpu' => 300, 'disk' => 40960], + ['memory' => 0, 'cpu' => 0, 'disk' => 0], + service: $serviceRecord, + externalServerId: 81, + consumedAt: now()->subDay() + ); + + $node = $this->service(servers: [[ + 'id' => 81, + 'uuid' => '10000000-0000-4000-8000-000000000081', + 'identifier' => 'server-81', + 'external_id' => (string) $serviceRecord->id, + 'node' => 5, + 'memory' => 6144, + 'cpu' => 300, + 'disk' => 40960, + ]])->getLocationAvailability(1)['nodes'][0]; + + $this->assertSame( + ['memory' => 0, 'cpu' => 0, 'disk' => 0], + $node['reserved'] + ); } - public function test_snapshot_with_single_location_single_node(): void + public function test_conflicting_confirmed_server_identities_fail_snapshot_proof_closed(): void { - $calls = 0; + $this->createCpuPolicy(); + $serviceRecord = Service::factory()->create([ + 'user_id' => User::factory()->create()->id, + ]); + DB::table('services')->where('id', $serviceRecord->id)->update([ + 'status' => Service::STATUS_ACTIVE, + ]); + $checkoutId = $this->insertReservation( + 'identity-checkout', + 'confirmed', + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + purpose: 'checkout' + ); + DB::table('ptero_resource_reservations') + ->where('id', $checkoutId) + ->update([ + 'purpose' => 'checkout', + 'service_id' => $serviceRecord->id, + 'external_server_id' => 81, + 'consumed_at' => now()->subHour(), + ]); + $this->insertUpgradeReservation( + 'identity-upgrade', + 'confirmed', + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + ['memory' => 8192, 'cpu' => 400, 'disk' => 40960], + ['memory' => 4096, 'cpu' => 200, 'disk' => 20480], + service: $serviceRecord, + externalServerId: 82, + consumedAt: now() + ); + + $node = $this->service(servers: [[ + 'id' => 82, + 'uuid' => '10000000-0000-4000-8000-000000000082', + 'identifier' => 'server-82', + 'external_id' => (string) $serviceRecord->id, + 'node' => 5, + 'memory' => 8192, + 'cpu' => 400, + 'disk' => 40960, + ]])->getLocationAvailability(1)['nodes'][0]; + + $this->assertSame( + ['memory' => 8192, 'cpu' => 400, 'disk' => 40960], + $node['reserved'] + ); + } - Http::fake($this->clusterSnapshotHttpFake( - $calls, - locations: [ - ['id' => 1, 'short' => 'dc1', 'long' => 'Data Center 1'], - ], - nodePages: [[ - $this->nodeWithServersPayload(1, 1, 'Node 1', [ - ['memory' => 2048, 'cpu' => 100, 'disk' => 10240], - ]), + public function test_server_assignment_wins_over_stale_node_allocation_snapshot(): void + { + $this->createCpuPolicy(); + $node = $this->service( + servers: [[ + 'id' => 81, + 'node' => 5, + 'memory' => 1024, + 'cpu' => 100, + 'disk' => 10240, + 'assigned_allocation_ids' => [501], ]], - )); + allocations: [ + ['id' => 501, 'ip' => '192.0.2.5', 'port' => 25565], + ['id' => 502, 'ip' => '192.0.2.5', 'port' => 25566], + ['id' => 503, 'ip' => '192.0.2.6', 'port' => 25565], + ] + )->getLocationAvailability(1)['nodes'][0]; + + $this->assertSame( + [502, 503], + array_column($node['available_allocations'], 'id') + ); + $this->assertTrue($node['available_allocations'][0]['ip_in_use']); + } - $snapshot = $this->service->buildClusterSnapshot(); + public function test_confirmed_local_claim_blocks_a_stale_free_allocation_snapshot(): void + { + $this->createCpuPolicy(); + $serviceRecord = Service::factory()->create([ + 'user_id' => User::factory()->create()->id, + ]); + DB::table('services')->where('id', $serviceRecord->id)->update([ + 'status' => Service::STATUS_ACTIVE, + ]); + $reservationId = $this->insertReservation( + 'confirmed-allocation-claim', + 'confirmed', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout', + allocation: [ + 'allocation_id' => 501, + 'ip' => '192.0.2.5', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ] + ); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'service_id' => $serviceRecord->id, + 'external_server_id' => 81, + 'consumed_at' => now(), + ]); + $node = $this->service(allocations: [ + ['id' => 501, 'ip' => '192.0.2.5', 'port' => 25565], + ['id' => 502, 'ip' => '192.0.2.6', 'port' => 25566], + ])->getLocationAvailability(1)['nodes'][0]; + + $this->assertSame( + [502], + array_column($node['available_allocations'], 'id') + ); + } - $this->assertArrayNotHasKey('error', $snapshot); - $this->assertCount(1, $snapshot['locations']); - $this->assertSame([1], $snapshot['by_location'][1]['nodes']); - $this->assertSame(['memory' => 8192, 'cpu' => 400, 'disk' => 51200], $snapshot['nodes'][1]['totals']); - $this->assertSame(['memory' => 2048, 'cpu' => 100, 'disk' => 10240], $snapshot['nodes'][1]['allocated']); - $this->assertSame(['memory' => 6144, 'cpu' => 300, 'disk' => 40960], $snapshot['nodes'][1]['available']); - $this->assertSame(['memory' => 8192, 'cpu' => 400, 'disk' => 51200], $snapshot['by_location'][1]['totals']); - $this->assertLessThanOrEqual(4, $calls); + public function test_locally_reserved_allocation_is_removed_from_panel_unassigned_inventory(): void + { + $this->createCpuPolicy(); + $this->insertReservation( + 'allocation-hold', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout', + allocation: [ + 'allocation_id' => 502, + 'ip' => '192.0.2.5', + 'port' => 25566, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ] + ); + + $service = $this->service(allocations: [ + ['id' => 501, 'ip' => '192.0.2.5', 'port' => 25565], + ['id' => 502, 'ip' => '192.0.2.5', 'port' => 25566], + ]); + $node = $service->getLocationAvailability(1)['nodes'][0]; + + $this->assertSame([501], array_column($node['available_allocations'], 'id')); + $this->assertTrue($node['available_allocations'][0]['ip_in_use']); + $editedNode = $service + ->getLocationAvailability(1, 'allocation-hold')['nodes'][0]; + $this->assertSame( + [501, 502], + array_column($editedNode['available_allocations'], 'id') + ); + $this->assertSame( + [false, false], + array_column($editedNode['available_allocations'], 'ip_in_use') + ); } - public function test_snapshot_aggregates_across_locations(): void + public function test_equivalent_ipv6_reservation_marks_the_whole_ip_in_use(): void { - $calls = 0; + $this->createCpuPolicy(); + $reservationId = $this->insertReservation( + 'ipv6-allocation-hold', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout', + allocation: [ + 'allocation_id' => 601, + 'ip' => '2001:db8::1', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ] + ); + + $node = $this->service(allocations: [[ + 'id' => 602, + 'ip' => '2001:0db8:0:0:0:0:0:1', + 'port' => 25566, + ]])->getLocationAvailability(1)['nodes'][0]; + + $this->assertTrue($node['available_allocations'][0]['ip_in_use']); + } - Http::fake($this->clusterSnapshotHttpFake( - $calls, - locations: [ - ['id' => 1, 'short' => 'dc1', 'long' => 'Data Center 1'], - ['id' => 2, 'short' => 'dc2', 'long' => 'Data Center 2'], + public function test_pending_dedicated_hold_removes_its_whole_ip_from_all_stock(): void + { + $this->createCpuPolicy(); + $this->insertReservation( + 'dedicated-allocation-hold', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout', + allocation: [ + 'allocation_id' => 502, + 'ip' => '192.0.2.5', + 'port' => 25566, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, ], - nodePages: [[ - $this->nodeWithServersPayload(1, 1, 'Node 1', [['memory' => 1024, 'cpu' => 50, 'disk' => 5120]]), - $this->nodeWithServersPayload(2, 1, 'Node 2', [['memory' => 2048, 'cpu' => 100, 'disk' => 10240]]), - $this->nodeWithServersPayload(3, 2, 'Node 3', [['memory' => 3072, 'cpu' => 150, 'disk' => 15360]]), - $this->nodeWithServersPayload(4, 2, 'Node 4', []), - $this->nodeWithServersPayload(5, 2, 'Node 5', [['memory' => 1024, 'cpu' => 50, 'disk' => 5120]]), - ]], - )); - - $snapshot = $this->service->buildClusterSnapshot(); + dedicatedIp: true + ); + $service = $this->service(allocations: [ + ['id' => 501, 'ip' => '192.0.2.5', 'port' => 25565], + ['id' => 502, 'ip' => '192.0.2.5', 'port' => 25566], + ['id' => 503, 'ip' => '192.0.2.6', 'port' => 25565], + ]); - $this->assertCount(5, $snapshot['nodes']); - $this->assertSame([1, 2], $snapshot['by_location'][1]['nodes']); - $this->assertSame([3, 4, 5], $snapshot['by_location'][2]['nodes']); - $this->assertSame(['memory' => 16384, 'cpu' => 800, 'disk' => 102400], $snapshot['by_location'][1]['totals']); - $this->assertSame(['memory' => 3072, 'cpu' => 150, 'disk' => 15360], $snapshot['by_location'][1]['allocated']); - $this->assertSame(['memory' => 24576, 'cpu' => 1200, 'disk' => 153600], $snapshot['by_location'][2]['totals']); - $this->assertSame(['memory' => 4096, 'cpu' => 200, 'disk' => 20480], $snapshot['by_location'][2]['allocated']); - $this->assertLessThanOrEqual(4, $calls); + $node = $service->getLocationAvailability(1)['nodes'][0]; + $this->assertSame( + [503], + array_column($node['available_allocations'], 'id') + ); + + $editing = $service + ->getLocationAvailability(1, 'dedicated-allocation-hold')['nodes'][0]; + $this->assertSame( + [501, 502, 503], + array_column($editing['available_allocations'], 'id') + ); } - public function test_snapshot_handles_paginated_node_response(): void + public function test_tampered_dedicated_ip_claim_fails_stock_closed(): void { - $calls = 0; - - Http::fake($this->clusterSnapshotHttpFake( - $calls, - locations: [ - ['id' => 1, 'short' => 'dc1', 'long' => 'Data Center 1'], + $this->createCpuPolicy(); + $reservationId = $this->insertReservation( + 'tampered-dedicated-allocation-hold', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout', + allocation: [ + 'allocation_id' => 502, + 'ip' => '192.0.2.5', + 'port' => 25566, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, ], - nodePages: [ - [ - $this->nodeWithServersPayload(1, 1, 'Node 1', []), - $this->nodeWithServersPayload(2, 1, 'Node 2', []), - ], - [ - $this->nodeWithServersPayload(3, 1, 'Node 3', []), - ], - ], - )); + dedicatedIp: true + ); + $tampered = json_decode( + (string) DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('configuration_payload'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $tampered['allocation_requirements']['dedicated_ip'] = false; + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'configuration_payload' => json_encode( + $tampered, + JSON_THROW_ON_ERROR + ), + ]); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'allocation snapshot failed its immutable capacity integrity check' + ); + + $this->service(allocations: [ + ['id' => 501, 'ip' => '192.0.2.5', 'port' => 25565], + ['id' => 502, 'ip' => '192.0.2.5', 'port' => 25566], + ])->getLocationAvailability(1); + } - $snapshot = $this->service->buildClusterSnapshot(); + public function test_missing_checkout_allocation_claim_fails_stock_closed_even_when_self_excluded(): void + { + $this->createCpuPolicy(); + $reservationId = $this->insertReservation( + 'missing-self-claim', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout' + ); + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->delete(); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'Allocation claims no longer match the immutable checkout reservation' + ); + + $this->service()->getLocationAvailability( + 1, + 'missing-self-claim' + ); + } - $this->assertSame([1, 2, 3], $snapshot['by_location'][1]['nodes']); - $this->assertCount(3, $snapshot['nodes']); - $this->assertLessThanOrEqual(4, $calls); + #[DataProvider('allocationClaimDriftCases')] + public function test_each_checkout_allocation_tuple_field_is_verified( + string $field, + mixed $value + ): void { + $this->createCpuPolicy(); + $reservationId = $this->insertReservation( + "claim-drift-{$field}", + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout', + allocation: [ + 'allocation_id' => 502, + 'ip' => '192.0.2.5', + 'port' => 25566, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ] + ); + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->update([$field => $value]); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'Allocation claims no longer match the immutable checkout reservation' + ); + + $this->service()->getLocationAvailability(1); } - public function test_snapshot_handles_pterodactyl_5xx_gracefully(): void + public function test_extra_checkout_allocation_claim_fails_stock_closed(): void { - $calls = 0; + $this->createCpuPolicy(); + $reservationId = $this->insertReservation( + 'extra-claim', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout' + ); + DB::table('ptero_reservation_allocations')->insert([ + 'reservation_id' => $reservationId, + 'panel_identity' => self::PANEL_IDENTITY, + 'node_id' => 5, + 'allocation_id' => 991001, + 'ip' => '192.0.2.99', + 'port' => 29999, + 'environment_key' => 'QUERY_PORT', + 'is_primary' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); - Http::fake(function ($request) use (&$calls) { - $calls++; + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'Allocation claims no longer match the immutable checkout reservation' + ); - if (str_contains($request->url(), '/api/application/locations')) { - return Http::response([ - 'data' => [ - ['attributes' => ['id' => 1, 'short' => 'dc1', 'long' => 'Data Center 1']], - ], - ], 200); - } + $this->service()->getLocationAvailability(1); + } - return Http::response(['errors' => [['detail' => 'panel down']]], 500); - }); + #[DataProvider('activeClaimStatuses')] + public function test_pending_and_paid_claims_must_remain_unreleased( + string $status + ): void { + $this->createCpuPolicy(); + $reservationId = $this->insertReservation( + "released-{$status}", + $status, + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + expiresAt: $status === 'paid_committed' + ? now()->subDay() + : now()->addDay(), + purpose: 'checkout' + ); + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->update(['released_at' => now()]); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'Allocation claims no longer match the immutable checkout reservation' + ); + + $this->service()->getLocationAvailability(1); + } - $snapshot = $this->service->buildClusterSnapshot(); + public function test_confirmed_claim_must_be_released_before_stock_can_be_quoted(): void + { + $this->createCpuPolicy(); + $reservationId = $this->insertReservation( + 'confirmed-unreleased', + 'confirmed', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout' + ); + DB::table('ptero_reservation_allocations') + ->where('reservation_id', $reservationId) + ->update(['released_at' => null]); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'Allocation claims no longer match the immutable checkout reservation' + ); + + $this->service()->getLocationAvailability(1); + } - $this->assertSame('Pterodactyl unavailable', $snapshot['error']); - $this->assertSame([], $snapshot['nodes']); - $this->assertSame([], $snapshot['by_location']); - $this->assertLessThanOrEqual(4, $calls); + public function test_expired_pending_commitment_stays_counted_until_atomic_cleanup(): void + { + $this->createCpuPolicy(); + $this->insertReservation( + 'pending-awaiting-cleanup', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + expiresAt: now()->subMinute(), + purpose: 'checkout', + allocation: [ + 'allocation_id' => 501, + 'ip' => '192.0.2.5', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ] + ); + + $node = $this->service()->getLocationAvailability(1)['nodes'][0]; + + $this->assertSame( + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + $node['reserved'] + ); + $this->assertSame([], $node['available_allocations']); } - public function test_snapshot_keeps_pterodactyl_call_count_constant(): void + public function test_materialized_terminal_commitment_with_unreleased_claim_fails_stock_closed(): void { - $calls = 0; - $nodes = []; + $this->createCpuPolicy(); + $this->insertReservation( + 'expired-unreleased', + 'expired', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + expiresAt: now()->subMinute(), + purpose: 'checkout' + ); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'terminal capacity commitment still owns an unreleased allocation claim' + ); + + $this->service()->getLocationAvailability(1); + } - for ($nodeId = 1; $nodeId <= 55; $nodeId++) { - $nodes[] = $this->nodeWithServersPayload($nodeId, ($nodeId % 5) + 1, 'Node '.$nodeId, []); - } + public function test_upgrade_commitment_cannot_own_checkout_allocation_claims(): void + { + $this->createCpuPolicy(); + $reservationId = $this->insertReservation( + 'upgrade-with-claim', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'upgrade' + ); + DB::table('ptero_reservation_allocations')->insert([ + 'reservation_id' => $reservationId, + 'panel_identity' => self::PANEL_IDENTITY, + 'node_id' => 5, + 'allocation_id' => 991002, + 'ip' => '192.0.2.100', + 'port' => 30000, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); - Http::fake($this->clusterSnapshotHttpFake( - $calls, - locations: [ - ['id' => 1, 'short' => 'dc1', 'long' => 'Data Center 1'], - ['id' => 2, 'short' => 'dc2', 'long' => 'Data Center 2'], - ['id' => 3, 'short' => 'dc3', 'long' => 'Data Center 3'], - ['id' => 4, 'short' => 'dc4', 'long' => 'Data Center 4'], - ['id' => 5, 'short' => 'dc5', 'long' => 'Data Center 5'], - ], - nodePages: [array_slice($nodes, 0, 50), array_slice($nodes, 50)], - )); + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'resource upgrade unexpectedly owns checkout allocation claims' + ); - $snapshot = $this->service->buildClusterSnapshot(); + $this->service()->getLocationAvailability(1); + } - $this->assertCount(55, $snapshot['nodes']); - $this->assertLessThanOrEqual(4, $calls); + public function test_signed_required_allocation_count_must_match_claim_set(): void + { + $this->createCpuPolicy(); + $reservationId = $this->insertReservation( + 'required-count-drift', + 'pending', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout' + ); + $payload = json_decode( + (string) DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->value('configuration_payload'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $payload['allocation_requirements']['required_count'] = 2; + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'configuration_fingerprint' => (new ReservationConfigurationService) + ->fingerprint($payload), + ]); + + $this->expectException(InvalidStockConfigurationException::class); + $this->expectExceptionMessage( + 'no valid signed allocation set' + ); + + $this->service()->getLocationAvailability(1); } - private function insertPendingReservation(string $token, int $nodeId, int $locationId, array $resources): void + public function test_confirmed_dedicated_server_blocks_ip_until_service_is_cancelled(): void { - DB::table('ptero_resource_reservations')->insert([ - 'token' => $token, - 'node_id' => $nodeId, - 'location_id' => $locationId, - 'memory' => $resources['memory'], - 'cpu' => $resources['cpu'], - 'disk' => $resources['disk'], - 'calculated_price' => 9.99, - 'pricing_breakdown' => json_encode([]), - 'status' => 'pending', - 'expires_at' => \now()->addMinutes(15), - 'created_at' => \now(), - 'updated_at' => \now(), + $this->createCpuPolicy(); + $serviceRecord = Service::factory()->create([ + 'user_id' => User::factory()->create()->id, + ]); + DB::table('services')->where('id', $serviceRecord->id)->update([ + 'status' => Service::STATUS_ACTIVE, ]); + $reservationId = $this->insertReservation( + 'confirmed-dedicated', + 'confirmed', + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + purpose: 'checkout', + allocation: [ + 'allocation_id' => 501, + 'ip' => '192.0.2.5', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ], + dedicatedIp: true + ); + DB::table('ptero_resource_reservations') + ->where('id', $reservationId) + ->update([ + 'service_id' => $serviceRecord->id, + ]); + $stock = $this->service(allocations: [ + ['id' => 502, 'ip' => '192.0.2.5', 'port' => 25566], + ['id' => 503, 'ip' => '192.0.2.6', 'port' => 25565], + ]); + + $this->assertSame( + [503], + array_column( + $stock->getLocationAvailability(1)['nodes'][0]['available_allocations'], + 'id' + ) + ); + + DB::table('services')->where('id', $serviceRecord->id)->update([ + 'status' => Service::STATUS_CANCELLED, + ]); + $this->assertSame( + [502, 503], + array_column( + $stock->getLocationAvailability(1)['nodes'][0]['available_allocations'], + 'id' + ) + ); } - private function availabilityHttpFake(int $nodeId, int $locationId, int $totalMemory, int $totalDisk, int $totalCpuThreads): callable - { - return function ($request) use ($nodeId, $locationId, $totalMemory, $totalDisk, $totalCpuThreads) { - $url = $request->url(); - - if (str_contains($url, '/api/application/nodes?')) { - return Http::response([ - 'data' => [[ - 'attributes' => [ - 'id' => $nodeId, - 'location_id' => $locationId, - 'name' => 'Node '.$nodeId, - 'fqdn' => 'node-'.$nodeId.'.example.com', - 'memory' => $totalMemory, - 'disk' => $totalDisk, - 'cpu_threads' => $totalCpuThreads, - 'memory_overallocate' => 0, - 'disk_overallocate' => 0, - 'maintenance_mode' => false, - ], - ]], - ], 200); - } + public function test_holds_from_another_panel_do_not_reduce_colliding_node_or_allocation_stock(): void + { + $this->createCpuPolicy(); + $this->insertReservation( + 'other-panel-hold', + 'pending', + ['memory' => 2048, 'cpu' => 100, 'disk' => 10240], + panelIdentity: str_repeat('f', 64), + purpose: 'checkout', + allocation: [ + 'allocation_id' => 501, + 'ip' => '192.0.2.5', + 'port' => 25565, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ] + ); + + $node = $this->service()->getLocationAvailability(1)['nodes'][0]; + + $this->assertSame( + ['memory' => 0, 'cpu' => 0, 'disk' => 0], + $node['reserved'] + ); + $this->assertSame( + [501], + array_column($node['available_allocations'], 'id') + ); + } - if (str_contains($url, "/api/application/nodes/{$nodeId}?include=servers")) { - return Http::response([ - 'attributes' => [ - 'relationships' => [ - 'servers' => [ - 'data' => [], - ], - ], - ], - ], 200); - } + public function test_fixed_node_upgrade_can_skip_new_allocation_but_not_other_eligibility_rules(): void + { + $this->createCpuPolicy(); + $service = $this->service(allocations: []); - if (str_ends_with($url, "/api/application/nodes/{$nodeId}")) { - return Http::response([ - 'attributes' => [ - 'id' => $nodeId, - 'location_id' => $locationId, - ], - ], 200); - } + $this->assertTrue($service->verifyNodeCapacity( + 5, + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240], + allocationCount: 0 + )); + $this->assertFalse($service->verifyAvailability( + 5, + ['memory' => 1024, 'cpu' => 100, 'disk' => 10240] + )); + } - return Http::response([], 404); - }; + /** + * @param list, + * allocation_headroom?: int + * }> $servers + * @param list|null $allocations + */ + private function service( + ?array $node = null, + array $servers = [], + ?array $allocations = null + ): ResourceCalculationService { + $node ??= $this->node(); + $allocations ??= [['id' => 501, 'ip' => '192.0.2.5', 'port' => 25565]]; + $inventory = Mockery::mock(PterodactylInventoryService::class); + $inventory->shouldReceive('panelIdentity') + ->zeroOrMoreTimes() + ->andReturn(self::PANEL_IDENTITY); + $inventory->shouldReceive('nodesInLocation') + ->zeroOrMoreTimes() + ->with(1) + ->andReturn([$node]); + $inventory->shouldReceive('nodes') + ->zeroOrMoreTimes() + ->andReturn([$node]); + $inventory->shouldReceive('serversForNodes') + ->zeroOrMoreTimes() + ->with([5]) + ->andReturn([5 => $servers]); + $inventory->shouldReceive('availableAllocationsForNode') + ->zeroOrMoreTimes() + ->with(5) + ->andReturn($allocations); + + return new ResourceCalculationService($inventory); } - private function clusterSnapshotHttpFake(int &$calls, array $locations, array $nodePages): callable + /** + * @return array + */ + private function node(array $overrides = []): array { - return function ($request) use (&$calls, $locations, $nodePages) { - $calls++; + return array_replace_recursive([ + 'id' => 5, + 'uuid' => '00000000-0000-4000-8000-000000000005', + 'name' => 'Node 5', + 'fqdn' => 'node-5.example.com', + 'public' => true, + 'maintenance_mode' => false, + 'location_id' => 1, + 'memory' => 32768, + 'disk' => 512000, + 'memory_overallocate' => 0, + 'disk_overallocate' => 0, + 'allocated_resources' => [ + 'memory' => 0, + 'disk' => 0, + ], + ], $overrides); + } - $url = $request->url(); - $query = []; - parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query); + private function createCpuPolicy( + int $capacity = 800, + int $overcommit = 10000 + ): NodeCapacityPolicy { + return NodeCapacityPolicy::query()->updateOrCreate([ + 'panel_identity' => self::PANEL_IDENTITY, + 'node_uuid' => '00000000-0000-4000-8000-000000000005', + ], [ + 'node_id' => 5, + 'location_id' => 1, + 'cpu_capacity_percent' => $capacity, + 'cpu_overcommit_bps' => $overcommit, + 'enabled' => true, + ]); + } - if (str_contains($url, '/api/application/locations')) { - return Http::response([ - 'data' => array_map(fn (array $location) => ['attributes' => $location], $locations), - 'meta' => [ - 'pagination' => [ - 'current_page' => 1, - 'total_pages' => 1, - ], - ], - ], 200); - } + /** + * @return array + */ + public static function allocationClaimDriftCases(): array + { + return [ + 'panel' => ['panel_identity', str_repeat('f', 64)], + 'node' => ['node_id', 6], + 'allocation' => ['allocation_id', 503], + 'ip' => ['ip', '192.0.2.6'], + 'port' => ['port', 25567], + 'environment' => ['environment_key', 'QUERY_PORT'], + 'primary' => ['is_primary', false], + ]; + } - if (str_contains($url, '/api/application/nodes')) { - $page = (int) ($query['page'] ?? 1); - - return Http::response([ - 'data' => $nodePages[$page - 1] ?? [], - 'meta' => [ - 'pagination' => [ - 'current_page' => $page, - 'total_pages' => count($nodePages), - ], - ], - ], 200); + /** + * @return array + */ + public static function activeClaimStatuses(): array + { + return [ + 'pending' => ['pending'], + 'paid committed' => ['paid_committed'], + ]; + } + + private function insertUpgradeReservation( + string $token, + string $status, + array $source, + array $target, + array $delta, + ?Service $service = null, + int $externalServerId = 81, + mixed $consumedAt = null + ): int { + if ( + $service === null + || $service->product_id === null + || $service->plan_id === null + ) { + $product = Product::factory()->create(); + $plan = Plan::factory()->create([ + 'priceable_id' => $product->id, + 'priceable_type' => Product::class, + ]); + $service ??= Service::factory()->create([ + 'user_id' => User::factory()->create()->id, + ]); + DB::table('services')->where('id', $service->id)->update([ + 'product_id' => $product->id, + 'plan_id' => $plan->id, + 'quantity' => 1, + 'currency_code' => 'USD', + ]); + $service->refresh(); + } + $product = Product::query() + ->findOrFail($service->product_id); + $server = $product->server; + if ($server?->extension !== 'Pterodactyl') { + $server = Server::query()->create([ + 'name' => "Pterodactyl Upgrade {$token}", + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + DB::table('products') + ->where('id', $service->product_id) + ->update(['server_id' => $server->id]); + } + $sourceSnapshot = [ + 'service_id' => (int) $service->id, + 'product_id' => (int) $service->product_id, + 'plan_id' => (int) $service->plan_id, + 'quantity' => 1, + 'currency_code' => 'USD', + 'properties' => [ + ...$source, + 'location' => 1, + ], + 'billing_anchor' => [], + ]; + $targetSnapshot = [ + 'service_id' => (int) $service->id, + 'product_id' => (int) $service->product_id, + 'plan_id' => (int) $service->plan_id, + 'quantity' => 1, + 'currency_code' => 'USD', + 'properties' => [ + ...$target, + 'location' => 1, + ], + 'recurring_price' => '0.00', + 'billing_anchor' => [], + ]; + $invoice = Invoice::factory()->create([ + 'user_id' => $service->user_id, + 'status' => match ($status) { + 'pending' => Invoice::STATUS_PENDING, + 'paid_committed', + 'confirmed' => Invoice::STATUS_PAID, + default => throw new \InvalidArgumentException( + "Unsupported upgrade reservation status {$status}." + ), + }, + 'currency_code' => 'USD', + ]); + $sourceFingerprint = $this->serviceUpgradeSnapshotFingerprint( + $sourceSnapshot + ); + $targetFingerprint = $this->serviceUpgradeSnapshotFingerprint( + $targetSnapshot + ); + $upgradeId = DB::table('service_upgrades')->insertGetId([ + 'service_id' => $service->id, + 'product_id' => $service->product_id, + 'plan_id' => $service->plan_id, + 'invoice_id' => $invoice->id, + 'status' => match ($status) { + 'confirmed' => 'completed', + 'paid_committed' => 'paid_committed', + 'pending' => 'awaiting_payment', + default => throw new \InvalidArgumentException( + "Unsupported upgrade reservation status {$status}." + ), + }, + 'active_service_guard_id' => $status === 'confirmed' + ? null + : $service->id, + 'type' => 'config_options', + 'source_snapshot' => json_encode( + $sourceSnapshot, + JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION + ), + 'target_snapshot' => json_encode( + $targetSnapshot, + JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION + ), + 'source_fingerprint' => $sourceFingerprint, + 'target_fingerprint' => $targetFingerprint, + 'quoted_amount' => '9.90', + 'currency_code' => 'USD', + 'credit_amount' => 0, + 'provisioning_attempts' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $payload = [ + 'service_upgrade_id' => $upgradeId, + 'source_fingerprint' => $sourceFingerprint, + 'target_fingerprint' => $targetFingerprint, + 'panel_identity' => self::PANEL_IDENTITY, + 'node_id' => 5, + 'location_id' => 1, + 'external_server_id' => $externalServerId, + 'external_server_uuid' => sprintf( + '10000000-0000-4000-8000-%012d', + $externalServerId + ), + 'external_server_identifier' => "server-{$externalServerId}", + 'external_server_external_id' => (string) $service->id, + 'external_user_id' => 44, + 'user_external_id' => "paymenter-user-{$service->user_id}", + 'user_email' => (string) $service->user->email, + 'nest_id' => 1, + 'egg_id' => 2, + 'preserved_build' => [ + 'swap' => 0, + 'io' => 500, + 'threads' => null, + 'databases' => 0, + 'allocations' => 0, + 'backups' => 0, + ], + 'allocation_id' => 501, + 'assigned_allocation_ids' => [501], + 'source' => $source, + 'target' => $target, + 'delta' => $delta, + ]; + $upgrade = (object) [ + 'id' => $upgradeId, + 'service_id' => $service->id, + 'source_fingerprint' => $sourceFingerprint, + 'target_fingerprint' => $targetFingerprint, + 'quoted_amount' => '9.90', + 'currency_code' => 'USD', + ]; + + return DB::table('ptero_resource_reservations')->insertGetId([ + 'purpose' => 'upgrade', + 'token' => $token, + 'service_id' => $service->id, + 'service_upgrade_id' => $upgradeId, + 'upgrade_guard_id' => $status === 'confirmed' + ? null + : $upgradeId, + 'server_extension_id' => $server->id, + 'invoice_id' => $invoice->id, + 'user_id' => $service->user_id, + 'product_id' => $service->product_id, + 'plan_id' => $service->plan_id, + 'quantity' => 1, + 'currency_code' => 'USD', + 'panel_identity' => self::PANEL_IDENTITY, + 'configuration_fingerprint' => (new UpgradeReservationIntegrityService) + ->fingerprint($upgrade, $payload), + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'pricing_version' => (new UpgradeReservationIntegrityService) + ->pricingVersion($upgrade), + 'formula_version' => 'dynamic-upgrade-v1', + 'node_id' => 5, + 'location_id' => 1, + 'memory' => $target['memory'], + 'cpu' => $target['cpu'], + 'disk' => $target['disk'], + 'reserved_memory' => $delta['memory'], + 'reserved_cpu' => $delta['cpu'], + 'reserved_disk' => $delta['disk'], + 'external_server_id' => $externalServerId, + 'external_user_id' => 44, + 'external_server_uuid' => $payload['external_server_uuid'], + 'external_server_identifier' => $payload['external_server_identifier'], + 'calculated_price' => '9.90', + 'pricing_breakdown' => json_encode( + [], + JSON_THROW_ON_ERROR + ), + 'status' => $status, + 'expires_at' => now()->addDay(), + 'consumed_at' => $consumedAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * @param array $snapshot + */ + private function serviceUpgradeSnapshotFingerprint( + array $snapshot + ): string { + $canonicalize = function (array $value) use ( + &$canonicalize + ): array { + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = $canonicalize($item); + } + } + if (! array_is_list($value)) { + ksort($value); } - return Http::response([], 404); + return $value; }; + + return hash('sha256', json_encode( + $canonicalize($snapshot), + JSON_THROW_ON_ERROR + | JSON_PRESERVE_ZERO_FRACTION + | JSON_UNESCAPED_SLASHES + )); } - private function nodeWithServersPayload(int $nodeId, int $locationId, string $name, array $servers): array - { - return [ - 'attributes' => [ - 'id' => $nodeId, - 'location_id' => $locationId, - 'name' => $name, - 'fqdn' => 'node-'.$nodeId.'.example.com', - 'memory' => 8192, - 'disk' => 51200, - 'cpu_threads' => 4, - 'memory_overallocate' => 0, - 'disk_overallocate' => 0, - 'maintenance_mode' => false, - 'relationships' => [ - 'servers' => [ - 'data' => array_map(fn (array $server, int $index) => [ - 'attributes' => [ - 'id' => ($nodeId * 100) + $index, - 'node' => $nodeId, - 'limits' => $server, - ], - ], $servers, array_keys($servers)), - ], - ], - ], + private function insertReservation( + string $token, + string $status, + array $resources, + mixed $expiresAt = null, + string $panelIdentity = self::PANEL_IDENTITY, + string $purpose = 'checkout', + ?array $allocation = null, + bool $dedicatedIp = false + ): int { + if (! in_array($purpose, ['checkout', 'upgrade'], true)) { + throw new \InvalidArgumentException( + 'The test reservation purpose is invalid.' + ); + } + + $allocation ??= [ + 'allocation_id' => 100000 + (int) sprintf('%u', crc32($token)), + 'ip' => '198.51.100.10', + 'port' => 20000 + ((int) sprintf('%u', crc32($token)) % 40000), + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, ]; + $payload = $purpose === 'checkout' + ? [ + 'panel_identity' => $panelIdentity, + 'node_id' => 5, + 'location_id' => 1, + 'resources' => $resources, + 'allocation_requirements' => [ + 'required_count' => 1, + 'dedicated_ip' => $dedicatedIp, + ], + 'allocations' => [$allocation], + ] + : []; + + $reservationId = DB::table( + 'ptero_resource_reservations' + )->insertGetId([ + 'purpose' => $purpose, + 'token' => $token, + 'panel_identity' => $panelIdentity, + 'configuration_fingerprint' => (new ReservationConfigurationService) + ->fingerprint($payload), + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + 'node_id' => 5, + 'location_id' => 1, + 'memory' => $resources['memory'], + 'cpu' => $resources['cpu'], + 'disk' => $resources['disk'], + 'calculated_price' => 9.99, + 'pricing_breakdown' => json_encode([]), + 'status' => $status, + 'expires_at' => $expiresAt ?? now()->addMinutes(15), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + if ($purpose === 'checkout') { + DB::table('ptero_reservation_allocations')->insert([ + 'reservation_id' => $reservationId, + 'panel_identity' => $panelIdentity, + 'node_id' => 5, + 'allocation_id' => $allocation['allocation_id'], + 'ip' => $allocation['ip'], + 'port' => $allocation['port'], + 'environment_key' => $allocation['environment_key'], + 'is_primary' => $allocation['is_primary'], + 'released_at' => $status === 'confirmed' ? now() : null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + return $reservationId; } } diff --git a/tests/Unit/ResourceQuoteServiceTest.php b/tests/Unit/ResourceQuoteServiceTest.php new file mode 100644 index 0000000..889f463 --- /dev/null +++ b/tests/Unit/ResourceQuoteServiceTest.php @@ -0,0 +1,305 @@ +quote( + requested: ['memory' => 32768, 'cpu' => 200, 'disk' => 51200], + nodes: [$this->node(1, 23552, 800, 512000)] + ); + + $this->assertTrue($quote['adjusted']); + $this->assertSame(23552, $quote['selection']['memory']); + $this->assertSame(23552, $quote['bounds']['memory']['max']); + $this->assertSame(10, $quote['bounds']['memory']['config_option_id']); + } + + public function test_live_stock_above_product_cap_keeps_32_gb_maximum(): void + { + $quote = $this->quote( + requested: ['memory' => 32768, 'cpu' => 200, 'disk' => 51200], + nodes: [$this->node(1, 102400, 800, 512000)] + ); + + $this->assertFalse($quote['adjusted']); + $this->assertSame(32768, $quote['selection']['memory']); + $this->assertSame(32768, $quote['bounds']['memory']['max']); + } + + public function test_live_bound_is_rounded_down_to_configured_step(): void + { + $quote = $this->quote( + requested: ['memory' => 32768, 'cpu' => 200, 'disk' => 51200], + nodes: [$this->node(1, 23000, 800, 512000)] + ); + + $this->assertSame(22528, $quote['selection']['memory']); + $this->assertSame(22528, $quote['bounds']['memory']['max']); + } + + public function test_bounds_never_combine_independent_maxima_from_different_nodes(): void + { + $quote = $this->quote( + requested: ['memory' => 32768, 'cpu' => 200, 'disk' => 102400], + nodes: [ + $this->node(1, 32768, 800, 51200), + $this->node(2, 16384, 800, 102400), + ] + ); + + $this->assertTrue($quote['adjusted']); + $this->assertSame([ + 'memory' => 32768, + 'cpu' => 200, + 'disk' => 51200, + ], $quote['selection']); + $this->assertSame(32768, $quote['bounds']['memory']['max']); + $this->assertSame( + 51200, + $quote['bounds']['disk']['max'], + 'Disk max must be conditional on the selected 32 GB RAM.' + ); + } + + public function test_port_count_is_part_of_node_feasibility(): void + { + $configuration = $this->configuration(); + $configuration['allocation_count'] = 2; + + $this->expectException(StockUnavailableException::class); + + $this->runQuote($configuration, [ + $this->node(1, 32768, 800, 512000, allocationCount: 1), + ]); + } + + public function test_explicit_product_port_is_part_of_quote_feasibility(): void + { + $configuration = $this->configuration(); + $configuration['required_ports'] = [25570]; + + $this->expectException(StockUnavailableException::class); + + $this->runQuote($configuration, [ + $this->node(1, 32768, 800, 512000), + ]); + } + + public function test_quote_and_reservation_share_deterministic_multi_ip_port_selection(): void + { + $configuration = $this->configuration(); + $configuration['required_ports'] = [25570]; + $node = $this->node(1, 32768, 800, 512000, allocationCount: 2); + $node['available_allocations'] = [ + ['id' => 100, 'ip' => '192.0.2.1', 'port' => 25570], + ['id' => 101, 'ip' => '192.0.2.2', 'port' => 25570], + ]; + + $product = new Product; + $product->id = 99; + $configurations = Mockery::mock(ProductResourceConfigurationService::class); + $configurations->shouldReceive('forQuote') + ->once() + ->with($product, []) + ->andReturn($configuration); + $resources = Mockery::mock(ResourceCalculationService::class); + $resources->shouldReceive('getLocationAvailability') + ->twice() + ->with(1, null) + ->andReturn(['nodes' => [$node]]); + $allocations = new AllocationSelectionService; + + $quote = (new ResourceQuoteService( + $configurations, + $resources, + $allocations + ))->quote($product, []); + $selectedNode = (new NodeSelectionService($resources, $allocations)) + ->selectBestNodeWithAllocations( + 1, + $configuration['resources'], + $configuration['allocation_count'], + null, + $configuration['required_ports'] + ); + + $this->assertTrue($quote['available']); + $this->assertSame( + [100], + array_column($selectedNode['selected_allocations'], 'id'), + 'Reservation placement must use the same deterministic set accepted by the quote.' + ); + } + + public function test_customer_quote_contains_no_node_identity(): void + { + $quote = $this->quote( + requested: ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], + nodes: [$this->node(55, 32768, 800, 512000)] + ); + $encoded = json_encode($quote, JSON_THROW_ON_ERROR); + + $this->assertStringNotContainsString('node_id', $encoded); + $this->assertStringNotContainsString('fqdn', $encoded); + $this->assertStringNotContainsString('available_allocations', $encoded); + } + + #[DataProvider('unboundedInventoryReasons')] + public function test_unbounded_inventory_is_configuration_failure(string $reason): void + { + $configuration = $this->configuration(); + $product = new Product; + $product->id = 99; + $configurations = Mockery::mock(ProductResourceConfigurationService::class); + $configurations->shouldReceive('forQuote')->andReturn($configuration); + $resources = Mockery::mock(ResourceCalculationService::class); + $resources->shouldReceive('getLocationAvailability')->andReturn([ + 'nodes' => [[ + 'eligible' => false, + 'ineligible_reasons' => [$reason], + 'available_allocations' => [], + ]], + ]); + + $this->expectException(InvalidStockConfigurationException::class); + + (new ResourceQuoteService( + $configurations, + $resources, + new AllocationSelectionService + )) + ->quote($product, []); + } + + public static function unboundedInventoryReasons(): array + { + return [ + 'missing CPU policy' => ['cpu_policy_missing'], + 'unbounded existing server' => ['unlimited_existing_resource'], + 'unbounded node memory' => ['unbounded_memory_overallocation'], + 'unbounded node disk' => ['unbounded_disk_overallocation'], + ]; + } + + private function quote(array $requested, array $nodes): array + { + $configuration = $this->configuration(); + $configuration['resources'] = $requested; + + return $this->runQuote($configuration, $nodes); + } + + private function runQuote(array $configuration, array $nodes): array + { + $product = new Product; + $product->id = 99; + $configurations = Mockery::mock(ProductResourceConfigurationService::class); + $configurations->shouldReceive('forQuote') + ->once() + ->with($product, []) + ->andReturn($configuration); + $resources = Mockery::mock(ResourceCalculationService::class); + $resources->shouldReceive('getLocationAvailability') + ->once() + ->with(1, null) + ->andReturn(['nodes' => $nodes]); + + return (new ResourceQuoteService( + $configurations, + $resources, + new AllocationSelectionService + )) + ->quote($product, []); + } + + private function configuration(): array + { + return [ + 'product_id' => 99, + 'location_id' => 1, + 'resources' => [ + 'memory' => 32768, + 'cpu' => 200, + 'disk' => 51200, + ], + 'sliders' => [ + 'memory' => [ + 'config_option_id' => 10, + 'min' => 1024, + 'max' => 32768, + 'step' => 1024, + 'default' => 4096, + ], + 'cpu' => [ + 'config_option_id' => 11, + 'min' => 100, + 'max' => 800, + 'step' => 100, + 'default' => 200, + ], + 'disk' => [ + 'config_option_id' => 12, + 'min' => 10240, + 'max' => 512000, + 'step' => 10240, + 'default' => 51200, + ], + ], + 'allocation_count' => 1, + 'required_ports' => [], + 'allocation_mappings' => [[ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => null, + 'is_primary' => true, + ]], + ]; + } + + private function node( + int $nodeId, + int $memory, + int $cpu, + int $disk, + int $allocationCount = 1 + ): array { + return [ + 'node_id' => $nodeId, + 'eligible' => true, + 'available' => [ + 'memory' => $memory, + 'cpu' => $cpu, + 'disk' => $disk, + ], + 'total' => [ + 'memory' => $memory, + 'cpu' => $cpu, + 'disk' => $disk, + ], + 'available_allocations' => $allocationCount === 0 + ? [] + : array_map( + fn (int $offset): array => [ + 'id' => ($nodeId * 100) + $offset, + 'ip' => '192.0.2.'.$nodeId, + 'port' => 25565 + $offset, + ], + range(1, $allocationCount) + ), + ]; + } +} diff --git a/tests/Unit/SchedulerHealthServiceTest.php b/tests/Unit/SchedulerHealthServiceTest.php new file mode 100644 index 0000000..55be417 --- /dev/null +++ b/tests/Unit/SchedulerHealthServiceTest.php @@ -0,0 +1,512 @@ +makePartial(); + $failure = new \RuntimeException('corrupt signed row'); + $health->shouldReceive('recordRowFailure') + ->once() + ->with( + SchedulerHealthService::TASK_EXPIRE_CHECKOUT, + 'resource_reservation', + 41, + $failure + ); + $visited = []; + + $processed = $health->processRows( + SchedulerHealthService::TASK_EXPIRE_CHECKOUT, + 'resource_reservation', + [41, 42, 43], + function (int $reservationId) use ( + &$visited, + $failure + ): bool { + $visited[] = $reservationId; + if ($reservationId === 41) { + throw $failure; + } + + return true; + } + ); + + $this->assertSame([41, 42, 43], $visited); + $this->assertSame(2, $processed); + } + + public function test_cursor_reaches_row_after_a_full_failed_page(): void + { + $candidateIds = $this->insertSchedulerCandidates(101); + $failedIds = array_fill_keys( + array_slice($candidateIds, 0, 100), + true + ); + $operatorAlerts = Mockery::mock( + SchedulerOperatorAlertService::class + ); + $health = Mockery::mock( + SchedulerHealthService::class, + [$operatorAlerts] + )->makePartial(); + $health->shouldReceive('recordRowFailure')->times(100); + $task = SchedulerHealthService::TASK_EXPIRE_CHECKOUT; + DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $task) + ->update(['last_scanned_entity_id' => 0]); + $eligible = fn () => DB::table('ptero_alert_configs') + ->where('location_name', 'like', 'scheduler-cursor-%'); + $firstVisited = []; + + $this->assertSame( + 0, + $health->processEligibleRows( + $task, + 'cursor_fixture', + 100, + $eligible, + function (int $candidateId) use ( + &$firstVisited, + $failedIds + ): bool { + $firstVisited[] = $candidateId; + if (isset($failedIds[$candidateId])) { + throw new \RuntimeException( + 'Deterministic corrupt row.' + ); + } + + return true; + } + ) + ); + $this->assertSame( + array_slice($candidateIds, 0, 100), + $firstVisited + ); + $this->assertSame( + $candidateIds[99], + (int) DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $task) + ->value('last_scanned_entity_id') + ); + + $secondVisited = []; + $this->assertSame( + 1, + $health->processEligibleRows( + $task, + 'cursor_fixture', + 100, + $eligible, + function (int $candidateId) use ( + &$secondVisited, + $candidateIds + ): bool { + $secondVisited[] = $candidateId; + + return $candidateId === $candidateIds[100]; + } + ) + ); + $this->assertSame($candidateIds[100], $secondVisited[0]); + } + + public function test_wrapped_scan_retries_a_repaired_early_row(): void + { + $candidateIds = $this->insertSchedulerCandidates(3); + $operatorAlerts = Mockery::mock( + SchedulerOperatorAlertService::class + ); + $health = Mockery::mock( + SchedulerHealthService::class, + [$operatorAlerts] + )->makePartial(); + $health->shouldReceive('recordRowFailure')->times(2); + $task = SchedulerHealthService::TASK_EXPIRE_CHECKOUT; + DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $task) + ->update(['last_scanned_entity_id' => 0]); + $eligible = fn () => DB::table('ptero_alert_configs') + ->where('location_name', 'like', 'scheduler-cursor-%'); + $firstVisited = []; + + $this->assertSame( + 0, + $health->processEligibleRows( + $task, + 'cursor_fixture', + 2, + $eligible, + function (int $candidateId) use ( + &$firstVisited + ): bool { + $firstVisited[] = $candidateId; + throw new \RuntimeException( + 'Temporarily corrupt row.' + ); + } + ) + ); + $this->assertSame( + array_slice($candidateIds, 0, 2), + $firstVisited + ); + + $secondVisited = []; + $this->assertSame( + 1, + $health->processEligibleRows( + $task, + 'cursor_fixture', + 2, + $eligible, + function (int $candidateId) use ( + &$secondVisited, + $candidateIds + ): bool { + $secondVisited[] = $candidateId; + + return $candidateId === $candidateIds[0]; + } + ) + ); + $this->assertSame( + [$candidateIds[2], $candidateIds[0]], + $secondVisited + ); + $this->assertSame( + $candidateIds[0], + (int) DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $task) + ->value('last_scanned_entity_id') + ); + } + + public function test_migration_seeds_every_runtime_task_definition(): void + { + foreach ( + SchedulerHealthService::taskDefinitions() as $taskName => $definition + ) { + $this->assertDatabaseHas('ptero_scheduler_heartbeats', [ + 'task_name' => $taskName, + 'expected_interval_seconds' => $definition[ + 'expected_interval_seconds' + ], + 'lag_threshold_seconds' => $definition[ + 'lag_threshold_seconds' + ], + 'last_scanned_entity_id' => 0, + ]); + } + } + + public function test_partial_run_keeps_previous_success_and_row_identity(): void + { + Carbon::setTestNow('2026-07-27 12:00:00'); + $operatorAlerts = Mockery::mock( + SchedulerOperatorAlertService::class + ); + $operatorAlerts->shouldReceive('notify') + ->once() + ->with(Mockery::on( + fn (array $context): bool => $context['task'] + === SchedulerHealthService::TASK_EXPIRE_CHECKOUT + && $context['entity_type'] === 'resource_reservation' + && $context['entity_id'] === 501 + && $context['error'] === 'invalid reservation' + )); + $health = new SchedulerHealthService($operatorAlerts); + $task = SchedulerHealthService::TASK_EXPIRE_CHECKOUT; + + DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $task) + ->delete(); + + $this->assertSame(2, $health->run($task, fn (): int => 2)); + $healthy = DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $task) + ->first(); + $this->assertSame(2, (int) $healthy->last_processed_count); + $this->assertSame(0, (int) $healthy->last_failure_count); + $this->assertSame( + '2026-07-27 12:00:00', + Carbon::parse($healthy->last_succeeded_at)->toDateTimeString() + ); + + Carbon::setTestNow('2026-07-27 12:01:00'); + $this->assertSame( + 1, + $health->run( + $task, + fn (): int => $health->processRows( + $task, + 'resource_reservation', + [501, 502], + function (int $reservationId): bool { + if ($reservationId === 501) { + throw new \RuntimeException( + 'invalid reservation' + ); + } + + return true; + } + ) + ) + ); + + $partial = DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $task) + ->first(); + $context = json_decode( + (string) $partial->last_failure_context, + true, + 512, + JSON_THROW_ON_ERROR + ); + + $this->assertSame(1, (int) $partial->last_processed_count); + $this->assertSame(1, (int) $partial->last_failure_count); + $this->assertSame(1, (int) $partial->consecutive_failures); + $this->assertSame('invalid reservation', $partial->last_error); + $this->assertSame(501, $context['entity_id']); + $this->assertSame('resource_reservation', $context['entity_type']); + $this->assertSame( + '2026-07-27 12:00:00', + Carbon::parse($partial->last_succeeded_at)->toDateTimeString() + ); + $this->assertSame( + '2026-07-27 12:01:00', + Carbon::parse($partial->last_completed_at)->toDateTimeString() + ); + } + + public function test_lag_is_persisted_alerted_once_and_cleared_by_success(): void + { + Carbon::setTestNow('2026-07-27 12:00:00'); + $task = SchedulerHealthService::TASK_EXPIRE_CHECKOUT; + $operatorAlerts = Mockery::mock( + SchedulerOperatorAlertService::class + ); + $operatorAlerts->shouldReceive('notify') + ->once() + ->with(Mockery::on( + fn (array $context): bool => $context['kind'] + === 'scheduler_lag' + && $context['task'] === $task + && $context['lag_seconds'] === 301 + )); + $health = new SchedulerHealthService($operatorAlerts); + + foreach ( + SchedulerHealthService::taskDefinitions() as $taskName => $definition + ) { + DB::table('ptero_scheduler_heartbeats')->updateOrInsert( + ['task_name' => $taskName], + [ + 'expected_interval_seconds' => $definition[ + 'expected_interval_seconds' + ], + 'lag_threshold_seconds' => $definition[ + 'lag_threshold_seconds' + ], + 'last_succeeded_at' => $taskName === $task + ? now()->subSeconds(301) + : now(), + 'last_alerted_at' => null, + 'lag_detected_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + $this->assertSame(1, $health->checkForLag()); + $this->assertSame(1, $health->checkForLag()); + $this->assertNotNull( + DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $task) + ->value('lag_detected_at') + ); + + $health->run($task, fn (): int => 0); + + $heartbeat = DB::table('ptero_scheduler_heartbeats') + ->where('task_name', $task) + ->first(); + $this->assertNull($heartbeat->lag_detected_at); + $this->assertSame( + '2026-07-27 12:00:00', + Carbon::parse($heartbeat->last_succeeded_at)->toDateTimeString() + ); + } + + public function test_bad_capacity_config_does_not_suppress_later_config(): void + { + DB::table('ptero_alert_configs')->delete(); + DB::table('ptero_scheduler_heartbeats') + ->where( + 'task_name', + SchedulerHealthService::TASK_CAPACITY_ALERTS + ) + ->delete(); + $bad = AlertConfig::create([ + 'location_id' => 71, + 'location_name' => 'Broken', + 'email_notifications' => false, + 'webhook_notifications' => false, + 'cooldown_minutes' => 60, + 'is_active' => true, + ]); + $good = AlertConfig::create([ + 'location_id' => 72, + 'location_name' => 'Healthy', + 'email_notifications' => false, + 'webhook_notifications' => false, + 'cooldown_minutes' => 60, + 'is_active' => true, + ]); + $resources = Mockery::mock(ResourceCalculationService::class); + $resources->shouldReceive('getLocationAvailability') + ->once() + ->with(71) + ->andThrow(new \RuntimeException('panel payload invalid')); + $resources->shouldReceive('getLocationAvailability') + ->once() + ->with(72) + ->andReturn([ + 'location_id' => 72, + 'location_name' => 'Healthy', + 'total_capacity' => [ + 'memory' => 100, + 'cpu' => 100, + 'disk' => 100, + ], + 'total_available' => [ + 'memory' => 100, + 'cpu' => 100, + 'disk' => 100, + ], + ]); + $operatorAlerts = Mockery::mock( + SchedulerOperatorAlertService::class + ); + $operatorAlerts->shouldReceive('notify') + ->once() + ->with(Mockery::on( + fn (array $context): bool => $context['entity_type'] + === 'alert_config_location' + && $context['entity_id'] === ($bad->id.':71') + )); + $this->app->instance( + SchedulerHealthService::class, + new SchedulerHealthService($operatorAlerts) + ); + + $processed = (new AlertService($resources)) + ->checkCapacityAlerts(chunkSize: 1); + + $this->assertSame(2, $processed); + $this->assertDatabaseHas('ptero_alert_configs', [ + 'id' => $good->id, + 'is_active' => true, + ]); + $heartbeat = DB::table('ptero_scheduler_heartbeats') + ->where( + 'task_name', + SchedulerHealthService::TASK_CAPACITY_ALERTS + ) + ->first(); + $context = json_decode( + (string) $heartbeat->last_failure_context, + true, + 512, + JSON_THROW_ON_ERROR + ); + $this->assertSame(($bad->id.':71'), $context['entity_id']); + $this->assertSame('panel payload invalid', $heartbeat->last_error); + } + + public function test_operator_alert_retains_failed_row_identity(): void + { + Notification::fake(); + $admin = User::factory()->create(['role_id' => 1]); + $context = [ + 'kind' => 'row_failure', + 'task' => SchedulerHealthService::TASK_RECONCILE_UPGRADES, + 'entity_type' => 'service_upgrade', + 'entity_id' => 991, + 'error' => 'upgrade snapshot drifted', + ]; + + (new SchedulerOperatorAlertService)->notify($context); + + Notification::assertSentTo( + $admin, + SchedulerTaskFailureNotification::class, + fn (SchedulerTaskFailureNotification $notification): bool => $notification->context === $context + ); + } + + /** + * @return list + */ + private function insertSchedulerCandidates(int $count): array + { + DB::table('ptero_alert_configs')->delete(); + $rows = []; + foreach (range(1, $count) as $index) { + $rows[] = [ + 'location_name' => "scheduler-cursor-{$index}", + 'email_notifications' => false, + 'webhook_notifications' => false, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + DB::table('ptero_alert_configs')->insert($rows); + + return DB::table('ptero_alert_configs') + ->where('location_name', 'like', 'scheduler-cursor-%') + ->orderBy('id') + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->all(); + } +} diff --git a/tests/Unit/SliderConfigReaderServiceTest.php b/tests/Unit/SliderConfigReaderServiceTest.php deleted file mode 100644 index 8158b1d..0000000 --- a/tests/Unit/SliderConfigReaderServiceTest.php +++ /dev/null @@ -1,134 +0,0 @@ -service = new SliderConfigReaderService(); - } - - public function test_get_config_returns_slider_info(): void - { - $product = Product::factory()->create(); - - $this->attachSlider($product, 'Memory', 'memory', [ - 'min' => 1024, - 'max' => 8192, - 'step' => 1024, - 'default' => 4096, - 'unit' => 'MB', - 'display_unit' => 'GB', - 'display_divisor' => 1024, - 'pricing' => ['model' => 'linear', 'rate_per_unit' => 2.00], - ]); - - $this->attachSlider($product, 'CPU', 'cpu', [ - 'min' => 100, - 'max' => 400, - 'step' => 100, - 'default' => 200, - 'unit' => '%', - 'display_unit' => 'cores', - 'display_divisor' => 100, - 'pricing' => ['model' => 'linear', 'rate_per_unit' => 3.00], - ]); - - $config = $this->service->getConfig($product->id); - - $this->assertTrue($config['has_config']); - $this->assertArrayHasKey('memory', $config['sliders']); - $this->assertArrayHasKey('cpu', $config['sliders']); - $this->assertEquals(1024, $config['sliders']['memory']['min']); - $this->assertEquals('GB', $config['sliders']['memory']['display_unit']); - } - - public function test_get_config_returns_empty_payload_when_no_sliders_exist(): void - { - $product = Product::factory()->create(); - - $config = $this->service->getConfig($product->id); - - $this->assertFalse($config['has_config']); - $this->assertSame([], $config['sliders']); - } - - public function test_service_resolves_from_container(): void - { - $this->assertInstanceOf(SliderConfigReaderService::class, app(SliderConfigReaderService::class)); - } - - private function attachSlider(Product $product, string $name, string $resourceType, array $metadata): void - { - $option = ConfigOption::create([ - 'name' => $name, - 'env_variable' => strtoupper($resourceType), - 'type' => 'dynamic_slider', - 'sort' => 0, - 'hidden' => false, - 'upgradable' => true, - 'metadata' => array_merge(['resource_type' => $resourceType], $metadata), - ]); - - DB::table('config_option_products')->insert([ - 'product_id' => $product->id, - 'config_option_id' => $option->id, - ]); - } - public function test_get_config_handles_json_encoded_metadata(): void - { - $product = Product::factory()->create(); - - $option = ConfigOption::create([ - 'name' => 'Memory', - 'env_variable' => 'MEMORY', - 'type' => 'dynamic_slider', - 'sort' => 0, - 'hidden' => false, - 'upgradable' => true, - 'metadata' => null, - ]); - - // Force a JSON string into the metadata column (simulates rows where cast is bypassed) - \Illuminate\Support\Facades\DB::table('config_options') - ->where('id', $option->id) - ->update(['metadata' => json_encode([ - 'resource_type' => 'memory', - 'min' => 1024, - 'max' => 8192, - 'step' => 1024, - 'default' => 4096, - 'unit' => 'MB', - 'display_unit' => 'GB', - 'display_divisor' => 1024, - 'pricing' => ['model' => 'linear', 'rate_per_unit' => 2.00], - ])]); - - DB::table('config_option_products')->insert([ - 'product_id' => $product->id, - 'config_option_id' => $option->id, - ]); - - $config = $this->service->getConfig($product->id); - - $this->assertTrue($config['has_config']); - $this->assertArrayHasKey('memory', $config['sliders']); - $this->assertEquals(1024, $config['sliders']['memory']['min']); - $this->assertEquals('GB', $config['sliders']['memory']['display_unit']); - } - -} diff --git a/tests/Unit/StoreReservationRequestTest.php b/tests/Unit/StoreReservationRequestTest.php deleted file mode 100644 index 79032f8..0000000 --- a/tests/Unit/StoreReservationRequestTest.php +++ /dev/null @@ -1,174 +0,0 @@ -createProductWithSliders($sliders); - /** @var User $user */ - $user = User::withoutEvents(fn () => User::factory()->create()); - $payload['product_id'] = $product->id; - $payload['location_id'] ??= 1; - $payload['cart_item_id'] ??= $this->createCartItemForUser($user, $product->id); - - $request = StoreReservationRequest::createFromBase(Request::create( - '/api/dynamic-pterodactyl/reservation', - 'POST', - $payload, - )); - - $request->setContainer($this->app); - $request->setRedirector($this->app->make(Redirector::class)); - $request->setUserResolver(fn () => $user); - - if ($shouldPass) { - $request->validateResolved(); - $this->addToAssertionCount(1); - - return; - } - - try { - $request->validateResolved(); - $this->fail('Expected validation to fail.'); - } catch (ValidationException $exception) { - $this->assertArrayHasKey($errorField, $exception->errors()); - } - } - - public static function reservationPayloadProvider(): array - { - return [ - 'valid request passes' => [ - 'sliders' => [ - 'memory' => ['min' => 1024, 'max' => 8192, 'step' => 1024, 'unit' => 'MB'], - 'cpu' => ['min' => 100, 'max' => 400, 'step' => 100, 'unit' => '%'], - 'disk' => ['min' => 10240, 'max' => 102400, 'step' => 10240, 'unit' => 'MB'], - ], - 'payload' => ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], - 'shouldPass' => true, - ], - 'out of bounds memory rejected' => [ - 'sliders' => [ - 'memory' => ['min' => 1024, 'max' => 8192, 'step' => 1024, 'unit' => 'MB'], - 'cpu' => ['min' => 100, 'max' => 400, 'step' => 100, 'unit' => '%'], - 'disk' => ['min' => 10240, 'max' => 102400, 'step' => 10240, 'unit' => 'MB'], - ], - 'payload' => ['memory' => 99999, 'cpu' => 200, 'disk' => 51200], - 'shouldPass' => false, - 'errorField' => 'memory', - ], - 'wrong step rejected' => [ - 'sliders' => [ - 'memory' => ['min' => 1024, 'max' => 8192, 'step' => 1024, 'unit' => 'MB'], - 'cpu' => ['min' => 100, 'max' => 400, 'step' => 100, 'unit' => '%'], - 'disk' => ['min' => 10240, 'max' => 102400, 'step' => 10240, 'unit' => 'MB'], - ], - 'payload' => ['memory' => 1536, 'cpu' => 200, 'disk' => 51200], - 'shouldPass' => false, - 'errorField' => 'memory', - ], - 'missing required resource rejected' => [ - 'sliders' => [ - 'memory' => ['min' => 1024, 'max' => 8192, 'step' => 1024, 'unit' => 'MB'], - 'cpu' => ['min' => 100, 'max' => 400, 'step' => 100, 'unit' => '%'], - ], - 'payload' => ['memory' => 4096], - 'shouldPass' => false, - 'errorField' => 'cpu', - ], - 'extra resource rejected' => [ - 'sliders' => [ - 'memory' => ['min' => 1024, 'max' => 8192, 'step' => 1024, 'unit' => 'MB'], - 'cpu' => ['min' => 100, 'max' => 400, 'step' => 100, 'unit' => '%'], - ], - 'payload' => ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], - 'shouldPass' => false, - 'errorField' => 'disk', - ], - 'product without slider config rejected' => [ - 'sliders' => [], - 'payload' => ['memory' => 4096, 'cpu' => 200, 'disk' => 51200], - 'shouldPass' => false, - 'errorField' => 'product_id', - ], - ]; - } - - private function createProductWithSliders(array $sliders): Product - { - /** @var Product $product */ - $product = Product::factory()->create(); - - foreach ($sliders as $resourceType => $slider) { - $optionId = DB::table('config_options')->insertGetId([ - 'name' => ucfirst($resourceType), - 'type' => 'dynamic_slider', - 'sort' => 0, - 'hidden' => false, - 'upgradable' => true, - 'metadata' => json_encode([ - 'resource_type' => $resourceType, - 'min' => $slider['min'], - 'max' => $slider['max'], - 'step' => $slider['step'], - 'default' => $slider['min'], - 'unit' => $slider['unit'], - 'display_unit' => $slider['unit'], - 'display_divisor' => 1, - 'pricing' => ['model' => 'linear', 'rate_per_unit' => 1], - ]), - 'created_at' => now(), - 'updated_at' => now(), - ]); - - DB::table('config_option_products')->insert([ - 'product_id' => $product->id, - 'config_option_id' => $optionId, - ]); - } - - return $product; - } - - private function createCartItemForUser(User $user, int $productId): int - { - $cartId = DB::table('carts')->insertGetId([ - 'ulid' => (string) Str::ulid(), - 'user_id' => $user->id, - 'currency_code' => 'USD', - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return DB::table('cart_items')->insertGetId([ - 'cart_id' => $cartId, - 'product_id' => $productId, - 'quantity' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } -} diff --git a/tests/Unit/UpgradeReservationServiceTest.php b/tests/Unit/UpgradeReservationServiceTest.php new file mode 100644 index 0000000..4d4e55c --- /dev/null +++ b/tests/Unit/UpgradeReservationServiceTest.php @@ -0,0 +1,1696 @@ +assertOrderedSourceMarkers($upgradeSource, [ + '$invoice = $candidate->invoice_id', + 'Service::query()', + '$upgrade = ServiceUpgrade::query()', + '$reservation = UpgradeReservation::query()', + '$invoice->items()', + ]); + $this->assertOrderedSourceMarkers($checkoutSource, [ + '$invoice = $candidate->invoice_id', + '$services = Service::query()', + '$reservations = DB::table(', + '$lockedItems = $invoice !== null', + ]); + } + + use DatabaseTransactions; + + protected function tearDown(): void + { + Mockery::close(); + + parent::tearDown(); + } + + public function test_upgrade_fixture_starts_from_completed_paid_checkout(): void + { + $fixture = $this->fixture(); + $reservation = DB::table('ptero_resource_reservations') + ->where('id', $fixture->checkoutReservationId) + ->first(); + $allocation = DB::table('ptero_reservation_allocations') + ->where('reservation_id', $fixture->checkoutReservationId) + ->first(); + $invoice = $fixture->checkoutInvoice->fresh(); + $payload = json_decode( + (string) $reservation->configuration_payload, + true, + 512, + JSON_THROW_ON_ERROR + ); + + $this->assertSame('checkout', $reservation->purpose); + $this->assertSame('confirmed', $reservation->status); + $this->assertNull($reservation->cart_item_id); + $this->assertSame( + $fixture->cartItemGuardId, + (int) $reservation->cart_item_guard_id + ); + $this->assertFalse( + DB::table('carts') + ->where('id', $reservation->cart_id) + ->exists() + ); + $this->assertSame(0, (int) $reservation->reserved_memory); + $this->assertSame(0, (int) $reservation->reserved_cpu); + $this->assertSame(0, (int) $reservation->reserved_disk); + $this->assertSame(1, (int) $reservation->provisioning_attempts); + $this->assertNotNull($reservation->paid_committed_at); + $this->assertNotNull($reservation->last_provisioning_attempt_at); + $this->assertNotNull($reservation->consumed_at); + $this->assertNotNull($reservation->last_reconciled_at); + $this->assertNotNull($allocation->released_at); + $this->assertSame(Invoice::STATUS_PAID, $invoice->status); + $this->assertSame( + $invoice->due_at->getTimestamp(), + Carbon::parse( + $reservation->guaranteed_until + )->getTimestamp() + ); + $this->assertSame( + $fixture->checkoutPrice, + (string) $invoice->items()->sole()->price + ); + $this->assertSame( + (int) $fixture->select->id, + collect($payload['config_options']) + ->firstWhere('environment_key', 'template')['id'] + ); + $this->assertSame( + (new ReservationConfigurationService)->fingerprint([ + 'product_id' => (int) $fixture->product->id, + 'plan_id' => (int) $fixture->plan->id, + 'currency_code' => 'USD', + 'calculated_price' => $fixture->checkoutPrice, + 'config_options' => $payload['config_options'], + ]), + $reservation->pricing_version + ); + } + + public function test_fixed_node_quote_clamps_32_gb_to_23_gb_and_accepts_select_key(): void + { + $fixture = $this->fixture(); + + $quote = $fixture->upgrades->quoteForService( + $fixture->service, + [ + $fixture->options['memory']->id => 32768, + $fixture->options['cpu']->id => 200, + $fixture->options['disk']->id => 20480, + $fixture->select->id => $fixture->selectChild->id, + ] + ); + + $this->assertTrue($quote['adjusted']); + $this->assertSame(23552, $quote['selection']['memory']); + $this->assertSame(23552, $quote['bounds']['memory']['max']); + } + + public function test_duplicate_active_resource_slider_fails_closed_but_hidden_one_does_not(): void + { + $fixture = $this->fixture(); + $duplicate = $this->option( + $fixture->product, + 'memory', + 1024, + 32768, + 1024, + 4096 + ); + + try { + $fixture->upgrades->quoteForService( + $fixture->service, + $this->selection($fixture) + ); + $this->fail('Expected duplicate memory sliders to fail closed.'); + } catch (InvalidStockConfigurationException $exception) { + $this->assertStringContainsString( + 'Multiple active memory', + $exception->getMessage() + ); + } + + $hiddenFixture = $this->fixture(); + $this->option( + $hiddenFixture->product, + 'memory', + 1024, + 32768, + 1024, + 4096, + hidden: true + ); + + $quote = $hiddenFixture->upgrades->quoteForService( + $hiddenFixture->service, + $this->selection($hiddenFixture) + ); + $this->assertSame(23552, $quote['bounds']['memory']['max']); + } + + public function test_zero_resource_minimum_cannot_become_an_unlimited_upgrade(): void + { + $fixture = $this->fixture(); + $metadata = (array) $fixture->options['memory']->metadata; + $metadata['min'] = 0; + $metadata['default'] = 0; + DB::table('config_options') + ->where('id', $fixture->options['memory']->id) + ->update([ + 'metadata' => json_encode( + $metadata, + JSON_THROW_ON_ERROR + ), + ]); + + $this->expectException( + InvalidStockConfigurationException::class + ); + $this->expectExceptionMessage( + 'Memory min value must be a positive whole number.' + ); + + $fixture->upgrades->quoteForService( + $fixture->service, + $this->selection($fixture) + ); + } + + public function test_upgrade_reserves_only_positive_delta_but_keeps_full_target(): void + { + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + + $this->assertSame(8192, (int) $reservation->memory); + $this->assertSame(100, (int) $reservation->cpu); + $this->assertSame(30720, (int) $reservation->disk); + $this->assertSame(4096, (int) $reservation->reserved_memory); + $this->assertSame(0, (int) $reservation->reserved_cpu); + $this->assertSame(10240, (int) $reservation->reserved_disk); + $this->assertSame(1, (int) $reservation->node_id); + $this->assertSame(1, (int) $reservation->location_id); + $this->assertSame(99, (int) $reservation->external_server_id); + $this->assertSame(44, (int) $reservation->external_user_id); + $this->assertSame( + '10000000-0000-4000-8000-000000000099', + $reservation->external_server_uuid + ); + $this->assertSame( + 'server-99', + $reservation->external_server_identifier + ); + } + + public function test_second_slider_upgrade_uses_current_service_without_rewriting_checkout_identity(): void + { + Queue::fake(); + $fixture = $this->fixture(); + $checkoutBefore = DB::table('ptero_resource_reservations') + ->where('id', $fixture->checkoutReservationId) + ->first([ + 'configuration_payload', + 'configuration_fingerprint', + 'product_id', + 'plan_id', + ]); + [$firstUpgrade, $firstInvoice] = $this->upgrade($fixture); + $firstReservation = $fixture->upgrades->reserveForUpgrade( + $firstUpgrade, + $firstInvoice->due_at + ); + DB::table('invoices') + ->where('id', $firstInvoice->id) + ->update(['status' => Invoice::STATUS_PAID]); + $fixture->upgrades->commitPaidUpgrade( + $firstUpgrade, + $firstInvoice->fresh() + ); + $provisioningUpgrade = app(ServiceUpgradeService::class) + ->beginProvisioning($firstUpgrade->fresh()); + $contract = $fixture->upgrades->beginProvisioning( + $provisioningUpgrade + ); + $fixture->remote->server['memory'] = 8192; + $fixture->remote->server['cpu'] = 100; + $fixture->remote->server['disk'] = 30720; + + app(ServiceUpgradeService::class)->complete( + $provisioningUpgrade, + $contract['provisioning_lease_id'] + ); + + $this->assertSame( + ServiceUpgrade::STATUS_COMPLETED, + $firstUpgrade->fresh()->status + ); + $this->assertSame( + 'confirmed', + $firstReservation->fresh()->status + ); + $checkoutAfterFirst = DB::table( + 'ptero_resource_reservations' + ) + ->where('id', $fixture->checkoutReservationId) + ->first([ + 'configuration_payload', + 'configuration_fingerprint', + 'product_id', + 'plan_id', + ]); + $this->assertEquals($checkoutBefore, $checkoutAfterFirst); + + $fixture->service->refresh(); + [$secondUpgrade, $secondInvoice] = $this->upgrade($fixture, [ + 'memory' => 12288, + 'cpu' => 200, + 'disk' => 40960, + ]); + + $secondReservation = $fixture->upgrades->reserveForUpgrade( + $secondUpgrade->fresh(), + $secondInvoice->due_at + ); + $payload = (array) $secondReservation->configuration_payload; + + $this->assertSame([ + 'memory' => 8192, + 'cpu' => 100, + 'disk' => 30720, + ], $payload['source']); + $this->assertSame([ + 'memory' => 12288, + 'cpu' => 200, + 'disk' => 40960, + ], $payload['target']); + $this->assertSame( + $fixture->product->id, + $secondUpgrade->fresh()->product_id + ); + $this->assertSame( + $fixture->plan->id, + $secondUpgrade->fresh()->plan_id + ); + $checkoutAfterSecond = DB::table( + 'ptero_resource_reservations' + ) + ->where('id', $fixture->checkoutReservationId) + ->first([ + 'configuration_payload', + 'configuration_fingerprint', + 'product_id', + 'plan_id', + ]); + $this->assertEquals($checkoutBefore, $checkoutAfterSecond); + } + + public function test_real_upgrade_reservation_is_accepted_by_stock_calculation_across_decimal_drivers(): void + { + $fixture = $this->fixture(); + NodeCapacityPolicy::create([ + 'panel_identity' => hash('sha256', 'https://panel.example'), + 'node_uuid' => 'node-1', + 'node_id' => 1, + 'location_id' => 1, + 'cpu_capacity_percent' => 800, + 'cpu_overcommit_bps' => 10000, + 'enabled' => true, + ]); + [$upgrade, $invoice] = $this->upgrade($fixture); + $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + + $node = [ + 'id' => 1, + 'uuid' => 'node-1', + 'name' => 'Node 1', + 'fqdn' => 'node-1.example.com', + 'public' => true, + 'maintenance_mode' => false, + 'location_id' => 1, + 'memory' => 32768, + 'disk' => 512000, + 'memory_overallocate' => 0, + 'disk_overallocate' => 0, + 'allocated_resources' => [ + 'memory' => 4096, + 'disk' => 20480, + ], + ]; + $fixture->inventory->shouldReceive('nodesInLocation') + ->once() + ->with(1) + ->andReturn([$node]); + $fixture->inventory->shouldReceive('serversForNodes') + ->once() + ->with([1]) + ->andReturn([1 => [$fixture->remote->server]]); + $fixture->inventory->shouldReceive( + 'availableAllocationsForNode' + )->once()->with(1)->andReturn([[ + 'id' => 9001, + 'ip' => '192.0.2.250', + 'port' => 25566, + ]]); + + $availability = (new ResourceCalculationService( + $fixture->inventory + ))->getLocationAvailability(1); + + $this->assertSame([ + 'memory' => 4096, + 'cpu' => 0, + 'disk' => 10240, + ], $availability['nodes'][0]['reserved']); + } + + public function test_unchanged_resource_vector_is_not_an_upgrade(): void + { + $fixture = $this->fixture(); + [$upgrade] = $this->upgrade($fixture, [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + ]); + + $this->expectException(InvalidResourceSelectionException::class); + $this->expectExceptionMessage('must change at least one'); + + $fixture->upgrades->reserveForUpgrade( + $upgrade->fresh(), + now()->addDays(7) + ); + } + + public function test_checkout_identity_survives_customer_email_edits(): void + { + $fixture = $this->fixture(); + $checkoutEmail = strtolower((string) $fixture->user->email); + $fixture->user->update([ + 'email' => "renamed-{$fixture->user->id}@example.com", + ]); + $fixture->service->unsetRelation('user'); + [$upgrade, $invoice] = $this->upgrade($fixture); + + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + $payload = (array) $reservation->configuration_payload; + + $this->assertSame(1, $payload['nest_id']); + $this->assertSame(2, $payload['egg_id']); + $this->assertSame($checkoutEmail, $payload['user_email']); + } + + public function test_active_checkout_blocks_product_identity_setting_edits(): void + { + $fixture = $this->fixture(); + + foreach ([ + 'nest_id' => 77, + 'egg_id' => 88, + ] as $key => $value) { + try { + $fixture->product->settings()->updateOrCreate( + ['key' => $key], + [ + 'value' => $value, + 'type' => 'integer', + 'encrypted' => false, + ] + ); + $this->fail( + "Expected the active checkout to protect {$key}." + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'product setting identity is required', + strtolower($exception->getMessage()) + ); + } + + $this->assertSame( + $key === 'nest_id' ? 1 : 2, + (int) $fixture->product->settings() + ->where('key', $key) + ->value('value') + ); + } + } + + public function test_server_identity_drift_is_rejected_before_upgrade_reservation(): void + { + $mutations = [ + ['uuid', '20000000-0000-4000-8000-000000000099'], + ['identifier', 'replacement-server'], + ['external_id', 'another-service'], + ['user_id', 45], + ['user_external_id', 'paymenter-user-999'], + ['nest_id', 7], + ['egg_id', 8], + ]; + + foreach ($mutations as [$field, $value]) { + $fixture = $this->fixture(); + $fixture->remote->server[$field] = $value; + [$upgrade, $invoice] = $this->upgrade($fixture); + + try { + $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + $this->fail("Expected {$field} drift to fail closed."); + } catch (InvalidResourceSelectionException $exception) { + $this->assertStringContainsString( + 'immutable checkout commitment', + $exception->getMessage() + ); + } + } + } + + public function test_paid_upgrade_commit_returns_explicit_handled_proof(): void + { + Queue::fake(); + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + DB::table('invoices')->where('id', $invoice->id)->update([ + 'status' => Invoice::STATUS_PAID, + ]); + $invoice->refresh(); + + $this->assertTrue( + $fixture->upgrades->commitPaidUpgrade($upgrade, $invoice) + ); + $this->assertSame( + 'paid_committed', + $reservation->fresh()->status + ); + $this->assertSame( + ServiceUpgrade::STATUS_PAID_COMMITTED, + $upgrade->fresh()->status + ); + } + + public function test_paid_commit_transition_does_not_bypass_invoice_line_proof(): void + { + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + DB::table('invoice_items') + ->where('id', $invoice->items()->firstOrFail()->id) + ->update(['price' => 1]); + DB::table('invoices')->where('id', $invoice->id)->update([ + 'status' => Invoice::STATUS_PAID, + ]); + + try { + $fixture->upgrades->commitPaidUpgrade( + $upgrade, + $invoice->fresh() + ); + $this->fail( + 'Expected the paid commit to reject a tampered invoice line.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'invoice line', + strtolower($exception->getMessage()) + ); + } + + $this->assertSame('pending', $reservation->fresh()->status); + $this->assertNull($reservation->fresh()->paid_committed_at); + } + + public function test_dynamic_upgrade_rejects_non_resource_configuration_change(): void + { + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade( + $fixture, + selectValueId: $fixture->selectAlternate->id + ); + + $this->expectException(InvalidResourceSelectionException::class); + $this->expectExceptionMessage( + 'may change only RAM, CPU, and disk' + ); + + $fixture->upgrades->reserveForUpgrade($upgrade, $invoice->due_at); + } + + public function test_old_worker_cannot_clear_newer_upgrade_lease(): void + { + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + $reservation->forceFill([ + 'status' => 'paid_committed', + 'provisioning_lease_id' => 'new-worker', + 'provisioning_started_at' => now(), + ])->save(); + + $this->assertFalse($fixture->upgrades->failProvisioning( + $upgrade, + new \RuntimeException('old worker failed'), + 'old-worker' + )); + $this->assertSame( + 'new-worker', + $reservation->fresh()->provisioning_lease_id + ); + + $this->assertTrue($fixture->upgrades->failProvisioning( + $upgrade, + new \RuntimeException('current worker failed'), + 'new-worker' + )); + $this->assertNull($reservation->fresh()->provisioning_lease_id); + } + + public function test_paid_upgrade_payload_is_verified_before_leasing(): void + { + Queue::fake(); + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + DB::table('invoices')->where('id', $invoice->id)->update([ + 'status' => Invoice::STATUS_PAID, + ]); + $invoice->refresh(); + $fixture->upgrades->commitPaidUpgrade($upgrade, $invoice); + + $payload = (array) $reservation->fresh()->configuration_payload; + $payload['target']['memory'] = 16384; + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + ]); + + try { + $fixture->upgrades->beginProvisioning($upgrade->fresh()); + $this->fail( + 'Expected a mutated paid upgrade payload to fail closed.' + ); + } catch (PermanentProvisioningException $exception) { + $this->assertStringContainsString( + 'immutable integrity', + $exception->getMessage() + ); + } + + $this->assertNull( + $reservation->fresh()->provisioning_lease_id + ); + Queue::assertPushed( + UpgradeJob::class, + fn (UpgradeJob $job): bool => $job->serviceUpgrade->is($upgrade) + ); + } + + public function test_tampered_upgrade_payload_is_rejected_before_payment(): void + { + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + $payload = (array) $reservation->configuration_payload; + $payload['target']['memory'] = 16384; + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + ]); + + $failure = $fixture->upgrades->preflightPaidUpgrade( + $upgrade, + $invoice + ); + + $this->assertStringContainsString( + 'immutable integrity', + strtolower((string) $failure) + ); + $this->assertSame( + ServiceUpgrade::STATUS_CANCELLED, + $upgrade->fresh()->status + ); + $this->assertSame(Invoice::STATUS_CANCELLED, $invoice->fresh()->status); + $this->assertSame('cancelled', $reservation->fresh()->status); + } + + public function test_live_target_config_drift_is_rejected_before_payment(): void + { + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + DB::table('service_configs') + ->where('configurable_type', ServiceUpgrade::class) + ->where('configurable_id', $upgrade->id) + ->where( + 'config_option_id', + $fixture->options['memory']->id + ) + ->update(['slider_value' => 16384]); + + $failure = $fixture->upgrades->preflightPaidUpgrade( + $upgrade, + $invoice + ); + + $this->assertStringContainsString( + 'immutable integrity', + strtolower((string) $failure) + ); + $this->assertSame( + ServiceUpgrade::STATUS_CANCELLED, + $upgrade->fresh()->status + ); + $this->assertSame('cancelled', $reservation->fresh()->status); + } + + public function test_hidden_slider_cannot_detach_existing_capacity_commitment(): void + { + Queue::fake(); + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + DB::table('config_options') + ->whereIn('id', collect($fixture->options)->pluck('id')) + ->update(['hidden' => true]); + DB::table('invoices')->where('id', $invoice->id)->update([ + 'status' => Invoice::STATUS_PAID, + ]); + $invoice->refresh(); + + $this->assertTrue( + $fixture->upgrades->commitPaidUpgrade($upgrade, $invoice) + ); + $this->assertSame( + 'paid_committed', + $reservation->fresh()->status + ); + $this->assertSame( + ServiceUpgrade::STATUS_PAID_COMMITTED, + $upgrade->fresh()->status + ); + } + + public function test_server_extension_drift_is_rejected_before_payment(): void + { + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + DB::table('extensions') + ->where('id', $fixture->server->id) + ->update(['extension' => 'DifferentServer']); + + $failure = $fixture->upgrades->preflightPaidUpgrade( + $upgrade, + $invoice + ); + + $this->assertStringContainsString( + 'changed after the upgrade was quoted', + strtolower((string) $failure) + ); + $this->assertSame( + ServiceUpgrade::STATUS_CANCELLED, + $upgrade->fresh()->status + ); + $this->assertSame('cancelled', $reservation->fresh()->status); + } + + public function test_completion_rechecks_live_target_inside_core_transaction(): void + { + Queue::fake(); + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + DB::table('invoices')->where('id', $invoice->id)->update([ + 'status' => Invoice::STATUS_PAID, + ]); + $invoice->refresh(); + $fixture->upgrades->commitPaidUpgrade($upgrade, $invoice); + $provisioningUpgrade = app(ServiceUpgradeService::class) + ->beginProvisioning($upgrade->fresh()); + $contract = $fixture->upgrades->beginProvisioning( + $provisioningUpgrade + ); + DB::table('service_configs') + ->where('configurable_type', ServiceUpgrade::class) + ->where('configurable_id', $upgrade->id) + ->where( + 'config_option_id', + $fixture->options['memory']->id + ) + ->update(['slider_value' => 16384]); + + try { + DB::transaction( + fn () => $fixture->upgrades->completeProvisioning( + $provisioningUpgrade, + $contract['provisioning_lease_id'] + ) + ); + $this->fail( + 'Expected completion-time target drift to fail closed.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'immutable integrity', + strtolower($exception->getMessage()) + ); + } + + $this->assertSame( + 'paid_committed', + $reservation->fresh()->status + ); + $this->assertNotNull( + $reservation->fresh()->provisioning_lease_id + ); + } + + public function test_tampered_invoice_line_is_cancelled_before_paid_transition(): void + { + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + DB::table('invoice_items') + ->where('id', $invoice->items()->firstOrFail()->id) + ->update(['price' => 1]); + + $failure = $fixture->upgrades->preflightPaidUpgrade( + $upgrade, + $invoice + ); + $this->assertStringContainsString( + 'invoice line', + strtolower((string) $failure) + ); + + $this->assertSame( + ServiceUpgrade::STATUS_CANCELLED, + $upgrade->fresh()->status + ); + $this->assertSame(Invoice::STATUS_CANCELLED, $invoice->fresh()->status); + $this->assertSame('cancelled', $reservation->fresh()->status); + } + + public function test_renewal_after_quote_invalidates_upgrade_before_payment(): void + { + $fixture = $this->fixture(); + $fixture->service = app( + ServiceBillingAnchorMutationCoordinator::class + )->update($fixture->service, [ + 'expires_at' => now()->addDays(10), + ]); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + + try { + app(ServiceBillingAnchorMutationCoordinator::class)->update( + $fixture->service, + ['expires_at' => now()->addDays(35)] + ); + $this->fail( + 'Expected the active upgrade to serialize renewal.' + ); + } catch (\RuntimeException $exception) { + $this->assertStringContainsString( + 'cannot change while an upgrade is active', + strtolower($exception->getMessage()) + ); + } + + // Model an internal fulfillment transition that changes the billing + // anchor after the quote despite that public guard. The extension must + // still fail closed if another coordinated path drifts it. + ServiceBillingAnchorMutationCoordinator::run( + $fixture->service, + function () use ($fixture): void { + $fixture->service->expires_at = now()->addDays(35); + $fixture->service->save(); + } + ); + + $failure = $fixture->upgrades->preflightPaidUpgrade( + $upgrade, + $invoice + ); + $this->assertStringContainsString( + 'service changed', + strtolower((string) $failure) + ); + + $this->assertSame( + ServiceUpgrade::STATUS_CANCELLED, + $upgrade->fresh()->status + ); + $this->assertSame(Invoice::STATUS_CANCELLED, $invoice->fresh()->status); + $this->assertSame('cancelled', $reservation->fresh()->status); + } + + public function test_add_payment_preserves_rejected_upgrade_attention_and_payment_evidence(): void + { + Queue::fake(); + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + + // Simulate storage tampering beneath the model layer so the real + // synchronous payment listener must preserve external payment + // evidence without consuming an invalid capacity commitment. + $payload = (array) $reservation->configuration_payload; + $payload['target']['memory'] = 16384; + DB::table('ptero_resource_reservations') + ->where('id', $reservation->id) + ->update([ + 'configuration_payload' => json_encode( + $payload, + JSON_THROW_ON_ERROR + ), + ]); + + ExtensionHelper::addPayment( + $invoice->id, + null, + (string) $upgrade->quoted_amount, + transactionId: 'paid-but-stale-upgrade' + ); + + $this->assertDatabaseHas('invoice_transactions', [ + 'invoice_id' => $invoice->id, + 'transaction_id' => 'paid-but-stale-upgrade', + 'status' => InvoiceTransactionStatus::Succeeded->value, + ]); + $this->assertSame(Invoice::STATUS_PENDING, $invoice->fresh()->status); + $this->assertNotNull( + $invoice->fresh()->payment_attention_required_at + ); + $this->assertStringContainsString( + 'refund or account-credit review', + (string) $invoice->fresh()->payment_attention_reason + ); + $this->assertSame( + ServiceUpgrade::STATUS_NEEDS_ATTENTION, + $upgrade->fresh()->status + ); + $this->assertSame('cancelled', $reservation->fresh()->status); + $this->assertNull($reservation->fresh()->upgrade_guard_id); + Queue::assertNotPushed(UpgradeJob::class); + } + + public function test_expiry_with_partial_payment_releases_delta_and_requires_attention(): void + { + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + $reservation->forceFill([ + 'expires_at' => now()->subSecond(), + 'guaranteed_until' => now()->subSecond(), + ])->save(); + InvoiceTransaction::create([ + 'invoice_id' => $invoice->id, + 'amount' => 1, + 'fee' => 0, + 'status' => InvoiceTransactionStatus::Processing, + ]); + + $this->assertSame(1, $fixture->upgrades->expireUnpaidUpgrades()); + $this->assertSame('expired', $reservation->fresh()->status); + $this->assertSame( + ServiceUpgrade::STATUS_NEEDS_ATTENTION, + $upgrade->fresh()->status + ); + $this->assertSame(Invoice::STATUS_PENDING, $invoice->fresh()->status); + $this->assertNotNull( + $invoice->fresh()->payment_attention_required_at + ); + } + + public function test_stale_provisioning_recovery_uses_core_lifecycle_coordinator(): void + { + Queue::fake(); + $fixture = $this->fixture(); + [$upgrade, $invoice] = $this->upgrade($fixture); + $reservation = $fixture->upgrades->reserveForUpgrade( + $upgrade, + $invoice->due_at + ); + DB::table('invoices')->where('id', $invoice->id)->update([ + 'status' => Invoice::STATUS_PAID, + ]); + + $this->assertTrue( + $fixture->upgrades->commitPaidUpgrade( + $upgrade, + $invoice->fresh() + ) + ); + $provisioningUpgrade = app(ServiceUpgradeService::class) + ->beginProvisioning($upgrade->fresh()); + $fixture->upgrades->beginProvisioning($provisioningUpgrade); + DB::table('service_upgrades') + ->where('id', $upgrade->id) + ->update([ + 'provisioning_started_at' => now()->subMinutes(11), + ]); + + $this->assertSame( + 1, + $fixture->upgrades->reconcileStalledUpgrades() + ); + $this->assertSame( + ServiceUpgrade::STATUS_RETRYABLE_FAILED, + $upgrade->fresh()->status + ); + $this->assertNull( + $reservation->fresh()->provisioning_lease_id + ); + } + + private function fixture(): object + { + Currency::firstOrCreate( + ['code' => 'USD'], + [ + 'name' => 'US Dollar', + 'prefix' => '$', + 'suffix' => '', + 'format' => '1,000.00', + ] + ); + $user = User::factory()->create(); + $server = Server::create([ + 'name' => 'Pterodactyl', + 'extension' => 'Pterodactyl', + 'type' => 'server', + 'enabled' => true, + ]); + $server->settings()->create([ + 'key' => 'host', + 'value' => 'https://panel.example', + 'type' => 'string', + 'encrypted' => false, + ]); + $product = Product::factory()->create([ + 'server_id' => $server->id, + 'hidden' => false, + ]); + $product->settings()->create([ + 'key' => 'location_ids', + 'value' => [1], + 'type' => 'array', + 'encrypted' => false, + ]); + foreach (['nest_id' => 1, 'egg_id' => 2] as $key => $value) { + $product->settings()->create([ + 'key' => $key, + 'value' => $value, + 'type' => 'integer', + 'encrypted' => false, + ]); + } + $plan = Plan::factory()->create([ + 'priceable_id' => $product->id, + 'priceable_type' => Product::class, + 'name' => 'Monthly', + 'billing_unit' => 'month', + 'billing_period' => 1, + 'type' => 'recurring', + ]); + Price::factory()->create([ + 'plan_id' => $plan->id, + 'price' => 10, + 'setup_fee' => 0, + 'currency_code' => 'USD', + ]); + + $options = [ + 'memory' => $this->option( + $product, + 'memory', + 1024, + 32768, + 1024, + 4096 + ), + 'cpu' => $this->option( + $product, + 'cpu', + 100, + 800, + 100, + 200 + ), + 'disk' => $this->option( + $product, + 'disk', + 10240, + 102400, + 10240, + 20480 + ), + ]; + $select = ConfigOption::create([ + 'name' => 'Template', + 'env_variable' => 'template', + 'type' => 'select', + 'hidden' => false, + 'upgradable' => true, + ]); + ConfigOptionProduct::create([ + 'product_id' => $product->id, + 'config_option_id' => $select->id, + ]); + $selectChild = ConfigOption::create([ + 'name' => 'Default', + 'env_variable' => 'default', + 'type' => 'option', + 'hidden' => false, + 'parent_id' => $select->id, + ]); + $selectAlternate = ConfigOption::create([ + 'name' => 'Alternate', + 'env_variable' => 'alternate', + 'type' => 'option', + 'hidden' => false, + 'parent_id' => $select->id, + ]); + foreach ([$selectChild, $selectAlternate] as $selectValue) { + $selectPlan = Plan::factory()->create([ + 'priceable_id' => $selectValue->id, + 'priceable_type' => ConfigOption::class, + 'name' => 'Monthly', + 'billing_unit' => 'month', + 'billing_period' => 1, + 'type' => 'recurring', + ]); + Price::factory()->create([ + 'plan_id' => $selectPlan->id, + 'price' => 0, + 'setup_fee' => 0, + 'currency_code' => 'USD', + ]); + } + + $resourceValues = [ + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + ]; + $checkoutPrice = number_format( + 10 + array_sum($resourceValues), + 2, + '.', + '' + ); + $checkoutStartedAt = now()->subMinutes(4); + $checkoutCommittedAt = now()->subMinutes(3); + $checkoutCompletedAt = now()->subMinutes(2); + $checkoutDueAt = now()->addDays(7); + $service = CapacityServiceCreationCoordinator::run( + fn () => Service::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + 'plan_id' => $plan->id, + 'quantity' => 1, + 'price' => $checkoutPrice, + 'currency_code' => 'USD', + 'status' => Service::STATUS_ACTIVE, + 'expires_at' => now()->addMonth(), + ]) + ); + foreach ($resourceValues as $resource => $value) { + ServiceConfig::create([ + 'configurable_id' => $service->id, + 'configurable_type' => Service::class, + 'config_option_id' => $options[$resource]->id, + 'config_value_id' => null, + 'slider_value' => $value, + ]); + } + ServiceConfig::create([ + 'configurable_id' => $service->id, + 'configurable_type' => Service::class, + 'config_option_id' => $select->id, + 'config_value_id' => $selectChild->id, + 'slider_value' => null, + ]); + + $cart = Cart::create([ + 'user_id' => $user->id, + 'currency_code' => 'USD', + ]); + $panelIdentity = hash('sha256', 'https://panel.example'); + $checkoutEmail = strtolower((string) $user->email); + $allocationId = 1000 + (int) $service->id; + $allocationIp = '192.0.2.'.(((int) $service->id % 250) + 1); + $allocationPort = 20000 + ((int) $service->id % 40000); + $configurationOptions = collect($resourceValues) + ->map(function (int $value, string $resource) use ($options): array { + $option = $options[$resource]; + + return [ + 'id' => (int) $option->id, + 'type' => 'dynamic_slider', + 'environment_key' => $resource, + 'resource_type' => $resource, + 'value' => (float) $value, + 'metadata' => (array) $option->metadata, + ]; + }) + ->sortBy('id') + ->values() + ->all(); + $configurationOptions[] = [ + 'id' => (int) $select->id, + 'type' => 'select', + 'environment_key' => 'template', + 'resource_type' => null, + 'value' => (float) $selectChild->id, + 'metadata' => (array) ($select->metadata ?? []), + ]; + usort( + $configurationOptions, + fn (array $left, array $right): int => $left['id'] <=> $right['id'] + ); + $cartSelections = collect($configurationOptions) + ->map(function (array $option) use ( + $options, + $select + ): array { + $model = $option['type'] === 'dynamic_slider' + ? $options[$option['resource_type']] + : $select; + + return [ + 'option_id' => (int) $option['id'], + 'option_name' => (string) $model->name, + 'option_type' => (string) $option['type'], + 'option_env_variable' => (string) $option['environment_key'], + 'value' => $option['value'], + ]; + }) + ->all(); + $cartItemGuardId = DB::table('cart_items')->insertGetId([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'plan_id' => $plan->id, + 'config_options' => json_encode( + $cartSelections, + JSON_THROW_ON_ERROR + ), + 'checkout_config' => json_encode([], JSON_THROW_ON_ERROR), + 'quantity' => 1, + 'created_at' => $checkoutStartedAt, + 'updated_at' => $checkoutStartedAt, + ]); + $checkoutInvoice = Invoice::factory()->create([ + 'user_id' => $user->id, + 'currency_code' => 'USD', + 'status' => Invoice::STATUS_PENDING, + 'due_at' => $checkoutDueAt, + ]); + $checkoutInvoice->items()->create([ + 'description' => (string) $service->description, + 'price' => $checkoutPrice, + 'quantity' => 1, + 'reference_id' => $service->id, + 'reference_type' => Service::class, + ]); + DB::table('invoices') + ->where('id', $checkoutInvoice->id) + ->update([ + 'status' => Invoice::STATUS_PAID, + 'updated_at' => $checkoutCommittedAt, + ]); + $checkoutInvoice->refresh(); + $checkoutConfiguration = new ReservationConfigurationService; + $pricingVersion = $checkoutConfiguration->fingerprint([ + 'product_id' => (int) $product->id, + 'plan_id' => (int) $plan->id, + 'currency_code' => 'USD', + 'calculated_price' => $checkoutPrice, + 'config_options' => $configurationOptions, + ]); + $checkoutPayload = $checkoutConfiguration->withPlacement([ + 'customer_id' => (int) $user->id, + 'cart_id' => (int) $cart->id, + 'server_extension_id' => (int) $server->id, + 'panel_identity' => $panelIdentity, + 'product_id' => (int) $product->id, + 'plan_id' => (int) $plan->id, + 'quantity' => 1, + 'currency_code' => 'USD', + 'location_id' => 1, + 'resources' => $resourceValues, + 'calculated_price' => $checkoutPrice, + 'pricing_version' => $pricingVersion, + 'formula_version' => ReservationConfigurationService::FORMULA_VERSION, + 'config_options' => $configurationOptions, + 'allocation_requirements' => [ + 'required_count' => 1, + 'mappings' => [[ + 'environment_key' => 'SERVER_PORT', + 'requested_port' => null, + 'is_primary' => true, + ]], + 'allowed_port_ranges' => [], + 'dedicated_ip' => false, + ], + 'provisioning_identity' => [ + 'nest_id' => 1, + 'egg_id' => 2, + 'user_external_id' => "paymenter-user-{$user->id}", + 'user_email' => $checkoutEmail, + ], + ], 1, [[ + 'allocation_id' => $allocationId, + 'ip' => $allocationIp, + 'port' => $allocationPort, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + ]]); + $checkoutReservationId = DB::table( + 'ptero_resource_reservations' + )->insertGetId([ + 'purpose' => 'checkout', + 'token' => hash('sha256', "checkout:{$service->id}"), + 'cart_item_id' => $cartItemGuardId, + 'cart_item_guard_id' => $cartItemGuardId, + 'cart_id' => $cart->id, + 'server_extension_id' => $server->id, + 'service_id' => $service->id, + 'service_guard_id' => $service->id, + 'invoice_id' => $checkoutInvoice->id, + 'user_id' => $user->id, + 'node_id' => 1, + 'location_id' => 1, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + 'reserved_memory' => 0, + 'reserved_cpu' => 0, + 'reserved_disk' => 0, + 'calculated_price' => $checkoutPrice, + 'pricing_breakdown' => json_encode([], JSON_THROW_ON_ERROR), + 'status' => 'confirmed', + 'expires_at' => $checkoutDueAt, + 'guaranteed_until' => $checkoutDueAt, + 'paid_committed_at' => $checkoutCommittedAt, + 'provisioning_attempts' => 1, + 'last_provisioning_attempt_at' => $checkoutCommittedAt, + 'panel_identity' => $panelIdentity, + 'product_id' => $product->id, + 'plan_id' => $plan->id, + 'quantity' => 1, + 'currency_code' => 'USD', + 'configuration_fingerprint' => $checkoutConfiguration->fingerprint($checkoutPayload), + 'configuration_payload' => json_encode( + $checkoutPayload, + JSON_THROW_ON_ERROR + ), + 'pricing_version' => $pricingVersion, + 'formula_version' => ReservationConfigurationService::FORMULA_VERSION, + 'external_server_id' => 99, + 'external_user_id' => 44, + 'external_server_uuid' => '10000000-0000-4000-8000-000000000099', + 'external_server_identifier' => 'server-99', + 'last_reconciled_at' => $checkoutCompletedAt, + 'consumed_at' => $checkoutCompletedAt, + 'created_at' => $checkoutStartedAt, + 'updated_at' => $checkoutCompletedAt, + ]); + DB::table('ptero_reservation_allocations')->insert([ + 'reservation_id' => $checkoutReservationId, + 'panel_identity' => $panelIdentity, + 'node_id' => 1, + 'allocation_id' => $allocationId, + 'ip' => $allocationIp, + 'port' => $allocationPort, + 'environment_key' => 'SERVER_PORT', + 'is_primary' => true, + 'released_at' => $checkoutCompletedAt, + 'created_at' => $checkoutStartedAt, + 'updated_at' => $checkoutCompletedAt, + ]); + DB::table('carts')->where('id', $cart->id)->delete(); + + $remote = (object) ['server' => [ + 'id' => 99, + 'uuid' => '10000000-0000-4000-8000-000000000099', + 'identifier' => 'server-99', + 'external_id' => (string) $service->id, + 'user_id' => 44, + 'user_external_id' => "paymenter-user-{$user->id}", + 'user_email' => $checkoutEmail, + 'nest_id' => 1, + 'egg_id' => 2, + 'node' => 1, + 'memory' => 4096, + 'cpu' => 200, + 'disk' => 20480, + 'swap' => 0, + 'io' => 500, + 'threads' => null, + 'database_limit' => 2, + 'allocation_limit' => 0, + 'backup_limit' => 3, + 'allocation' => $allocationId, + 'assigned_allocation_ids' => [$allocationId], + ]]; + $inventory = Mockery::mock(PterodactylInventoryService::class); + $inventory->shouldReceive('assertExclusiveProvisioningControl') + ->zeroOrMoreTimes(); + $inventory->shouldReceive('panelIdentity') + ->zeroOrMoreTimes() + ->andReturn($panelIdentity); + $inventory->shouldReceive('serverByExternalId') + ->zeroOrMoreTimes() + ->with($service->id) + ->andReturnUsing(fn (): array => $remote->server); + $inventory->shouldReceive('nodes')->zeroOrMoreTimes()->andReturn([[ + 'id' => 1, + 'uuid' => 'node-1', + 'location_id' => 1, + ]]); + $resources = Mockery::mock(ResourceCalculationService::class); + $resources->shouldReceive('getNodeAvailability') + ->zeroOrMoreTimes() + ->with(1) + ->andReturn([ + 'available' => [ + 'memory' => 19456, + 'cpu' => 600, + 'disk' => 512000, + ], + ]); + $resources->shouldReceive('verifyNodeCapacity') + ->zeroOrMoreTimes() + ->andReturn(true); + $upgrades = new UpgradeReservationService( + $inventory, + $resources + ); + $this->app->instance(UpgradeReservationService::class, $upgrades); + + return (object) [ + 'user' => $user, + 'server' => $server, + 'product' => $product, + 'plan' => $plan, + 'service' => $service, + 'options' => $options, + 'select' => $select, + 'selectChild' => $selectChild, + 'selectAlternate' => $selectAlternate, + 'remote' => $remote, + 'inventory' => $inventory, + 'upgrades' => $upgrades, + 'checkoutReservationId' => $checkoutReservationId, + 'checkoutInvoice' => $checkoutInvoice, + 'checkoutPrice' => $checkoutPrice, + 'cartItemGuardId' => $cartItemGuardId, + ]; + } + + /** + * @param array{memory: int, cpu: int, disk: int}|null $resources + */ + private function upgrade( + object $fixture, + ?array $resources = null, + ?int $selectValueId = null + ): array { + $dueAt = now()->addDays(7); + $upgrade = ServiceUpgrade::create([ + 'service_id' => $fixture->service->id, + 'product_id' => $fixture->product->id, + 'plan_id' => $fixture->plan->id, + 'invoice_id' => null, + 'status' => ServiceUpgrade::STATUS_AWAITING_PAYMENT, + 'active_service_guard_id' => $fixture->service->id, + 'currency_code' => 'USD', + 'capacity_mode' => ServiceUpgrade::CAPACITY_MODE_DYNAMIC, + ]); + foreach ($resources ?? [ + 'memory' => 8192, + 'cpu' => 100, + 'disk' => 30720, + ] as $resource => $value) { + $upgrade->configs()->create([ + 'config_option_id' => $fixture->options[$resource]->id, + 'config_value_id' => null, + 'slider_value' => $value, + ]); + } + if ($selectValueId !== null) { + $upgrade->configs()->create([ + 'config_option_id' => $fixture->select->id, + 'config_value_id' => $selectValueId, + 'slider_value' => null, + ]); + } + $upgrade->load([ + 'service.product.server.settings', + 'service.product.settings', + 'service.configs.configOption', + 'service.configs.configValue', + 'product.server.settings', + 'product.settings', + 'plan.prices', + 'configs.configOption', + 'configs.configValue', + ]); + $upgrade->captureSnapshots(); + $upgrade->quoted_amount = round( + (float) $upgrade->signedUpgradePrice()->price, + 2 + ); + $upgrade->credit_amount = $upgrade->signedCreditAmount(); + ServiceUpgradeMutationCoordinator::save($upgrade); + if ((float) $upgrade->quoted_amount <= 0) { + return [$upgrade->fresh(), null]; + } + + $invoice = Invoice::factory()->create([ + 'user_id' => $fixture->user->id, + 'currency_code' => 'USD', + 'status' => Invoice::STATUS_PENDING, + 'due_at' => $dueAt, + ]); + $invoice->items()->create([ + 'description' => 'Resource upgrade', + 'price' => $upgrade->quoted_amount, + 'quantity' => 1, + 'reference_id' => $upgrade->id, + 'reference_type' => ServiceUpgrade::class, + ]); + $upgrade->invoice_id = $invoice->id; + ServiceUpgradeMutationCoordinator::save($upgrade); + + return [$upgrade->fresh(), $invoice->fresh()]; + } + + private function option( + Product $product, + string $resource, + int $min, + int $max, + int $step, + int $default, + bool $hidden = false + ): ConfigOption { + $option = ConfigOption::create([ + 'name' => ucfirst($resource), + 'env_variable' => $resource, + 'type' => 'dynamic_slider', + 'hidden' => $hidden, + 'upgradable' => true, + 'metadata' => [ + 'managed_by' => 'dynamic_pterodactyl', + 'managed_product_id' => $product->id, + 'resource_type' => $resource, + 'min' => $min, + 'max' => $max, + 'step' => $step, + 'default' => $default, + 'display_divisor' => 1, + 'pricing' => [ + 'model' => 'linear', + 'rate_per_unit' => 1, + ], + ], + ]); + ConfigOptionProduct::create([ + 'product_id' => $product->id, + 'config_option_id' => $option->id, + ]); + + return $option; + } + + private function selection(object $fixture): array + { + return [ + $fixture->options['memory']->id => 32768, + $fixture->options['cpu']->id => 200, + $fixture->options['disk']->id => 20480, + $fixture->select->id => $fixture->selectChild->id, + ]; + } + + /** + * @param list $markers + */ + private function assertOrderedSourceMarkers( + string $source, + array $markers + ): void { + $previous = -1; + foreach ($markers as $marker) { + $position = strpos($source, $marker); + $this->assertNotFalse( + $position, + "Missing lock-order marker: {$marker}" + ); + $this->assertGreaterThan( + $previous, + $position, + "Lock-order marker is out of order: {$marker}" + ); + $previous = $position; + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e4bde8c..013dd97 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,8 +1,8 @@