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:
42
website/app/Models/CancellationSurvey.php
Normal file
42
website/app/Models/CancellationSurvey.php
Normal 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);
|
||||
}
|
||||
}
|
||||
94
website/app/Models/LoginHistory.php
Normal file
94
website/app/Models/LoginHistory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
64
website/app/Models/PlanConfigGroup.php
Normal file
64
website/app/Models/PlanConfigGroup.php
Normal 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);
|
||||
}
|
||||
}
|
||||
117
website/app/Models/PlanConfigOption.php
Normal file
117
website/app/Models/PlanConfigOption.php
Normal 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);
|
||||
}
|
||||
}
|
||||
59
website/app/Models/PlanConfigValue.php
Normal file
59
website/app/Models/PlanConfigValue.php
Normal 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);
|
||||
}
|
||||
}
|
||||
49
website/app/Models/SubscriptionConfigSelection.php
Normal file
49
website/app/Models/SubscriptionConfigSelection.php
Normal 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');
|
||||
}
|
||||
}
|
||||
52
website/app/Models/TrustedDevice.php
Normal file
52
website/app/Models/TrustedDevice.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
52
website/app/Models/WinbackCampaign.php
Normal file
52
website/app/Models/WinbackCampaign.php
Normal 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);
|
||||
}
|
||||
}
|
||||
69
website/app/Models/WinbackRecipient.php
Normal file
69
website/app/Models/WinbackRecipient.php
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user