Idempotent provisioning, service soft-delete, Plans page redesign, doc updates

Part A: Fix duplicate Service creation on provisioning retry
- All 4 provisioning services use Service::firstOrCreate() keyed on
  subscription_id+service_type to prevent duplicates on queue retries
- HandleSubscriptionCreated sends notification before provisioning,
  no longer re-throws on failure
- RetryProvisioningCommand simplified to reuse existing Service records

Part B: Plans/Pricing page complete redesign
- Service type tabs (VPS, Dedicated, Web Hosting, MySQL)
- Billing cycle segmented toggle (monthly/quarterly/semi-annual/annual)
- Feature icons per service type, Popular/Best Value badges
- Stock indicators, effective monthly price calculations

Part C: Admin service soft-delete/archive
- Service model uses SoftDeletes trait
- Admin can archive and restore services
- Show archived toggle on services list
- Migration adds deleted_at column

Docs: Updated TASKS.md, CLAUDE.md, PROJECT_DEVELOPMENT.md, MEMORY.md
- Phase 3 marked complete, test counts updated (252 passing)
- SupportPal references replaced with standalone ticket system
- Frontend design skill background rule added
- Closed GitHub issues #3, #6, #7, #8, #9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-10 06:30:57 -05:00
parent bf4f5f97c0
commit 45d25d61ba
101 changed files with 13225 additions and 1888 deletions

View File

@@ -25,6 +25,7 @@ class Invoice extends Model
'currency',
'status',
'invoice_pdf',
'notes',
'due_date',
'paid_at',
];

View File

@@ -21,6 +21,7 @@ class Plan extends Model
'currency',
'billing_cycle',
'stripe_price_id',
'stripe_product_id',
'paypal_plan_id',
'features',
'stock_quantity',

View File

@@ -8,11 +8,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Cashier\Subscription;
class Service extends Model
{
use HasFactory;
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
class Setting extends Model
{
@@ -15,37 +16,77 @@ class Setting extends Model
];
/**
* Get a setting value by key.
* Keys that must be encrypted at rest.
*
* @var array<int, string>
*/
private const ENCRYPTED_KEYS = [
'virtfusion_api_token',
'synergycp_api_token',
'enhance_api_token',
'pterodactyl_api_token',
'enhance_organization_id',
'discord_payment_webhook_url',
'discord_provisioning_webhook_url',
'discord_support_webhook_url',
'discord_system_webhook_url',
];
/**
* Get a setting value by key, automatically decrypting if needed.
*/
public static function get(string $key, mixed $default = null): mixed
{
$setting = static::query()->where('key', $key)->first();
return $setting?->value ?? $default;
if (! $setting || $setting->value === null) {
return $default;
}
if (in_array($key, self::ENCRYPTED_KEYS, true)) {
return self::decryptValue($setting->value);
}
return $setting->value;
}
/**
* Set a setting value by key.
* Set a setting value by key, automatically encrypting if needed.
*/
public static function set(string $key, mixed $value, string $group = 'general'): void
{
$storedValue = $value;
if (in_array($key, self::ENCRYPTED_KEYS, true) && $value !== null && $value !== '') {
$storedValue = Crypt::encryptString((string) $value);
}
static::query()->updateOrCreate(
['key' => $key],
['value' => $value, 'group' => $group],
['value' => $storedValue, 'group' => $group],
);
}
/**
* Get all settings for a given group as a key-value array.
* Encrypted values are automatically decrypted.
*
* @return array<string, string|null>
*/
public static function getGroup(string $group): array
{
return static::query()
$settings = static::query()
->where('group', $group)
->pluck('value', 'key')
->toArray();
foreach ($settings as $key => $value) {
if (in_array($key, self::ENCRYPTED_KEYS, true) && $value !== null) {
$settings[$key] = self::decryptValue($value);
}
}
return $settings;
}
/**
@@ -59,4 +100,25 @@ class Setting extends Model
static::set($key, $value, $group);
}
}
/**
* Check if a key stores encrypted data.
*/
public static function isEncryptedKey(string $key): bool
{
return in_array($key, self::ENCRYPTED_KEYS, true);
}
/**
* Safely decrypt a value, returning the raw value if decryption fails.
*/
private static function decryptValue(string $value): string
{
try {
return Crypt::decryptString($value);
} catch (\Exception) {
// Value may not be encrypted yet (legacy data)
return $value;
}
}
}

View File

@@ -29,6 +29,7 @@ class User extends Authenticatable implements MustVerifyEmail
'phone',
'company',
'admin_notes',
'virtfusion_user_id',
];
/** @var list<string> */