feat: complete pre-launch audit — frontend polish, churn prevention, login history, financial reports, configurable checkout

Includes all work from phases 6-9+ and frontend polish rounds 1 & 2:

- Login history with device trust, new device notifications, session management
- Churn prevention: cancellation surveys, winback campaigns with email sequences
- Financial reports: revenue, P&L, tax, aging, refund, subscription reports with PDF/CSV/JSON export
- Configurable checkout: plan config groups/options, build-your-own VPS
- Frontend polish: fix broken legal links, add SEO meta tags, favicon, font display=swap,
  Head titles on all 14 marketing pages, mobile responsive fixes, AuthLayout legal footer,
  remove false 24/7 claims, hide empty stats, correct uptime SLA to 99.9%,
  GameServers notify buttons linked to /contact, 301 redirects for /terms and /privacy
- WHMCS migration scripts
- Update legal page effective dates to March 16, 2026

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-03-16 11:39:25 -04:00
parent 5be235d35e
commit b4ef90465c
187 changed files with 27317 additions and 1840 deletions

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\Subscription;
class CancellationSurvey extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'subscription_id',
'cancellation_reason',
'cancellation_feedback',
'would_return',
];
protected function casts(): array
{
return [
'created_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LoginHistory extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'ip_address',
'user_agent',
'device_type',
'browser',
'os',
'location_country',
'location_city',
'success',
'two_factor_used',
'is_new_device',
'device_hash',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'success' => 'boolean',
'two_factor_used' => 'boolean',
'is_new_device' => 'boolean',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Generate a device hash from user agent and first 3 octets of IP.
*/
public static function generateDeviceHash(string $userAgent, string $ip): string
{
$ipPrefix = self::getIpPrefix($ip);
return hash('sha256', $userAgent.$ipPrefix);
}
/**
* Extract the first 3 octets of an IPv4 address, or first 3 groups of an IPv6 address.
*/
private static function getIpPrefix(string $ip): string
{
if (str_contains($ip, '.')) {
// IPv4: take first 3 octets
$parts = explode('.', $ip);
return implode('.', array_slice($parts, 0, 3));
}
// IPv6: take first 3 groups
$parts = explode(':', $ip);
return implode(':', array_slice($parts, 0, 3));
}
/** @param Builder<LoginHistory> $query */
public function scopeSuccessful(Builder $query): void
{
$query->where('success', true);
}
/** @param Builder<LoginHistory> $query */
public function scopeFailed(Builder $query): void
{
$query->where('success', false);
}
/** @param Builder<LoginHistory> $query */
public function scopeForUser(Builder $query, int $userId): void
{
$query->where('user_id', $userId);
}
}

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Plan extends Model
@@ -24,6 +26,7 @@ class Plan extends Model
'stripe_product_id',
'paypal_plan_id',
'features',
'provisioning_config',
'stock_quantity',
'status',
'sort_order',
@@ -34,6 +37,7 @@ class Plan extends Model
return [
'price' => 'decimal:2',
'features' => 'array',
'provisioning_config' => 'array',
'stock_quantity' => 'integer',
'sort_order' => 'integer',
];
@@ -54,6 +58,11 @@ class Plan extends Model
return $this->hasMany(PlanPrice::class);
}
public function configGroups(): BelongsToMany
{
return $this->belongsToMany(PlanConfigGroup::class, 'plan_config_group_plan');
}
public function priceForCycle(string $cycle): ?PlanPrice
{
return $this->prices()->where('billing_cycle', $cycle)->first();
@@ -61,7 +70,7 @@ class Plan extends Model
public function isAvailable(): bool
{
if ($this->status !== 'active') {
if (! in_array($this->status, ['active', 'internal'])) {
return false;
}
@@ -71,4 +80,9 @@ class Plan extends Model
return true;
}
public function scopePublic(Builder $query): Builder
{
return $query->whereNotIn('status', ['hidden', 'internal', 'inactive']);
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class PlanConfigGroup extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name',
'description',
'mode',
'service_type',
'is_active',
'sort_order',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}
public function options(): HasMany
{
return $this->hasMany(PlanConfigOption::class, 'group_id')->orderBy('sort_order');
}
public function plans(): BelongsToMany
{
return $this->belongsToMany(Plan::class, 'plan_config_group_plan');
}
public function scopePreset(Builder $query): Builder
{
return $query->where('mode', 'preset');
}
public function scopeBuildYourOwn(Builder $query): Builder
{
return $query->where('mode', 'build_your_own');
}
public function scopeForServiceType(Builder $query, string $type): Builder
{
return $query->where('service_type', $type);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PlanConfigOption extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'name',
'description',
'type',
'provisioning_key',
'required',
'is_active',
'min_qty',
'max_qty',
'step',
'unit_label',
'hourly_price',
'monthly_price',
'quarterly_price',
'semi_annual_price',
'annual_price',
'sort_order',
];
protected function casts(): array
{
return [
'required' => 'boolean',
'is_active' => 'boolean',
'min_qty' => 'integer',
'max_qty' => 'integer',
'step' => 'integer',
'hourly_price' => 'decimal:4',
'monthly_price' => 'decimal:2',
'quarterly_price' => 'decimal:2',
'semi_annual_price' => 'decimal:2',
'annual_price' => 'decimal:2',
'sort_order' => 'integer',
];
}
public function group(): BelongsTo
{
return $this->belongsTo(PlanConfigGroup::class, 'group_id');
}
public function values(): HasMany
{
return $this->hasMany(PlanConfigValue::class, 'option_id')->orderBy('sort_order');
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function isSlider(): bool
{
return $this->type === 'slider';
}
public function isQuantity(): bool
{
return $this->type === 'quantity';
}
public function isDropdown(): bool
{
return $this->type === 'dropdown';
}
public function isRadio(): bool
{
return $this->type === 'radio';
}
public function isCheckbox(): bool
{
return $this->type === 'checkbox';
}
public function isText(): bool
{
return $this->type === 'text';
}
public function calculatePrice(int $quantity, string $cycle): float
{
$priceField = match ($cycle) {
'hourly' => 'hourly_price',
'monthly' => 'monthly_price',
'quarterly' => 'quarterly_price',
'semi_annual' => 'semi_annual_price',
'annual' => 'annual_price',
default => 'monthly_price',
};
return (float) (($this->{$priceField} ?? 0) * $quantity);
}
public function getHourlyPrice(int $quantity): float
{
return (float) (($this->hourly_price ?? 0) * $quantity);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlanConfigValue extends Model
{
use HasFactory;
protected $fillable = [
'option_id',
'label',
'value',
'hourly_price',
'monthly_price',
'quarterly_price',
'semi_annual_price',
'annual_price',
'is_default',
'sort_order',
];
protected function casts(): array
{
return [
'hourly_price' => 'decimal:4',
'monthly_price' => 'decimal:2',
'quarterly_price' => 'decimal:2',
'semi_annual_price' => 'decimal:2',
'annual_price' => 'decimal:2',
'is_default' => 'boolean',
'sort_order' => 'integer',
];
}
public function option(): BelongsTo
{
return $this->belongsTo(PlanConfigOption::class, 'option_id');
}
public function getPriceForCycle(string $cycle): float
{
$priceField = match ($cycle) {
'hourly' => 'hourly_price',
'monthly' => 'monthly_price',
'quarterly' => 'quarterly_price',
'semi_annual' => 'semi_annual_price',
'annual' => 'annual_price',
default => 'monthly_price',
};
return (float) ($this->{$priceField} ?? 0);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\Subscription;
class SubscriptionConfigSelection extends Model
{
protected $fillable = [
'subscription_id',
'option_id',
'value_id',
'quantity',
'text_value',
'locked_price',
'locked_hourly_price',
'billing_cycle',
'is_custom_build',
];
protected function casts(): array
{
return [
'quantity' => 'integer',
'locked_price' => 'decimal:4',
'locked_hourly_price' => 'decimal:4',
'is_custom_build' => 'boolean',
];
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function option(): BelongsTo
{
return $this->belongsTo(PlanConfigOption::class, 'option_id');
}
public function value(): BelongsTo
{
return $this->belongsTo(PlanConfigValue::class, 'value_id');
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TrustedDevice extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'device_hash',
'device_name',
'ip_address',
'last_used_at',
'expires_at',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @param Builder<TrustedDevice> $query */
public function scopeActive(Builder $query): void
{
$query->where('expires_at', '>', now());
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
}

View File

@@ -91,6 +91,16 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(CouponRedemption::class);
}
public function loginHistories(): HasMany
{
return $this->hasMany(LoginHistory::class);
}
public function trustedDevices(): HasMany
{
return $this->hasMany(TrustedDevice::class);
}
public function isAdmin(): bool
{
return $this->hasRole('admin');

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class WinbackCampaign extends Model
{
use HasFactory;
protected $fillable = [
'name',
'cancellation_reason',
'email_sequence',
'offer_type',
'offer_value',
'offer_duration_days',
'coupon_code',
'status',
];
protected function casts(): array
{
return [
'email_sequence' => 'array',
'offer_value' => 'decimal:2',
'offer_duration_days' => 'integer',
];
}
public function recipients(): HasMany
{
return $this->hasMany(WinbackRecipient::class, 'campaign_id');
}
/** @param Builder<self> $query */
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
/** @param Builder<self> $query */
public function scopeForReason(Builder $query, string $reason): Builder
{
return $query->where('cancellation_reason', $reason);
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\Subscription;
class WinbackRecipient extends Model
{
use HasFactory;
protected $fillable = [
'campaign_id',
'user_id',
'subscription_id',
'current_email_index',
'last_email_sent_at',
'opened_count',
'clicked_count',
'reactivated',
'reactivated_at',
'unsubscribed_at',
];
protected function casts(): array
{
return [
'current_email_index' => 'integer',
'opened_count' => 'integer',
'clicked_count' => 'integer',
'reactivated' => 'boolean',
'last_email_sent_at' => 'datetime',
'reactivated_at' => 'datetime',
'unsubscribed_at' => 'datetime',
];
}
public function campaign(): BelongsTo
{
return $this->belongsTo(WinbackCampaign::class, 'campaign_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function isActive(): bool
{
if ($this->unsubscribed_at !== null) {
return false;
}
if ($this->reactivated) {
return false;
}
return $this->campaign->status === 'active';
}
}