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:
@@ -25,6 +25,7 @@ class Invoice extends Model
|
||||
'currency',
|
||||
'status',
|
||||
'invoice_pdf',
|
||||
'notes',
|
||||
'due_date',
|
||||
'paid_at',
|
||||
];
|
||||
|
||||
@@ -21,6 +21,7 @@ class Plan extends Model
|
||||
'currency',
|
||||
'billing_cycle',
|
||||
'stripe_price_id',
|
||||
'stripe_product_id',
|
||||
'paypal_plan_id',
|
||||
'features',
|
||||
'stock_quantity',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'phone',
|
||||
'company',
|
||||
'admin_notes',
|
||||
'virtfusion_user_id',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
|
||||
Reference in New Issue
Block a user