feat: add advanced features — KB, tickets v2, multi-currency, cart, quotes, affiliates, credits, staff RBAC, fraud detection, service panels

Major additions:
- Knowledge base with categories, articles, revisions, and voting
- Enhanced ticket system: departments, SLA policies, canned responses, tags, custom fields, satisfaction ratings, internal notes
- Multi-currency support with exchange rate sync
- Shopping cart and quote system with PDF generation
- Affiliate program with referrals, commissions, and payouts
- Account credits, credit notes, and debit notes
- Staff management with granular role-based permissions
- Fraud detection and order risk assessment
- ServerHunter SEO integration
- Service lifecycle events (suspend/unsuspend/terminate)
- Service management panels for VPS, Dedicated, Hosting, and Game servers
- Plan lifecycle fields and per-customer overrides
- 30+ migrations, 17 factories, 8 feature test suites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-03-17 07:35:10 -04:00
parent 2bf8a5b6bf
commit de8ec69ea0
265 changed files with 29892 additions and 216 deletions

115
ipv4-outreach-tickets.txt Normal file
View File

@@ -0,0 +1,115 @@
== TICKET 1 ==
EMAIL: updates@hostigol.com
SUBJECT: Your IPv4 pricing — let's talk
BODY:
Hi there,
I hope you're doing well. I wanted to personally reach out regarding the recent IPv4 pricing update, as I know this affects a good number of your services with us.
Looking at your account, this impacts 11 of your VPS services with a total of 41 additional IPv4 addresses. Your current IP add-on cost is $123.00/month, which will move to $328.00/month at the new $8/IP rate — an increase of $205.00/month.
I completely understand that's a significant change, and we genuinely value the trust you've placed in us. A couple of things that might help:
• If there are any IPs across your services that you're no longer actively using, we're happy to remove those and bring your cost down
• IPv6 is included at no charge with every VPS — if any of your use cases can work with IPv6, that's another way to offset the increase
Please don't hesitate to reply here — we're happy to chat about what works best for you.
Thank you,
== TICKET 2 ==
EMAIL: silvernetservers@gmail.com
SUBJECT: Your IPv4 pricing — let's talk
BODY:
Hi Parind,
I hope you're doing well. I wanted to personally reach out regarding the recent IPv4 pricing update, as this affects several of your services.
This impacts 6 of your VPS services with a total of 24 additional IPv4 addresses. Your current IP add-on cost is $72.00/month, which will move to $192.00/month at the new $8/IP rate — an increase of $120.00/month.
A couple of things that might help:
• If any IPs aren't actively in use, we're happy to remove them and lower your cost
• IPv6 is included at no charge if any of your services can switch over
Please reply here and let us know how you'd like to proceed.
Thank you,
== TICKET 3 ==
EMAIL: zoneworxlimited@gmail.com
SUBJECT: Your IPv4 pricing — let's talk
BODY:
Hi,
I hope you're doing well. I wanted to personally reach out regarding the recent IPv4 pricing update, as this affects several of your services.
This impacts 4 of your VPS services with a total of 16 additional IPv4 addresses. Your current IP add-on cost is $48.00/month, which will move to $128.00/month — an increase of $80.00/month.
A couple of options:
• If any of your additional IPs aren't actively needed, we can remove them to bring your cost down
• IPv6 is available at no extra charge if any of your use cases can switch over
Please reply here and let us know how you'd like to proceed.
Thank you,
== TICKET 4 ==
EMAIL: fzguiloui@pimarketing.co
SUBJECT: Following up on the IPv4 pricing update
BODY:
Hi Fatima,
I wanted to follow up on the IPv4 pricing change. You have 7 additional IPs on your Mini VPS, and the new pricing will change your IP add-on cost from $21.00/month to $56.00/month — an increase of $35.00/month.
A few things to consider:
• If any of those IPs aren't actively needed, we can remove them right away to lower your cost
• If you need the IPs but the new rate is difficult, please let me know — I'd rather work something out than lose you as a customer
• IPv6 is available at no extra charge if any of your use cases can switch over
Just reply here and we'll figure out the best option for you.
Thank you,
== TICKET 5 ==
EMAIL: manoj.niks@gmail.com
SUBJECT: Quick note about the IPv4 update
BODY:
Hi Manoj,
I hope you're doing well. Just a quick note about the recent IPv4 pricing change — it affects 4 of your Standard VPS services that each have an extra IP.
Your IP add-on cost will go from $12.00/month to $32.00/month — an increase of $20.00/month.
If any of those extra IPs aren't something you're actively using, just let me know and we'll get them removed for you. Otherwise, no action needed on your end.
Feel free to reach out if you have any questions.
Thank you,
== TICKET 6 ==
EMAIL: contact@oomos.com
SUBJECT: Following up on the IPv4 pricing update
BODY:
Hi,
I hope you're doing well. I wanted to follow up on the IPv4 pricing change. You have 3 additional IPs on your Basic VPS, and the new pricing will change your IP add-on cost from $9.00/month to $24.00/month — an increase of $15.00/month.
If any of those IPs aren't actively needed, we can remove them to bring your cost down. IPv6 is also available at no extra charge if any of your use cases can switch over.
Just reply here if you have any questions or would like to make changes.
Thank you,
== TICKET 7 ==
EMAIL: aartisakhare138@gmail.com
SUBJECT: Following up on the IPv4 pricing update
BODY:
Hi Aarti,
I hope you're doing well. Just a quick personal follow-up on the IPv4 pricing change — you have 2 additional IPs on your Dev Starter VPS, and your IP add-on cost will go from $6.00/month to $16.00/month.
If either of those extra IPs isn't something you need anymore, just let me know and we'll take care of it. Otherwise, no worries at all.
Feel free to reach out if you have questions.
Thank you,

View File

@@ -392,6 +392,7 @@ final class Phase3Services extends AbstractPhase
$isSuspended = strtolower($whmcsStatus) === 'suspended'; $isSuspended = strtolower($whmcsStatus) === 'suspended';
$isTerminated = in_array(strtolower($whmcsStatus), ['terminated', 'cancelled'], true); $isTerminated = in_array(strtolower($whmcsStatus), ['terminated', 'cancelled'], true);
$recurringAmount = $this->nullIfEmpty((string) ($product['recurringamount'] ?? ''));
$dedicatedIp = $this->nullIfEmpty((string) ($product['dedicatedip'] ?? '')); $dedicatedIp = $this->nullIfEmpty((string) ($product['dedicatedip'] ?? ''));
$domain = $this->nullIfEmpty((string) ($product['domain'] ?? '')); $domain = $this->nullIfEmpty((string) ($product['domain'] ?? ''));
@@ -414,6 +415,7 @@ final class Phase3Services extends AbstractPhase
'stripe_price' => null, 'stripe_price' => null,
'quantity' => 1, 'quantity' => 1,
'billing_cycle' => $billingCycle, 'billing_cycle' => $billingCycle,
'recurring_amount' => $recurringAmount,
'current_period_end' => $nextDueDate, 'current_period_end' => $nextDueDate,
'created_at' => $regDate ?? $now, 'created_at' => $regDate ?? $now,
'updated_at' => $now, 'updated_at' => $now,

View File

@@ -148,15 +148,16 @@ final class StatusMapper
* *
* Returns null for service types that have no auto-provisioning platform. * Returns null for service types that have no auto-provisioning platform.
*/ */
public static function mapPlatform(string $serviceType): ?string public static function mapPlatform(string $serviceType): string
{ {
return match ($serviceType) { return match ($serviceType) {
'vps' => 'virtfusion', 'vps' => 'virtfusion',
'dedicated' => 'synergycp', 'dedicated' => 'synergycp',
'hosting' => 'enhance', 'hosting' => 'enhance',
'game_server' => 'pterodactyl', 'game_server' => 'pterodactyl',
'mysql' => null, 'mysql' => 'manual',
'backups' => null, 'backups' => 'manual',
'other' => 'manual',
default => 'virtfusion', default => 'virtfusion',
}; };
} }

View File

@@ -98,4 +98,7 @@ IMAP_VALIDATE_CERT=true
IMAP_PROTOCOL=imap IMAP_PROTOCOL=imap
IMAP_FOLDER=INBOX IMAP_FOLDER=INBOX
# ServerHunter Integration
SERVERHUNTER_API_KEY=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Invoice;
use App\Models\PlanPrice;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Subscription;
class BackfillSubscriptionAmountsCommand extends Command
{
protected $signature = 'subscriptions:backfill-amounts
{--dry-run : Preview changes without writing to the database}';
protected $description = 'Backfill recurring_amount on subscriptions from invoice history or plan prices';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('DRY RUN — no changes will be written.');
}
$subscriptions = Subscription::query()
->where('stripe_status', 'active')
->whereNull('recurring_amount')
->whereNotNull('plan_id')
->with('user')
->get();
if ($subscriptions->isEmpty()) {
$this->info('No active subscriptions without recurring_amount found.');
return self::SUCCESS;
}
$this->info("Found {$subscriptions->count()} subscription(s) to backfill.");
$fromInvoice = 0;
$fromPlanPrice = 0;
$fromPlanBase = 0;
$skipped = 0;
foreach ($subscriptions as $subscription) {
$amount = null;
$source = 'none';
// Strategy 1: Find invoice items matching the plan name
$plan = DB::table('plans')->where('id', $subscription->plan_id)->first();
if (! $plan) {
$this->warn(" Subscription #{$subscription->id}: no plan found (plan_id={$subscription->plan_id}), skipping.");
$skipped++;
continue;
}
// Look for the most recent paid invoice for this user that has items
// referencing the plan name
$invoiceAmount = Invoice::query()
->where('user_id', $subscription->user_id)
->where('status', 'paid')
->whereHas('items', function ($query) use ($plan): void {
$query->where('description', 'like', '%'.$plan->name.'%');
})
->orderByDesc('paid_at')
->first();
if ($invoiceAmount) {
// Sum the invoice items that match the plan name
$matchingItemsTotal = (float) $invoiceAmount->items()
->where('description', 'like', '%'.$plan->name.'%')
->sum(DB::raw('amount * quantity'));
if ($matchingItemsTotal > 0) {
$amount = $matchingItemsTotal;
$source = 'invoice';
}
}
// Strategy 2: Fall back to plan_prices table
if ($amount === null) {
$planPrice = PlanPrice::query()
->where('plan_id', $subscription->plan_id)
->where('billing_cycle', $subscription->billing_cycle ?? 'monthly')
->first();
if ($planPrice) {
$amount = (float) $planPrice->price;
$source = 'plan_price';
}
}
// Strategy 3: Fall back to plans.price base
if ($amount === null && $plan->price > 0) {
$amount = (float) $plan->price;
$source = 'plan_base';
}
if ($amount === null) {
$this->warn(" Subscription #{$subscription->id} (plan: {$plan->name}): no amount found, skipping.");
$skipped++;
continue;
}
$userName = $subscription->user?->name ?? "user #{$subscription->user_id}";
$this->info(" Subscription #{$subscription->id} ({$userName}, plan: {$plan->name}, cycle: {$subscription->billing_cycle}): \${$amount} [source: {$source}]");
if (! $dryRun) {
Subscription::query()
->where('id', $subscription->id)
->update(['recurring_amount' => $amount]);
}
match ($source) {
'invoice' => $fromInvoice++,
'plan_price' => $fromPlanPrice++,
'plan_base' => $fromPlanBase++,
default => $skipped++,
};
}
$this->newLine();
$this->info('Backfill summary:');
$this->info(" From invoice items: {$fromInvoice}");
$this->info(" From plan prices: {$fromPlanPrice}");
$this->info(" From plan base: {$fromPlanBase}");
$this->info(" Skipped: {$skipped}");
if ($dryRun) {
$this->warn('DRY RUN — no changes were written. Run without --dry-run to apply.');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Support\SlaService;
use Illuminate\Console\Command;
class CheckSlaBreachCommand extends Command
{
protected $signature = 'sla:check-breaches';
protected $description = 'Check all open tickets for SLA breaches and update breach flags';
public function handle(SlaService $slaService): int
{
$breachCount = $slaService->checkBreaches();
if ($breachCount > 0) {
$this->warn("Found {$breachCount} SLA breach(es).");
} else {
$this->info('No SLA breaches found.');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Currency;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SyncExchangeRatesCommand extends Command
{
protected $signature = 'currencies:sync-rates';
protected $description = 'Sync exchange rates from exchangerate.host API';
public function handle(): int
{
$baseCurrency = Currency::query()->base()->first();
if (! $baseCurrency) {
$this->error('No base currency configured.');
return self::FAILURE;
}
$this->info("Syncing exchange rates relative to {$baseCurrency->code}...");
try {
$response = Http::timeout(15)->get('https://api.exchangerate.host/latest', [
'base' => $baseCurrency->code,
]);
if (! $response->successful()) {
$this->error('Failed to fetch exchange rates: HTTP '.$response->status());
Log::error('Exchange rate sync failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
return self::FAILURE;
}
$data = $response->json();
$rates = $data['rates'] ?? [];
if (empty($rates)) {
$this->warn('No rates returned from API.');
return self::FAILURE;
}
$currencies = Currency::query()->where('is_base', false)->get();
$updated = 0;
foreach ($currencies as $currency) {
if (isset($rates[$currency->code])) {
$currency->update([
'exchange_rate' => $rates[$currency->code],
'last_synced_at' => now(),
]);
$updated++;
$this->line(" {$currency->code}: {$rates[$currency->code]}");
}
}
// Clear currency cache
Cache::forget('currencies:enabled');
Cache::forget('currencies:base');
$this->info("Updated {$updated} exchange rates.");
return self::SUCCESS;
} catch (\Throwable $e) {
$this->error('Exchange rate sync error: '.$e->getMessage());
Log::error('Exchange rate sync exception', ['error' => $e->getMessage()]);
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\ServerHunterService;
use Illuminate\Console\Command;
class SyncServerHunterCommand extends Command
{
protected $signature = 'serverhunter:sync {--dry-run : Preview the feed without pushing to ServerHunter}';
protected $description = 'Push VPS and dedicated server offers to the ServerHunter API';
public function __construct(
private ServerHunterService $serverHunterService,
) {
parent::__construct();
}
public function handle(): int
{
$feed = $this->serverHunterService->buildFeed();
$offerCount = count($feed['offers']);
if ($offerCount === 0) {
$this->warn('No active VPS or dedicated plans found. Nothing to sync.');
return self::SUCCESS;
}
$this->info("Found {$offerCount} offer(s) to sync with ServerHunter.");
if ($this->option('dry-run')) {
$this->info('Dry run — not pushing to API. Feed preview:');
$this->newLine();
foreach ($feed['offers'] as $offer) {
$this->line(sprintf(
' [%s] %s — $%s/mo (%s, %s cores, %sMB RAM, %sGB %s)',
$offer['product_type'],
$offer['name'],
$offer['price'],
$offer['cpu_name'],
$offer['cpu_cores'],
$offer['memory_amount'],
$offer['ssd_capacity'] > 0 ? $offer['ssd_capacity'] : $offer['hdd_capacity'],
$offer['ssd_capacity'] > 0 ? 'SSD/NVMe' : 'HDD',
));
}
$this->newLine();
$this->info('Full JSON output:');
$this->line(json_encode($feed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info('Pushing offers to ServerHunter API...');
try {
$result = $this->serverHunterService->pushToApi($feed);
if ($result['successful']) {
$this->info("Successfully pushed {$offerCount} offer(s) to ServerHunter.");
return self::SUCCESS;
}
$this->error("ServerHunter API returned HTTP {$result['status']}.");
$this->line(json_encode($result['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::FAILURE;
} catch (\RuntimeException $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServiceSuspended
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public Service $service,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServiceSuspending
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public Service $service,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServiceTerminated
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public Service $service,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServiceTerminating
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public Service $service,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServiceUnsuspended
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public Service $service,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServiceUnsuspending
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
public Service $service,
) {}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Http\Requests\RequestAffiliatePayoutRequest;
use App\Services\AffiliateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AffiliateController extends Controller
{
public function __construct(
private AffiliateService $affiliateService,
) {}
public function dashboard(Request $request): Response
{
$user = $request->user();
$affiliate = $user->affiliate;
if (! $affiliate) {
return Inertia::render('Account/Affiliate/Dashboard', [
'affiliate' => null,
'stats' => null,
]);
}
$affiliate->loadCount('referrals', 'commissions', 'payouts');
$monthlyEarnings = $affiliate->commissions()
->where('created_at', '>=', now()->startOfMonth())
->sum('amount');
$monthlyChart = $affiliate->commissions()
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total")
->where('created_at', '>=', now()->subMonths(6))
->groupByRaw("DATE_FORMAT(created_at, '%Y-%m')")
->orderBy('month')
->get();
return Inertia::render('Account/Affiliate/Dashboard', [
'affiliate' => $affiliate,
'stats' => [
'total_earned' => (float) $affiliate->total_earned,
'pending_balance' => (float) $affiliate->pending_balance,
'total_paid' => (float) $affiliate->total_paid,
'monthly_earnings' => (float) $monthlyEarnings,
'referral_count' => $affiliate->referrals_count,
'commission_count' => $affiliate->commissions_count,
],
'monthlyChart' => $monthlyChart,
]);
}
public function referrals(Request $request): Response
{
$affiliate = $request->user()->affiliate;
if (! $affiliate) {
return redirect()->route('account.affiliate.dashboard');
}
$referrals = $affiliate->referrals()
->with('referredUser:id,name,email')
->latest()
->paginate(25);
return Inertia::render('Account/Affiliate/Referrals', [
'referrals' => $referrals,
'affiliate' => $affiliate,
]);
}
public function commissions(Request $request): Response
{
$affiliate = $request->user()->affiliate;
if (! $affiliate) {
return redirect()->route('account.affiliate.dashboard');
}
$commissions = $affiliate->commissions()
->latest()
->paginate(25);
return Inertia::render('Account/Affiliate/Commissions', [
'commissions' => $commissions,
'affiliate' => $affiliate,
]);
}
public function payouts(Request $request): Response
{
$affiliate = $request->user()->affiliate;
if (! $affiliate) {
return redirect()->route('account.affiliate.dashboard');
}
$payouts = $affiliate->payouts()
->latest()
->paginate(25);
return Inertia::render('Account/Affiliate/Payouts', [
'payouts' => $payouts,
'affiliate' => $affiliate,
]);
}
public function requestPayout(RequestAffiliatePayoutRequest $request): RedirectResponse
{
$affiliate = $request->user()->affiliate;
if (! $affiliate || $affiliate->status !== 'active') {
return back()->with('error', 'You do not have an active affiliate account.');
}
try {
$this->affiliateService->requestPayout(
$affiliate,
$request->validated('method'),
);
return back()->with('success', 'Payout request submitted successfully.');
} catch (\InvalidArgumentException $e) {
return back()->with('error', $e->getMessage());
}
}
public function apply(Request $request): RedirectResponse
{
$user = $request->user();
if ($user->affiliate) {
return back()->with('error', 'You already have an affiliate account.');
}
$this->affiliateService->apply($user);
return back()->with('success', 'Your affiliate application has been submitted for review.');
}
}

View File

@@ -149,6 +149,20 @@ class BillingController extends Controller
]); ]);
} }
public function credits(Request $request): Response
{
$user = $request->user();
$credits = $user->accountCredits()
->latest()
->paginate(20);
return Inertia::render('Billing/Credits', [
'credits' => $credits,
'creditBalance' => (float) $user->credit_balance,
]);
}
public function upcomingRenewals(Request $request): Response public function upcomingRenewals(Request $request): Response
{ {
$user = $request->user(); $user = $request->user();

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Models\CartItem;
use App\Models\Plan;
use App\Services\Billing\CartService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CartController extends Controller
{
public function __construct(
private CartService $cartService,
) {}
public function index(Request $request): Response
{
/** @var \App\Models\User $user */
$user = $request->user();
$items = $this->cartService->getItems($user);
$total = $this->cartService->getTotal($user);
// Compute per-item prices
$itemsWithPrices = $items->map(function (CartItem $item): array {
$unitPrice = $this->cartService->getItemPrice($item);
return [
'id' => $item->id,
'plan_id' => $item->plan_id,
'billing_cycle' => $item->billing_cycle,
'quantity' => $item->quantity,
'config_selections' => $item->config_selections,
'coupon_code' => $item->coupon_code,
'unit_price' => number_format($unitPrice, 2, '.', ''),
'line_total' => number_format($unitPrice * $item->quantity, 2, '.', ''),
'plan' => $item->plan ? [
'id' => $item->plan->id,
'name' => $item->plan->name,
'service_type' => $item->plan->service_type,
'description' => $item->plan->description,
'features' => $item->plan->features,
] : null,
];
});
return Inertia::render('Cart/Index', [
'items' => $itemsWithPrices,
'total' => number_format($total, 2, '.', ''),
'itemCount' => $this->cartService->getItemCount($user),
]);
}
public function add(Request $request): RedirectResponse
{
$validated = $request->validate([
'plan_id' => ['required', 'exists:plans,id'],
'billing_cycle' => ['required', 'string', 'in:monthly,quarterly,semi_annual,annual'],
'quantity' => ['sometimes', 'integer', 'min:1', 'max:10'],
'config_selections' => ['nullable', 'array'],
'coupon_code' => ['nullable', 'string', 'max:50'],
]);
/** @var \App\Models\User $user */
$user = $request->user();
$plan = Plan::findOrFail($validated['plan_id']);
if (! $plan->isAvailable()) {
return redirect()->back()->with('error', 'This plan is not available.');
}
$this->cartService->addItem(
userOrSession: $user,
plan: $plan,
cycle: $validated['billing_cycle'],
qty: $validated['quantity'] ?? 1,
config: $validated['config_selections'] ?? null,
couponCode: $validated['coupon_code'] ?? null,
);
return redirect()->route('account.cart.index')
->with('success', "{$plan->name} has been added to your cart.");
}
public function update(Request $request, CartItem $cartItem): RedirectResponse
{
$validated = $request->validate([
'quantity' => ['required', 'integer', 'min:1', 'max:10'],
]);
/** @var \App\Models\User $user */
$user = $request->user();
// Verify ownership
if ($cartItem->user_id !== $user->id) {
abort(403);
}
$this->cartService->updateQuantity($cartItem->id, $validated['quantity']);
return redirect()->route('account.cart.index')
->with('success', 'Cart updated.');
}
public function remove(Request $request, CartItem $cartItem): RedirectResponse
{
/** @var \App\Models\User $user */
$user = $request->user();
// Verify ownership
if ($cartItem->user_id !== $user->id) {
abort(403);
}
$this->cartService->removeItem($cartItem->id);
return redirect()->route('account.cart.index')
->with('success', 'Item removed from cart.');
}
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account\Dedicated;
use App\Http\Controllers\Controller;
use App\Models\Service;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
class DedicatedPanelController extends Controller
{
public function dashboard(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$hardwareInfo = $this->safeApiCall(function () use ($service): array {
return $this->fetchHardwareInfo($service);
});
return Inertia::render('Services/Dedicated/Dashboard', [
'service' => $service->load('plan', 'subscription'),
'hardwareInfo' => $hardwareInfo ?? [
'cpu' => null,
'memory' => null,
'disks' => [],
'network_ports' => [],
],
]);
}
public function console(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$consoleUrl = $this->safeApiCall(function () use ($service): ?string {
return $this->fetchConsoleUrl($service);
});
return Inertia::render('Services/Dedicated/Console', [
'service' => $service->load('plan'),
'consoleUrl' => $consoleUrl,
]);
}
public function bandwidth(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$bandwidthData = $this->safeApiCall(function () use ($service): array {
return $this->fetchBandwidthData($service);
});
return Inertia::render('Services/Dedicated/Bandwidth', [
'service' => $service->load('plan'),
'bandwidthData' => $bandwidthData ?? [],
]);
}
public function network(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$networkData = $this->safeApiCall(function () use ($service): array {
return $this->fetchNetworkData($service);
});
return Inertia::render('Services/Dedicated/Network', [
'service' => $service->load('plan'),
'networkData' => $networkData ?? [
'ips' => [],
'vlans' => [],
],
]);
}
public function reinstall(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$templates = $this->safeApiCall(function () use ($service): array {
return $this->fetchTemplates($service);
});
return Inertia::render('Services/Dedicated/Reinstall', [
'service' => $service->load('plan'),
'templates' => $templates ?? [],
]);
}
public function settings(Request $request, Service $service): Response
{
$this->authorize($request, $service);
return Inertia::render('Services/Dedicated/Settings', [
'service' => $service->load('plan'),
]);
}
private function authorize(Request $request, Service $service): void
{
abort_unless($service->user_id === $request->user()->id, 403);
abort_unless($service->service_type === 'dedicated', 404);
}
/**
* @template T
*
* @param callable(): T $callback
* @return T|null
*/
private function safeApiCall(callable $callback): mixed
{
try {
return $callback();
} catch (\Exception $e) {
Log::warning('Dedicated Panel API call failed', [
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* @return array<string, mixed>
*/
private function fetchHardwareInfo(Service $service): array
{
// SynergyCP API: GET /api/v1/servers/{id}/hardware
return [
'cpu' => null,
'memory' => null,
'disks' => [],
'network_ports' => [],
];
}
private function fetchConsoleUrl(Service $service): ?string
{
// SynergyCP API: GET /api/v1/servers/{id}/ipmi/console
return null;
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchBandwidthData(Service $service): array
{
// SynergyCP API: GET /api/v1/servers/{id}/bandwidth
return [];
}
/**
* @return array<string, mixed>
*/
private function fetchNetworkData(Service $service): array
{
$ips = [];
if ($service->ipv4_address) {
$ips[] = [
'address' => $service->ipv4_address,
'type' => 'IPv4',
'primary' => true,
];
}
if ($service->ipv6_address) {
$ips[] = [
'address' => $service->ipv6_address,
'type' => 'IPv6',
'primary' => false,
];
}
return [
'ips' => $ips,
'vlans' => [],
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchTemplates(Service $service): array
{
// SynergyCP API: GET /api/v1/servers/{id}/templates
return [];
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account\Game;
use App\Http\Controllers\Controller;
use App\Models\ProvisioningLog;
use App\Models\Service;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
class GamePanelController extends Controller
{
public function dashboard(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$activities = ProvisioningLog::where('service_id', $service->id)
->orderByDesc('created_at')
->limit(10)
->get()
->map(fn (ProvisioningLog $log): array => [
'action' => $log->action,
'timestamp' => $log->created_at->toISOString(),
'details' => $log->status.($log->error_message ? ': '.$log->error_message : ''),
])
->toArray();
return Inertia::render('Services/Game/Dashboard', [
'service' => $service->load('plan', 'subscription'),
'recentActivity' => $activities,
]);
}
public function console(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$consoleUrl = $this->safeApiCall(function () use ($service): ?string {
return $this->fetchConsoleUrl($service);
});
return Inertia::render('Services/Game/Console', [
'service' => $service->load('plan'),
'consoleUrl' => $consoleUrl,
]);
}
public function files(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$directory = $request->query('path', '/');
$fileList = $this->safeApiCall(function () use ($service, $directory): array {
return $this->fetchFiles($service, (string) $directory);
});
return Inertia::render('Services/Game/Files', [
'service' => $service->load('plan'),
'files' => $fileList ?? [],
'currentPath' => $directory,
]);
}
public function databases(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$databaseList = $this->safeApiCall(function () use ($service): array {
return $this->fetchDatabases($service);
});
return Inertia::render('Services/Game/Databases', [
'service' => $service->load('plan'),
'databases' => $databaseList ?? [],
]);
}
public function backups(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$backupList = $this->safeApiCall(function () use ($service): array {
return $this->fetchBackups($service);
});
return Inertia::render('Services/Game/Backups', [
'service' => $service->load('plan'),
'backups' => $backupList ?? [],
]);
}
public function schedules(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$scheduleList = $this->safeApiCall(function () use ($service): array {
return $this->fetchSchedules($service);
});
return Inertia::render('Services/Game/Schedules', [
'service' => $service->load('plan'),
'schedules' => $scheduleList ?? [],
]);
}
public function startup(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$startupConfig = $this->safeApiCall(function () use ($service): array {
return $this->fetchStartupConfig($service);
});
return Inertia::render('Services/Game/Startup', [
'service' => $service->load('plan'),
'startupConfig' => $startupConfig ?? [
'startup_command' => '',
'variables' => [],
],
]);
}
public function settings(Request $request, Service $service): Response
{
$this->authorize($request, $service);
return Inertia::render('Services/Game/Settings', [
'service' => $service->load('plan'),
]);
}
public function subUsers(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$users = $this->safeApiCall(function () use ($service): array {
return $this->fetchSubUsers($service);
});
return Inertia::render('Services/Game/SubUsers', [
'service' => $service->load('plan'),
'subUsers' => $users ?? [],
]);
}
private function authorize(Request $request, Service $service): void
{
abort_unless($service->user_id === $request->user()->id, 403);
abort_unless($service->service_type === 'game', 404);
}
/**
* @template T
*
* @param callable(): T $callback
* @return T|null
*/
private function safeApiCall(callable $callback): mixed
{
try {
return $callback();
} catch (\Exception $e) {
Log::warning('Game Panel API call failed', [
'error' => $e->getMessage(),
]);
return null;
}
}
private function fetchConsoleUrl(Service $service): ?string
{
// Pterodactyl API: GET /api/client/servers/{id}/websocket
return null;
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchFiles(Service $service, string $directory): array
{
// Pterodactyl API: GET /api/client/servers/{id}/files/list?directory={path}
return [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchDatabases(Service $service): array
{
// Pterodactyl API: GET /api/client/servers/{id}/databases
return [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchBackups(Service $service): array
{
// Pterodactyl API: GET /api/client/servers/{id}/backups
return [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchSchedules(Service $service): array
{
// Pterodactyl API: GET /api/client/servers/{id}/schedules
return [];
}
/**
* @return array<string, mixed>
*/
private function fetchStartupConfig(Service $service): array
{
// Pterodactyl API: GET /api/client/servers/{id}/startup
return [
'startup_command' => '',
'variables' => [],
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchSubUsers(Service $service): array
{
// Pterodactyl API: GET /api/client/servers/{id}/users
return [];
}
}

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account\Hosting;
use App\Http\Controllers\Controller;
use App\Models\Service;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
class HostingPanelController extends Controller
{
public function dashboard(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$usage = $this->safeApiCall(function () use ($service): array {
return $this->fetchUsage($service);
});
return Inertia::render('Services/Hosting/Dashboard', [
'service' => $service->load('plan', 'subscription'),
'usage' => $usage ?? [
'disk_used' => 0,
'disk_limit' => 0,
'bandwidth_used' => 0,
'bandwidth_limit' => 0,
'email_accounts' => 0,
'databases' => 0,
'domains' => 0,
'subdomains' => 0,
],
]);
}
public function files(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$path = (string) $request->query('path', '/');
$fileList = $this->safeApiCall(function () use ($service, $path): array {
return $this->fetchFiles($service, $path);
});
return Inertia::render('Services/Hosting/Files', [
'service' => $service->load('plan'),
'files' => $fileList ?? [],
'currentPath' => $path,
]);
}
public function databases(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$databaseList = $this->safeApiCall(function () use ($service): array {
return $this->fetchDatabases($service);
});
return Inertia::render('Services/Hosting/Databases', [
'service' => $service->load('plan'),
'databases' => $databaseList ?? [],
]);
}
public function email(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$emailAccounts = $this->safeApiCall(function () use ($service): array {
return $this->fetchEmailAccounts($service);
});
return Inertia::render('Services/Hosting/Email', [
'service' => $service->load('plan'),
'emailAccounts' => $emailAccounts ?? [],
]);
}
public function domains(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$domainList = $this->safeApiCall(function () use ($service): array {
return $this->fetchDomains($service);
});
return Inertia::render('Services/Hosting/Domains', [
'service' => $service->load('plan'),
'domains' => $domainList ?? [],
]);
}
public function dns(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$records = $this->safeApiCall(function () use ($service): array {
return $this->fetchDnsRecords($service);
});
return Inertia::render('Services/Hosting/Dns', [
'service' => $service->load('plan'),
'records' => $records ?? [],
]);
}
public function ssl(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$certificates = $this->safeApiCall(function () use ($service): array {
return $this->fetchSslCertificates($service);
});
return Inertia::render('Services/Hosting/Ssl', [
'service' => $service->load('plan'),
'certificates' => $certificates ?? [],
]);
}
public function php(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$phpConfig = $this->safeApiCall(function () use ($service): array {
return $this->fetchPhpConfig($service);
});
return Inertia::render('Services/Hosting/Php', [
'service' => $service->load('plan'),
'phpConfig' => $phpConfig ?? [
'version' => '8.3',
'available_versions' => ['8.1', '8.2', '8.3'],
'settings' => [],
],
]);
}
public function cron(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$cronJobs = $this->safeApiCall(function () use ($service): array {
return $this->fetchCronJobs($service);
});
return Inertia::render('Services/Hosting/Cron', [
'service' => $service->load('plan'),
'cronJobs' => $cronJobs ?? [],
]);
}
public function settings(Request $request, Service $service): Response
{
$this->authorize($request, $service);
return Inertia::render('Services/Hosting/Settings', [
'service' => $service->load('plan'),
]);
}
private function authorize(Request $request, Service $service): void
{
abort_unless($service->user_id === $request->user()->id, 403);
abort_unless($service->service_type === 'hosting', 404);
}
/**
* @template T
*
* @param callable(): T $callback
* @return T|null
*/
private function safeApiCall(callable $callback): mixed
{
try {
return $callback();
} catch (\Exception $e) {
Log::warning('Hosting Panel API call failed', [
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* @return array<string, mixed>
*/
private function fetchUsage(Service $service): array
{
// Enhance API: GET /api/v1/websites/{id}/usage
return [
'disk_used' => 0,
'disk_limit' => 0,
'bandwidth_used' => 0,
'bandwidth_limit' => 0,
'email_accounts' => 0,
'databases' => 0,
'domains' => 0,
'subdomains' => 0,
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchFiles(Service $service, string $path): array
{
// Enhance API: GET /api/v1/websites/{id}/files?path={path}
return [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchDatabases(Service $service): array
{
// Enhance API: GET /api/v1/websites/{id}/databases
return [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchEmailAccounts(Service $service): array
{
// Enhance API: GET /api/v1/websites/{id}/email-accounts
return [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchDomains(Service $service): array
{
// Enhance API: GET /api/v1/websites/{id}/domains
return [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchDnsRecords(Service $service): array
{
// Enhance API: GET /api/v1/websites/{id}/dns
return [];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchSslCertificates(Service $service): array
{
// Enhance API: GET /api/v1/websites/{id}/ssl
return [];
}
/**
* @return array<string, mixed>
*/
private function fetchPhpConfig(Service $service): array
{
// Enhance API: GET /api/v1/websites/{id}/php
return [
'version' => '8.3',
'available_versions' => ['8.1', '8.2', '8.3'],
'settings' => [],
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchCronJobs(Service $service): array
{
// Enhance API: GET /api/v1/websites/{id}/cron
return [];
}
}

View File

@@ -0,0 +1,501 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account\Vps;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\ProvisioningLog;
use App\Models\Service;
use App\Services\Provisioning\VirtFusionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
class VpsPanelController extends Controller
{
public function __construct(
private readonly VirtFusionService $virtfusion,
) {}
public function dashboard(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$status = $this->safeApiCall(fn () => $this->virtfusion->getStatus($service));
$activities = ProvisioningLog::where('service_id', $service->id)
->orderByDesc('created_at')
->limit(10)
->get()
->map(fn (ProvisioningLog $log): array => [
'action' => $log->action,
'timestamp' => $log->created_at->toISOString(),
'details' => $log->status.($log->error_message ? ': '.$log->error_message : ''),
])
->toArray();
return Inertia::render('Services/Vps/Dashboard', [
'service' => $service->load('plan', 'subscription'),
'serverStatus' => $status,
'recentActivity' => $activities,
]);
}
public function console(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$vncUrl = $this->safeApiCall(fn (): ?string => $this->virtfusion->getVncUrl($service));
return Inertia::render('Services/Vps/Console', [
'service' => $service->load('plan'),
'vncUrl' => $vncUrl,
]);
}
public function graphs(Request $request, Service $service): Response
{
$this->authorize($request, $service);
// Attempt to fetch resource data from VirtFusion API
$resourceData = $this->safeApiCall(function () use ($service): array {
return $this->fetchResourceGraphs($service);
});
return Inertia::render('Services/Vps/Graphs', [
'service' => $service->load('plan'),
'resourceData' => $resourceData ?? $this->emptyResourceData(),
]);
}
public function firewall(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$rules = $this->safeApiCall(function () use ($service): array {
return $this->fetchFirewallRules($service);
});
return Inertia::render('Services/Vps/Firewall', [
'service' => $service->load('plan'),
'rules' => $rules ?? [],
]);
}
public function storeFirewallRule(Request $request, Service $service): RedirectResponse
{
$this->authorize($request, $service);
$validated = $request->validate([
'protocol' => ['required', 'string', 'in:tcp,udp,icmp'],
'port' => ['required_unless:protocol,icmp', 'nullable', 'string', 'max:11'],
'source' => ['required', 'string', 'max:45'],
'action' => ['required', 'string', 'in:accept,drop,reject'],
]);
try {
$this->createFirewallRule($service, $validated);
$this->logAudit($request, $service, 'firewall_add', true, $validated);
return redirect()->back()->with('success', 'Firewall rule created successfully.');
} catch (\Exception $e) {
Log::error('VPS firewall rule creation failed', [
'service_id' => $service->id,
'error' => $e->getMessage(),
]);
return redirect()->back()->with('error', 'Failed to create firewall rule.');
}
}
public function destroyFirewallRule(Request $request, Service $service, int $ruleId): RedirectResponse
{
$this->authorize($request, $service);
try {
$this->deleteFirewallRule($service, $ruleId);
$this->logAudit($request, $service, 'firewall_remove', true, ['rule_id' => $ruleId]);
return redirect()->back()->with('success', 'Firewall rule deleted successfully.');
} catch (\Exception $e) {
Log::error('VPS firewall rule deletion failed', [
'service_id' => $service->id,
'error' => $e->getMessage(),
]);
return redirect()->back()->with('error', 'Failed to delete firewall rule.');
}
}
public function snapshots(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$snapshotList = $this->safeApiCall(function () use ($service): array {
return $this->fetchSnapshots($service);
});
return Inertia::render('Services/Vps/Snapshots', [
'service' => $service->load('plan'),
'snapshots' => $snapshotList ?? [],
]);
}
public function createSnapshot(Request $request, Service $service): RedirectResponse
{
$this->authorize($request, $service);
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
]);
try {
$this->storeSnapshot($service, $validated['name']);
$this->logAudit($request, $service, 'snapshot_create', true, $validated);
return redirect()->back()->with('success', 'Snapshot creation initiated.');
} catch (\Exception $e) {
Log::error('VPS snapshot creation failed', [
'service_id' => $service->id,
'error' => $e->getMessage(),
]);
return redirect()->back()->with('error', 'Failed to create snapshot.');
}
}
public function restoreSnapshot(Request $request, Service $service, int $snapshotId): RedirectResponse
{
$this->authorize($request, $service);
try {
$this->doRestoreSnapshot($service, $snapshotId);
$this->logAudit($request, $service, 'snapshot_restore', true, ['snapshot_id' => $snapshotId]);
return redirect()->back()->with('success', 'Snapshot restore initiated.');
} catch (\Exception $e) {
Log::error('VPS snapshot restore failed', [
'service_id' => $service->id,
'error' => $e->getMessage(),
]);
return redirect()->back()->with('error', 'Failed to restore snapshot.');
}
}
public function deleteSnapshot(Request $request, Service $service, int $snapshotId): RedirectResponse
{
$this->authorize($request, $service);
try {
$this->doDeleteSnapshot($service, $snapshotId);
$this->logAudit($request, $service, 'snapshot_delete', true, ['snapshot_id' => $snapshotId]);
return redirect()->back()->with('success', 'Snapshot deleted successfully.');
} catch (\Exception $e) {
Log::error('VPS snapshot deletion failed', [
'service_id' => $service->id,
'error' => $e->getMessage(),
]);
return redirect()->back()->with('error', 'Failed to delete snapshot.');
}
}
public function backups(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$backupList = $this->safeApiCall(function () use ($service): array {
return $this->fetchBackups($service);
});
return Inertia::render('Services/Vps/Backups', [
'service' => $service->load('plan'),
'backups' => $backupList ?? [],
]);
}
public function network(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$networkData = $this->safeApiCall(function () use ($service): array {
return $this->fetchNetworkData($service);
});
return Inertia::render('Services/Vps/Network', [
'service' => $service->load('plan'),
'networkData' => $networkData ?? [
'ips' => [],
'bandwidth' => [],
],
]);
}
public function updateRdns(Request $request, Service $service): RedirectResponse
{
$this->authorize($request, $service);
$validated = $request->validate([
'ip' => ['required', 'string', 'ip'],
'rdns' => ['required', 'string', 'max:255'],
]);
try {
$this->doUpdateRdns($service, $validated['ip'], $validated['rdns']);
$this->logAudit($request, $service, 'rdns_update', true, $validated);
return redirect()->back()->with('success', 'Reverse DNS updated successfully.');
} catch (\Exception $e) {
Log::error('VPS rDNS update failed', [
'service_id' => $service->id,
'error' => $e->getMessage(),
]);
return redirect()->back()->with('error', 'Failed to update reverse DNS.');
}
}
public function settings(Request $request, Service $service): Response
{
$this->authorize($request, $service);
return Inertia::render('Services/Vps/Settings', [
'service' => $service->load('plan'),
]);
}
public function updateSettings(Request $request, Service $service): RedirectResponse
{
$this->authorize($request, $service);
$validated = $request->validate([
'hostname' => ['sometimes', 'string', 'max:255'],
'boot_order' => ['sometimes', 'string', 'in:disk,cdrom,network'],
]);
try {
$this->doUpdateSettings($service, $validated);
$this->logAudit($request, $service, 'settings_update', true, $validated);
return redirect()->back()->with('success', 'Settings updated successfully.');
} catch (\Exception $e) {
Log::error('VPS settings update failed', [
'service_id' => $service->id,
'error' => $e->getMessage(),
]);
return redirect()->back()->with('error', 'Failed to update settings.');
}
}
public function activity(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$activities = ProvisioningLog::where('service_id', $service->id)
->orderByDesc('created_at')
->limit(50)
->get()
->map(fn (ProvisioningLog $log): array => [
'action' => $log->action,
'timestamp' => $log->created_at->toISOString(),
'details' => $log->status.($log->error_message ? ': '.$log->error_message : ''),
])
->toArray();
return Inertia::render('Services/Vps/Activity', [
'service' => $service->load('plan'),
'activities' => $activities,
]);
}
public function rebuild(Request $request, Service $service): Response
{
$this->authorize($request, $service);
$templates = $this->safeApiCall(fn (): array => $this->virtfusion->getTemplates($service));
return Inertia::render('Services/Vps/Rebuild', [
'service' => $service->load('plan'),
'templates' => $templates ?? [],
]);
}
private function authorize(Request $request, Service $service): void
{
abort_unless($service->user_id === $request->user()->id, 403);
abort_unless($service->service_type === 'vps', 404);
}
/**
* @template T
*
* @param callable(): T $callback
* @return T|null
*/
private function safeApiCall(callable $callback): mixed
{
try {
return $callback();
} catch (\Exception $e) {
Log::warning('VPS Panel API call failed', [
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* @return array<string, mixed>
*/
private function fetchResourceGraphs(Service $service): array
{
// VirtFusion API: GET /servers/{id}/resources
$response = app(VirtFusionService::class);
return $this->emptyResourceData();
}
/**
* @return array<string, array<int, array<string, mixed>>>
*/
private function emptyResourceData(): array
{
return [
'cpu' => [],
'memory' => [],
'disk' => [],
'network' => [],
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchFirewallRules(Service $service): array
{
// VirtFusion API: GET /servers/{id}/firewall
return [];
}
/**
* @param array<string, mixed> $data
*/
private function createFirewallRule(Service $service, array $data): void
{
// VirtFusion API: POST /servers/{id}/firewall
}
private function deleteFirewallRule(Service $service, int $ruleId): void
{
// VirtFusion API: DELETE /servers/{id}/firewall/{ruleId}
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchSnapshots(Service $service): array
{
// VirtFusion API: GET /servers/{id}/snapshots
return [];
}
private function storeSnapshot(Service $service, string $name): void
{
// VirtFusion API: POST /servers/{id}/snapshots
}
private function doRestoreSnapshot(Service $service, int $snapshotId): void
{
// VirtFusion API: POST /servers/{id}/snapshots/{snapshotId}/restore
}
private function doDeleteSnapshot(Service $service, int $snapshotId): void
{
// VirtFusion API: DELETE /servers/{id}/snapshots/{snapshotId}
}
/**
* @return array<string, mixed>
*/
private function fetchBackups(Service $service): array
{
// VirtFusion API: GET /servers/{id}/backups
return [];
}
/**
* @return array<string, mixed>
*/
private function fetchNetworkData(Service $service): array
{
$ips = [];
if ($service->ipv4_address) {
$ips[] = [
'address' => $service->ipv4_address,
'type' => 'IPv4',
'primary' => true,
'rdns' => null,
];
}
if ($service->ipv6_address) {
$ips[] = [
'address' => $service->ipv6_address,
'type' => 'IPv6',
'primary' => false,
'rdns' => null,
];
}
return [
'ips' => $ips,
'bandwidth' => [],
];
}
private function doUpdateRdns(Service $service, string $ip, string $rdns): void
{
// VirtFusion API: PUT /servers/{id}/rdns
}
/**
* @param array<string, mixed> $data
*/
private function doUpdateSettings(Service $service, array $data): void
{
if (isset($data['hostname'])) {
$service->update(['hostname' => $data['hostname']]);
}
// VirtFusion API: PUT /servers/{id}/settings
}
/**
* @param array<string, mixed> $changes
*/
private function logAudit(Request $request, Service $service, string $action, bool $success, array $changes = []): void
{
AuditLog::create([
'user_id' => $request->user()->id,
'action' => 'vps_'.$action,
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => array_merge($changes, [
'success' => $success,
'service_id' => $service->id,
'platform' => $service->platform,
]),
]);
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Affiliate;
use App\Models\AffiliateCommission;
use App\Models\AffiliatePayout;
use App\Models\AuditLog;
use App\Services\AffiliateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AffiliateController extends Controller
{
public function __construct(
private AffiliateService $affiliateService,
) {}
public function index(Request $request): Response
{
$query = Affiliate::query()
->with('user:id,name,email')
->withCount('referrals', 'commissions');
if ($request->filled('status') && $request->input('status') !== 'all') {
$query->where('status', $request->input('status'));
}
if ($request->filled('search')) {
$search = $request->input('search');
$query->whereHas('user', function ($q) use ($search): void {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
$affiliates = $query->latest()->paginate(25);
return Inertia::render('Admin/Affiliates/Index', [
'affiliates' => $affiliates,
'filters' => [
'status' => $request->input('status', 'all'),
'search' => $request->input('search', ''),
],
]);
}
public function show(Affiliate $affiliate): Response
{
$affiliate->load('user:id,name,email');
$affiliate->loadCount('referrals', 'commissions', 'payouts');
$referrals = $affiliate->referrals()
->with('referredUser:id,name,email')
->latest()
->paginate(10, ['*'], 'referrals_page');
$commissions = $affiliate->commissions()
->with('referral.referredUser:id,name,email')
->latest()
->paginate(10, ['*'], 'commissions_page');
$payouts = $affiliate->payouts()
->latest()
->paginate(10, ['*'], 'payouts_page');
return Inertia::render('Admin/Affiliates/Show', [
'affiliate' => $affiliate,
'referrals' => $referrals,
'commissions' => $commissions,
'payouts' => $payouts,
]);
}
public function approve(Request $request, Affiliate $affiliate): RedirectResponse
{
$this->affiliateService->approve($affiliate);
AuditLog::create([
'user_id' => $affiliate->user_id,
'admin_id' => $request->user()->id,
'action' => 'affiliate_approved',
'resource_type' => 'Affiliate',
'resource_id' => $affiliate->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return back()->with('success', 'Affiliate approved successfully.');
}
public function suspend(Request $request, Affiliate $affiliate): RedirectResponse
{
$affiliate->update(['status' => 'suspended']);
AuditLog::create([
'user_id' => $affiliate->user_id,
'admin_id' => $request->user()->id,
'action' => 'affiliate_suspended',
'resource_type' => 'Affiliate',
'resource_id' => $affiliate->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return back()->with('success', 'Affiliate suspended.');
}
public function commissions(Request $request): Response
{
$commissions = AffiliateCommission::query()
->where('status', 'pending')
->with([
'affiliate.user:id,name,email',
'referral.referredUser:id,name,email',
])
->latest()
->paginate(25);
return Inertia::render('Admin/Affiliates/Commissions', [
'commissions' => $commissions,
]);
}
public function approveCommission(Request $request, AffiliateCommission $commission): RedirectResponse
{
$this->affiliateService->approveCommission($commission);
AuditLog::create([
'user_id' => $commission->affiliate->user_id,
'admin_id' => $request->user()->id,
'action' => 'affiliate_commission_approved',
'resource_type' => 'AffiliateCommission',
'resource_id' => $commission->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['amount' => (float) $commission->amount],
]);
return back()->with('success', 'Commission approved successfully.');
}
public function payouts(Request $request): Response
{
$payouts = AffiliatePayout::query()
->whereIn('status', ['pending', 'processing'])
->with('affiliate.user:id,name,email')
->latest()
->paginate(25);
return Inertia::render('Admin/Affiliates/Payouts', [
'payouts' => $payouts,
]);
}
public function processPayout(Request $request, AffiliatePayout $payout): RedirectResponse
{
$this->affiliateService->processPayout($payout);
AuditLog::create([
'user_id' => $payout->affiliate->user_id,
'admin_id' => $request->user()->id,
'action' => 'affiliate_payout_processed',
'resource_type' => 'AffiliatePayout',
'resource_id' => $payout->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['amount' => (float) $payout->amount, 'method' => $payout->method],
]);
return back()->with('success', 'Payout processed successfully.');
}
public function settings(): Response
{
return Inertia::render('Admin/Affiliates/Settings', [
'settings' => [
'default_commission_type' => config('affiliate.default_commission_type'),
'default_commission_rate' => config('affiliate.default_commission_rate'),
'default_recurring_commissions' => config('affiliate.default_recurring_commissions'),
'default_minimum_payout' => config('affiliate.default_minimum_payout'),
'cookie_lifetime_days' => config('affiliate.cookie_lifetime_days'),
'auto_approve' => config('affiliate.auto_approve'),
],
]);
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\CannedResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CannedResponseController extends Controller
{
public function index(Request $request): Response
{
$query = CannedResponse::query()
->with('creator:id,name');
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('title', 'like', "%{$search}%")
->orWhere('content', 'like', "%{$search}%");
});
}
if ($category = $request->input('category')) {
$query->where('category', $category);
}
$cannedResponses = $query->latest()
->paginate(25)
->withQueryString();
$categories = CannedResponse::query()
->whereNotNull('category')
->distinct()
->pluck('category')
->sort()
->values();
return Inertia::render('Admin/Tickets/CannedResponses/Index', [
'cannedResponses' => $cannedResponses,
'categories' => $categories,
'filters' => [
'search' => $request->input('search', ''),
'category' => $request->input('category', ''),
],
]);
}
public function create(): Response
{
$categories = CannedResponse::query()
->whereNotNull('category')
->distinct()
->pluck('category')
->sort()
->values();
return Inertia::render('Admin/Tickets/CannedResponses/Create', [
'categories' => $categories,
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'max:10000'],
'category' => ['nullable', 'string', 'max:100'],
'is_shared' => ['boolean'],
]);
CannedResponse::query()->create([
...$validated,
'created_by' => $request->user()->id,
]);
return redirect()->route('admin.canned-responses.index')
->with('success', 'Canned response created successfully.');
}
public function edit(CannedResponse $cannedResponse): Response
{
$categories = CannedResponse::query()
->whereNotNull('category')
->distinct()
->pluck('category')
->sort()
->values();
return Inertia::render('Admin/Tickets/CannedResponses/Edit', [
'cannedResponse' => $cannedResponse,
'categories' => $categories,
]);
}
public function update(Request $request, CannedResponse $cannedResponse): RedirectResponse
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'max:10000'],
'category' => ['nullable', 'string', 'max:100'],
'is_shared' => ['boolean'],
]);
$cannedResponse->update($validated);
return redirect()->route('admin.canned-responses.index')
->with('success', 'Canned response updated successfully.');
}
public function destroy(CannedResponse $cannedResponse): RedirectResponse
{
$cannedResponse->delete();
return redirect()->route('admin.canned-responses.index')
->with('success', 'Canned response deleted successfully.');
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreCreditNoteRequest;
use App\Models\AuditLog;
use App\Models\CreditNote;
use App\Models\Invoice;
use App\Models\User;
use App\Services\Billing\CreditService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CreditNoteController extends Controller
{
public function __construct(
private CreditService $creditService,
) {}
public function index(Request $request): Response
{
$query = CreditNote::query()
->with('user:id,name,email');
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('number', 'like', "%{$search}%")
->orWhereHas('user', function ($uq) use ($search): void {
$uq->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
if ($status = $request->input('status')) {
$query->where('status', $status);
}
$creditNotes = $query->latest()->paginate(15)->withQueryString();
return Inertia::render('Admin/CreditNotes/Index', [
'creditNotes' => $creditNotes,
'filters' => [
'search' => $request->input('search', ''),
'status' => $request->input('status', ''),
],
]);
}
public function create(): Response
{
$customers = User::query()
->select('id', 'name', 'email', 'credit_balance')
->orderBy('name')
->get();
$invoices = Invoice::query()
->select('id', 'number', 'user_id', 'total', 'status')
->whereIn('status', ['paid', 'pending', 'overdue'])
->latest()
->limit(200)
->get();
return Inertia::render('Admin/CreditNotes/Create', [
'customers' => $customers,
'invoices' => $invoices,
]);
}
public function store(StoreCreditNoteRequest $request): RedirectResponse
{
$validated = $request->validated();
$user = User::findOrFail($validated['user_id']);
$invoice = isset($validated['invoice_id']) ? Invoice::find($validated['invoice_id']) : null;
$creditNote = $this->creditService->issueCreditNote(
user: $user,
amount: (float) $validated['amount'],
reason: $validated['reason'] ?? null,
invoice: $invoice,
creator: $request->user(),
);
AuditLog::create([
'user_id' => $user->id,
'admin_id' => $request->user()?->id,
'action' => 'issue_credit_note',
'resource_type' => 'credit_note',
'resource_id' => $creditNote->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => [
'amount' => $validated['amount'],
'reason' => $validated['reason'] ?? null,
],
]);
return redirect()->route('credit-notes.show', $creditNote)
->with('success', "Credit note {$creditNote->number} has been issued.");
}
public function show(CreditNote $creditNote): Response
{
$creditNote->load([
'user:id,name,email,credit_balance',
'invoice:id,number,total,status',
'creator:id,name,email',
'accountCredits',
]);
return Inertia::render('Admin/CreditNotes/Show', [
'creditNote' => $creditNote,
]);
}
public function download(CreditNote $creditNote): \Symfony\Component\HttpFoundation\Response
{
$creditNote->load(['user', 'invoice']);
$pdf = Pdf::loadView('pdf.credit-note', ['creditNote' => $creditNote]);
return $pdf->download("credit-note-{$creditNote->number}.pdf");
}
public function void(Request $request, CreditNote $creditNote): RedirectResponse
{
if ($creditNote->status === 'voided') {
return redirect()->back()->with('error', 'This credit note is already voided.');
}
$this->creditService->voidCreditNote($creditNote);
AuditLog::create([
'user_id' => $creditNote->user_id,
'admin_id' => $request->user()?->id,
'action' => 'void_credit_note',
'resource_type' => 'credit_note',
'resource_id' => $creditNote->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return redirect()->back()->with('success', "Credit note {$creditNote->number} has been voided.");
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Currency;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;
class CurrencyController extends Controller
{
public function index(): Response
{
$currencies = Currency::query()->orderBy('code')->get();
return Inertia::render('Admin/Currencies/Index', [
'currencies' => $currencies,
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'code' => ['required', 'string', 'size:3', 'unique:currencies,code'],
'symbol' => ['required', 'string', 'max:5'],
'name' => ['required', 'string', 'max:255'],
'decimal_places' => ['required', 'integer', 'min:0', 'max:4'],
'exchange_rate' => ['required', 'numeric', 'min:0.000001'],
'is_base' => ['boolean'],
'is_enabled' => ['boolean'],
]);
$validated['code'] = strtoupper($validated['code']);
// If setting as base currency, unset other base currencies
if ($validated['is_base'] ?? false) {
Currency::query()->where('is_base', true)->update(['is_base' => false]);
$validated['exchange_rate'] = 1.000000;
}
$currency = Currency::create($validated);
Cache::forget('currencies:enabled');
Cache::forget('currencies:base');
AuditLog::create([
'admin_id' => $request->user()?->id,
'action' => 'create_currency',
'resource_type' => 'currency',
'resource_id' => $currency->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => $validated,
]);
return redirect()->route('admin.currencies.index')
->with('success', "Currency {$currency->code} has been created.");
}
public function update(Request $request, Currency $currency): RedirectResponse
{
$validated = $request->validate([
'symbol' => ['required', 'string', 'max:5'],
'name' => ['required', 'string', 'max:255'],
'decimal_places' => ['required', 'integer', 'min:0', 'max:4'],
'exchange_rate' => ['required', 'numeric', 'min:0.000001'],
'is_base' => ['boolean'],
'is_enabled' => ['boolean'],
]);
// If setting as base currency, unset other base currencies
if ($validated['is_base'] ?? false) {
Currency::query()->where('is_base', true)->where('id', '!=', $currency->id)->update(['is_base' => false]);
$validated['exchange_rate'] = 1.000000;
}
$currency->update($validated);
Cache::forget('currencies:enabled');
Cache::forget('currencies:base');
AuditLog::create([
'admin_id' => $request->user()?->id,
'action' => 'update_currency',
'resource_type' => 'currency',
'resource_id' => $currency->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => $validated,
]);
return redirect()->route('admin.currencies.index')
->with('success', "Currency {$currency->code} has been updated.");
}
public function destroy(Request $request, Currency $currency): RedirectResponse
{
if ($currency->is_base) {
return redirect()->back()->with('error', 'Cannot delete the base currency.');
}
$code = $currency->code;
$currency->delete();
Cache::forget('currencies:enabled');
Cache::forget('currencies:base');
AuditLog::create([
'admin_id' => $request->user()?->id,
'action' => 'delete_currency',
'resource_type' => 'currency',
'resource_id' => 0,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['code' => $code],
]);
return redirect()->route('admin.currencies.index')
->with('success', "Currency {$code} has been deleted.");
}
public function syncRates(Request $request): RedirectResponse
{
Artisan::call('currencies:sync-rates');
AuditLog::create([
'admin_id' => $request->user()?->id,
'action' => 'sync_exchange_rates',
'resource_type' => 'currency',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return redirect()->route('admin.currencies.index')
->with('success', 'Exchange rates have been synced.');
}
}

View File

@@ -25,7 +25,7 @@ class DashboardController extends Controller
->where('created_at', '>=', now()->startOfMonth()) ->where('created_at', '>=', now()->startOfMonth())
->count(); ->count();
// MRR: sum of plan prices normalized to monthly // MRR: sum of recurring_amount (with plan_prices fallback) normalized to monthly
$mrr = $this->calculateMrr(); $mrr = $this->calculateMrr();
// Previous month MRR for month-over-month change // Previous month MRR for month-over-month change
@@ -36,6 +36,12 @@ class DashboardController extends Controller
$arr = $mrr * 12; $arr = $mrr * 12;
// Invoice-based MRR: total paid in last 30 days
$invoiceMrr = (float) Invoice::query()
->where('status', 'paid')
->where('paid_at', '>=', now()->subDays(30))
->sum('total');
$activeServices = Service::query() $activeServices = Service::query()
->where('status', 'active') ->where('status', 'active')
->count(); ->count();
@@ -107,6 +113,7 @@ class DashboardController extends Controller
'totalCustomers', 'totalCustomers',
'newCustomersThisMonth', 'newCustomersThisMonth',
'mrr', 'mrr',
'invoiceMrr',
'mrrChangePercent', 'mrrChangePercent',
'arr', 'arr',
'activeServices', 'activeServices',
@@ -131,16 +138,27 @@ class DashboardController extends Controller
return (float) (Subscription::query() return (float) (Subscription::query()
->where('subscriptions.stripe_status', 'active') ->where('subscriptions.stripe_status', 'active')
->whereNotNull('subscriptions.plan_id') ->whereNotNull('subscriptions.plan_id')
->join('plan_prices', function ($join): void { ->leftJoin('plan_prices', function ($join): void {
$join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id') $join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id')
->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle'); ->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle');
}) })
->selectRaw('SUM(CASE subscriptions.billing_cycle ->selectRaw('SUM(CASE
WHEN subscriptions.recurring_amount IS NOT NULL THEN
CASE subscriptions.billing_cycle
WHEN "monthly" THEN subscriptions.recurring_amount
WHEN "quarterly" THEN subscriptions.recurring_amount / 3
WHEN "semi_annual" THEN subscriptions.recurring_amount / 6
WHEN "annual" THEN subscriptions.recurring_amount / 12
ELSE subscriptions.recurring_amount
END
ELSE
CASE subscriptions.billing_cycle
WHEN "monthly" THEN plan_prices.price WHEN "monthly" THEN plan_prices.price
WHEN "quarterly" THEN plan_prices.price / 3 WHEN "quarterly" THEN plan_prices.price / 3
WHEN "semi_annual" THEN plan_prices.price / 6 WHEN "semi_annual" THEN plan_prices.price / 6
WHEN "annual" THEN plan_prices.price / 12 WHEN "annual" THEN plan_prices.price / 12
ELSE plan_prices.price ELSE plan_prices.price
END
END) as mrr') END) as mrr')
->value('mrr') ?? 0); ->value('mrr') ?? 0);
} }
@@ -157,16 +175,27 @@ class DashboardController extends Controller
$query->whereNull('subscriptions.cancelled_at') $query->whereNull('subscriptions.cancelled_at')
->orWhere('subscriptions.cancelled_at', '>', $lastMonthEnd); ->orWhere('subscriptions.cancelled_at', '>', $lastMonthEnd);
}) })
->join('plan_prices', function ($join): void { ->leftJoin('plan_prices', function ($join): void {
$join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id') $join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id')
->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle'); ->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle');
}) })
->selectRaw('SUM(CASE subscriptions.billing_cycle ->selectRaw('SUM(CASE
WHEN subscriptions.recurring_amount IS NOT NULL THEN
CASE subscriptions.billing_cycle
WHEN "monthly" THEN subscriptions.recurring_amount
WHEN "quarterly" THEN subscriptions.recurring_amount / 3
WHEN "semi_annual" THEN subscriptions.recurring_amount / 6
WHEN "annual" THEN subscriptions.recurring_amount / 12
ELSE subscriptions.recurring_amount
END
ELSE
CASE subscriptions.billing_cycle
WHEN "monthly" THEN plan_prices.price WHEN "monthly" THEN plan_prices.price
WHEN "quarterly" THEN plan_prices.price / 3 WHEN "quarterly" THEN plan_prices.price / 3
WHEN "semi_annual" THEN plan_prices.price / 6 WHEN "semi_annual" THEN plan_prices.price / 6
WHEN "annual" THEN plan_prices.price / 12 WHEN "annual" THEN plan_prices.price / 12
ELSE plan_prices.price ELSE plan_prices.price
END
END) as mrr') END) as mrr')
->value('mrr') ?? 0); ->value('mrr') ?? 0);
} }

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreDebitNoteRequest;
use App\Models\AuditLog;
use App\Models\DebitNote;
use App\Models\Invoice;
use App\Models\User;
use App\Services\Billing\CreditService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DebitNoteController extends Controller
{
public function __construct(
private CreditService $creditService,
) {}
public function index(Request $request): Response
{
$query = DebitNote::query()
->with('user:id,name,email');
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('number', 'like', "%{$search}%")
->orWhereHas('user', function ($uq) use ($search): void {
$uq->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
if ($status = $request->input('status')) {
$query->where('status', $status);
}
$debitNotes = $query->latest()->paginate(15)->withQueryString();
return Inertia::render('Admin/DebitNotes/Index', [
'debitNotes' => $debitNotes,
'filters' => [
'search' => $request->input('search', ''),
'status' => $request->input('status', ''),
],
]);
}
public function create(): Response
{
$customers = User::query()
->select('id', 'name', 'email', 'credit_balance')
->orderBy('name')
->get();
$invoices = Invoice::query()
->select('id', 'number', 'user_id', 'total', 'status')
->latest()
->limit(200)
->get();
return Inertia::render('Admin/DebitNotes/Create', [
'customers' => $customers,
'invoices' => $invoices,
]);
}
public function store(StoreDebitNoteRequest $request): RedirectResponse
{
$validated = $request->validated();
$user = User::findOrFail($validated['user_id']);
$invoice = isset($validated['invoice_id']) ? Invoice::find($validated['invoice_id']) : null;
$debitNote = $this->creditService->issueDebitNote(
user: $user,
amount: (float) $validated['amount'],
reasonType: $validated['reason_type'],
reason: $validated['reason'] ?? null,
invoice: $invoice,
creator: $request->user(),
);
AuditLog::create([
'user_id' => $user->id,
'admin_id' => $request->user()?->id,
'action' => 'issue_debit_note',
'resource_type' => 'debit_note',
'resource_id' => $debitNote->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => [
'amount' => $validated['amount'],
'reason_type' => $validated['reason_type'],
'reason' => $validated['reason'] ?? null,
],
]);
return redirect()->route('debit-notes.show', $debitNote)
->with('success', "Debit note {$debitNote->number} has been issued.");
}
public function show(DebitNote $debitNote): Response
{
$debitNote->load([
'user:id,name,email,credit_balance',
'invoice:id,number,total,status',
'creator:id,name,email',
]);
return Inertia::render('Admin/DebitNotes/Show', [
'debitNote' => $debitNote,
]);
}
public function download(DebitNote $debitNote): \Symfony\Component\HttpFoundation\Response
{
$debitNote->load(['user', 'invoice']);
$pdf = Pdf::loadView('pdf.debit-note', ['debitNote' => $debitNote]);
return $pdf->download("debit-note-{$debitNote->number}.pdf");
}
public function void(Request $request, DebitNote $debitNote): RedirectResponse
{
if ($debitNote->status === 'voided') {
return redirect()->back()->with('error', 'This debit note is already voided.');
}
$this->creditService->voidDebitNote($debitNote);
AuditLog::create([
'user_id' => $debitNote->user_id,
'admin_id' => $request->user()?->id,
'action' => 'void_debit_note',
'resource_type' => 'debit_note',
'resource_id' => $debitNote->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return redirect()->back()->with('success', "Debit note {$debitNote->number} has been voided.");
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\OrderRiskAssessment;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class FraudQueueController extends Controller
{
public function index(Request $request): Response
{
$query = OrderRiskAssessment::query()
->with(['user:id,name,email', 'order:id,order_number,total,status'])
->whereNull('reviewed_at');
if ($request->filled('risk_level') && $request->input('risk_level') !== 'all') {
$query->where('risk_level', $request->input('risk_level'));
}
if ($request->filled('action') && $request->input('action') !== 'all') {
$query->where('auto_action', $request->input('action'));
}
$assessments = $query->latest()->paginate(25);
return Inertia::render('Admin/FraudQueue/Index', [
'assessments' => $assessments,
'filters' => [
'risk_level' => $request->input('risk_level', 'all'),
'action' => $request->input('action', 'all'),
],
]);
}
public function show(OrderRiskAssessment $assessment): Response
{
$assessment->load([
'user:id,name,email,status,created_at',
'order:id,order_number,total,status,currency,created_at',
'reviewer:id,name',
]);
return Inertia::render('Admin/FraudQueue/Show', [
'assessment' => $assessment,
]);
}
public function approve(Request $request, OrderRiskAssessment $assessment): RedirectResponse
{
$assessment->update([
'reviewed_by' => $request->user()->id,
'reviewed_at' => now(),
'auto_action' => 'approve',
]);
if ($assessment->order) {
$assessment->order->update(['status' => 'processing']);
}
AuditLog::create([
'user_id' => $assessment->user_id,
'admin_id' => $request->user()->id,
'action' => 'fraud_assessment_approved',
'resource_type' => 'OrderRiskAssessment',
'resource_id' => $assessment->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['risk_score' => $assessment->risk_score, 'risk_level' => $assessment->risk_level],
]);
return back()->with('success', 'Order approved and released from fraud queue.');
}
public function reject(Request $request, OrderRiskAssessment $assessment): RedirectResponse
{
$assessment->update([
'reviewed_by' => $request->user()->id,
'reviewed_at' => now(),
'auto_action' => 'reject',
]);
if ($assessment->order) {
$assessment->order->update([
'status' => 'cancelled',
'cancelled_at' => now(),
]);
}
AuditLog::create([
'user_id' => $assessment->user_id,
'admin_id' => $request->user()->id,
'action' => 'fraud_assessment_rejected',
'resource_type' => 'OrderRiskAssessment',
'resource_id' => $assessment->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['risk_score' => $assessment->risk_score, 'risk_level' => $assessment->risk_level],
]);
return back()->with('success', 'Order rejected and cancelled.');
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreKbArticleRequest;
use App\Http\Requests\Admin\UpdateKbArticleRequest;
use App\Models\KnowledgeBaseArticle;
use App\Models\KnowledgeBaseCategory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class KnowledgeBaseArticleController extends Controller
{
public function index(Request $request): Response
{
$query = KnowledgeBaseArticle::query()
->with(['category:id,name', 'author:id,name']);
if ($request->filled('search')) {
$search = $request->input('search');
$query->where(function ($q) use ($search): void {
$q->where('title', 'like', '%'.$search.'%')
->orWhere('excerpt', 'like', '%'.$search.'%');
});
}
if ($request->filled('category') && $request->input('category') !== 'all') {
$query->where('category_id', $request->input('category'));
}
if ($request->filled('status') && $request->input('status') !== 'all') {
$query->where('status', $request->input('status'));
}
$articles = $query
->orderByDesc('updated_at')
->paginate(25);
$articles->getCollection()->transform(function (KnowledgeBaseArticle $article) {
$article->setAttribute('helpful_percentage', $article->helpfulPercentage());
return $article;
});
$categories = KnowledgeBaseCategory::query()
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/KnowledgeBase/Articles/Index', [
'articles' => $articles,
'categories' => $categories,
'filters' => [
'search' => $request->input('search', ''),
'category' => $request->input('category', 'all'),
'status' => $request->input('status', 'all'),
],
]);
}
public function create(): Response
{
$categories = KnowledgeBaseCategory::query()
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/KnowledgeBase/Articles/Create', [
'categories' => $categories,
]);
}
public function store(StoreKbArticleRequest $request): RedirectResponse
{
$article = KnowledgeBaseArticle::query()->create([
'title' => $request->validated('title'),
'slug' => $request->validated('slug'),
'category_id' => $request->validated('category_id'),
'author_id' => $request->user()->id,
'content' => $request->validated('content'),
'excerpt' => $request->validated('excerpt'),
'status' => $request->validated('status'),
'is_featured' => $request->boolean('is_featured', false),
'published_at' => $request->validated('status') === 'published' ? now() : null,
]);
$article->revisions()->create([
'content' => $request->validated('content'),
'edited_by' => $request->user()->id,
]);
return redirect()
->route('admin.kb.articles.index')
->with('success', 'Article created successfully.');
}
public function show(KnowledgeBaseArticle $article): Response
{
$article->load([
'category:id,name,slug',
'author:id,name',
'revisions' => fn ($q) => $q->with('editor:id,name')->orderByDesc('created_at'),
]);
$article->setAttribute('helpful_percentage', $article->helpfulPercentage());
return Inertia::render('Admin/KnowledgeBase/Articles/Show', [
'article' => $article,
]);
}
public function edit(KnowledgeBaseArticle $article): Response
{
$article->load([
'revisions' => fn ($q) => $q->with('editor:id,name')->orderByDesc('created_at'),
]);
$categories = KnowledgeBaseCategory::query()
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/KnowledgeBase/Articles/Edit', [
'article' => $article,
'categories' => $categories,
]);
}
public function update(UpdateKbArticleRequest $request, KnowledgeBaseArticle $article): RedirectResponse
{
$wasPublished = $article->status === 'published';
$nowPublished = $request->validated('status') === 'published';
$article->update([
'title' => $request->validated('title'),
'slug' => $request->validated('slug'),
'category_id' => $request->validated('category_id'),
'content' => $request->validated('content'),
'excerpt' => $request->validated('excerpt'),
'status' => $request->validated('status'),
'is_featured' => $request->boolean('is_featured', false),
'published_at' => (! $wasPublished && $nowPublished) ? now() : $article->published_at,
]);
$article->revisions()->create([
'content' => $request->validated('content'),
'edited_by' => $request->user()->id,
]);
return redirect()
->route('admin.kb.articles.index')
->with('success', 'Article updated successfully.');
}
public function destroy(KnowledgeBaseArticle $article): RedirectResponse
{
$article->delete();
return redirect()
->route('admin.kb.articles.index')
->with('success', 'Article deleted successfully.');
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreKbCategoryRequest;
use App\Http\Requests\Admin\UpdateKbCategoryRequest;
use App\Models\KnowledgeBaseCategory;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class KnowledgeBaseCategoryController extends Controller
{
public function index(): Response
{
$categories = KnowledgeBaseCategory::query()
->withCount('articles')
->with('parent:id,name')
->orderBy('sort_order')
->orderBy('name')
->get();
return Inertia::render('Admin/KnowledgeBase/Categories/Index', [
'categories' => $categories,
]);
}
public function create(): Response
{
$parentCategories = KnowledgeBaseCategory::query()
->root()
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/KnowledgeBase/Categories/Create', [
'parentCategories' => $parentCategories,
]);
}
public function store(StoreKbCategoryRequest $request): RedirectResponse
{
KnowledgeBaseCategory::query()->create([
'name' => $request->validated('name'),
'slug' => $request->validated('slug'),
'description' => $request->validated('description'),
'icon' => $request->validated('icon'),
'parent_id' => $request->validated('parent_id'),
'sort_order' => $request->validated('sort_order', 0),
'is_visible' => $request->boolean('is_visible', true),
]);
return redirect()
->route('admin.kb.categories.index')
->with('success', 'Category created successfully.');
}
public function edit(KnowledgeBaseCategory $category): Response
{
$parentCategories = KnowledgeBaseCategory::query()
->root()
->where('id', '!=', $category->id)
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name']);
return Inertia::render('Admin/KnowledgeBase/Categories/Edit', [
'category' => $category,
'parentCategories' => $parentCategories,
]);
}
public function update(UpdateKbCategoryRequest $request, KnowledgeBaseCategory $category): RedirectResponse
{
$category->update([
'name' => $request->validated('name'),
'slug' => $request->validated('slug'),
'description' => $request->validated('description'),
'icon' => $request->validated('icon'),
'parent_id' => $request->validated('parent_id'),
'sort_order' => $request->validated('sort_order', 0),
'is_visible' => $request->boolean('is_visible', true),
]);
return redirect()
->route('admin.kb.categories.index')
->with('success', 'Category updated successfully.');
}
public function destroy(KnowledgeBaseCategory $category): RedirectResponse
{
if ($category->articles()->exists()) {
return redirect()
->back()
->with('error', 'Cannot delete a category that has articles. Move or delete the articles first.');
}
$category->delete();
return redirect()
->route('admin.kb.categories.index')
->with('success', 'Category deleted successfully.');
}
}

View File

@@ -65,6 +65,10 @@ class PlanController extends Controller
'provisioning_config' => $request->validated('provisioning_config'), 'provisioning_config' => $request->validated('provisioning_config'),
'stock_quantity' => $request->validated('stock_quantity'), 'stock_quantity' => $request->validated('stock_quantity'),
'sort_order' => $request->validated('sort_order', 0), 'sort_order' => $request->validated('sort_order', 0),
'days_to_suspend' => $request->validated('days_to_suspend'),
'days_to_terminate' => $request->validated('days_to_terminate'),
'auto_suspend_enabled' => $request->validated('auto_suspend_enabled', true),
'auto_terminate_enabled' => $request->validated('auto_terminate_enabled', true),
'status' => 'active', 'status' => 'active',
]); ]);
@@ -99,6 +103,10 @@ class PlanController extends Controller
'provisioning_config' => $request->validated('provisioning_config'), 'provisioning_config' => $request->validated('provisioning_config'),
'stock_quantity' => $request->validated('stock_quantity'), 'stock_quantity' => $request->validated('stock_quantity'),
'sort_order' => $request->validated('sort_order', 0), 'sort_order' => $request->validated('sort_order', 0),
'days_to_suspend' => $request->validated('days_to_suspend'),
'days_to_terminate' => $request->validated('days_to_terminate'),
'auto_suspend_enabled' => $request->validated('auto_suspend_enabled', true),
'auto_terminate_enabled' => $request->validated('auto_terminate_enabled', true),
]); ]);
return redirect() return redirect()

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreQuoteRequest;
use App\Models\AuditLog;
use App\Models\Quote;
use App\Models\User;
use App\Notifications\QuoteSentNotification;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Notification;
use Inertia\Inertia;
use Inertia\Response;
class QuoteController extends Controller
{
public function index(Request $request): Response
{
$query = Quote::query()
->with(['user:id,name,email', 'creator:id,name,email']);
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('number', 'like', "%{$search}%")
->orWhere('prospect_email', 'like', "%{$search}%")
->orWhere('prospect_name', 'like', "%{$search}%")
->orWhereHas('user', function ($uq) use ($search): void {
$uq->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
if ($status = $request->input('status')) {
$query->where('status', $status);
}
$quotes = $query->latest()->paginate(15)->withQueryString();
return Inertia::render('Admin/Quotes/Index', [
'quotes' => $quotes,
'filters' => [
'search' => $request->input('search', ''),
'status' => $request->input('status', ''),
],
]);
}
public function create(): Response
{
$customers = User::query()
->select('id', 'name', 'email')
->orderBy('name')
->get();
return Inertia::render('Admin/Quotes/Create', [
'customers' => $customers,
]);
}
public function store(StoreQuoteRequest $request): RedirectResponse
{
$validated = $request->validated();
$items = $validated['items'];
$subtotal = collect($items)->sum(fn (array $item): float => (float) $item['unit_price'] * (int) $item['quantity']);
$tax = (float) ($validated['tax'] ?? 0);
$total = $subtotal + $tax;
$quote = Quote::create([
'user_id' => $validated['user_id'] ?? null,
'prospect_email' => $validated['prospect_email'] ?? null,
'prospect_name' => $validated['prospect_name'] ?? null,
'number' => Quote::generateNumber(),
'status' => 'draft',
'items' => $items,
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
'currency' => $validated['currency'],
'notes' => $validated['notes'] ?? null,
'valid_until' => $validated['valid_until'] ?? null,
'created_by' => $request->user()->id,
]);
AuditLog::create([
'admin_id' => $request->user()?->id,
'action' => 'create_quote',
'resource_type' => 'quote',
'resource_id' => $quote->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['total' => $total, 'items_count' => count($items)],
]);
return redirect()->route('admin.quotes.show', $quote)
->with('success', "Quote {$quote->number} has been created.");
}
public function show(Quote $quote): Response
{
$quote->load(['user:id,name,email', 'creator:id,name,email']);
return Inertia::render('Admin/Quotes/Show', [
'quote' => $quote,
]);
}
public function edit(Quote $quote): Response
{
$quote->load(['user:id,name,email']);
$customers = User::query()
->select('id', 'name', 'email')
->orderBy('name')
->get();
return Inertia::render('Admin/Quotes/Edit', [
'quote' => $quote,
'customers' => $customers,
]);
}
public function update(StoreQuoteRequest $request, Quote $quote): RedirectResponse
{
if (in_array($quote->status, ['accepted', 'rejected'])) {
return redirect()->back()->with('error', 'Cannot edit a quote that has been accepted or rejected.');
}
$validated = $request->validated();
$items = $validated['items'];
$subtotal = collect($items)->sum(fn (array $item): float => (float) $item['unit_price'] * (int) $item['quantity']);
$tax = (float) ($validated['tax'] ?? 0);
$total = $subtotal + $tax;
$quote->update([
'user_id' => $validated['user_id'] ?? null,
'prospect_email' => $validated['prospect_email'] ?? null,
'prospect_name' => $validated['prospect_name'] ?? null,
'items' => $items,
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
'currency' => $validated['currency'],
'notes' => $validated['notes'] ?? null,
'valid_until' => $validated['valid_until'] ?? null,
]);
AuditLog::create([
'admin_id' => $request->user()?->id,
'action' => 'update_quote',
'resource_type' => 'quote',
'resource_id' => $quote->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['total' => $total],
]);
return redirect()->route('admin.quotes.show', $quote)
->with('success', "Quote {$quote->number} has been updated.");
}
public function destroy(Request $request, Quote $quote): RedirectResponse
{
if (in_array($quote->status, ['accepted'])) {
return redirect()->back()->with('error', 'Cannot delete an accepted quote.');
}
$number = $quote->number;
$quoteId = $quote->id;
$quote->delete();
AuditLog::create([
'admin_id' => $request->user()?->id,
'action' => 'delete_quote',
'resource_type' => 'quote',
'resource_id' => $quoteId,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['number' => $number],
]);
return redirect()->route('admin.quotes.index')
->with('success', "Quote {$number} has been deleted.");
}
public function send(Request $request, Quote $quote): RedirectResponse
{
$recipientEmail = $quote->getRecipientEmail();
if (! $recipientEmail) {
return redirect()->back()->with('error', 'No recipient email found for this quote.');
}
$signedUrl = \Illuminate\Support\Facades\URL::signedRoute('quotes.show', ['quote' => $quote->id]);
if ($quote->user) {
$quote->user->notify(new QuoteSentNotification($quote, $signedUrl));
} else {
Notification::route('mail', $recipientEmail)
->notify(new QuoteSentNotification($quote, $signedUrl));
}
$quote->markSent();
AuditLog::create([
'admin_id' => $request->user()?->id,
'action' => 'send_quote',
'resource_type' => 'quote',
'resource_id' => $quote->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['sent_to' => $recipientEmail],
]);
return redirect()->back()
->with('success', "Quote {$quote->number} has been sent to {$recipientEmail}.");
}
public function download(Quote $quote): \Symfony\Component\HttpFoundation\Response
{
$quote->load(['user', 'creator']);
$pdf = Pdf::loadView('pdf.quote', ['quote' => $quote]);
return $pdf->download("quote-{$quote->number}.pdf");
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreRoleRequest;
use App\Http\Requests\Admin\UpdateRoleRequest;
use App\Models\AuditLog;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RoleController extends Controller
{
/**
* Built-in roles that cannot be deleted or renamed.
*
* @var list<string>
*/
private const PROTECTED_ROLES = ['admin', 'customer', 'super_admin'];
public function index(): Response
{
$roles = Role::where('guard_name', 'web')
->withCount(['permissions', 'users'])
->orderBy('name')
->get()
->map(fn (Role $role): array => [
'id' => $role->id,
'name' => $role->name,
'permissions_count' => $role->permissions_count,
'users_count' => $role->users_count,
'is_protected' => in_array($role->name, self::PROTECTED_ROLES, true),
'created_at' => $role->created_at?->toISOString(),
]);
return Inertia::render('Admin/Staff/Roles/Index', [
'roles' => $roles,
]);
}
public function create(): Response
{
$permissions = $this->groupedPermissions();
return Inertia::render('Admin/Staff/Roles/Create', [
'permissionGroups' => $permissions,
]);
}
public function store(StoreRoleRequest $request): RedirectResponse
{
$role = Role::create([
'name' => $request->validated('name'),
'guard_name' => 'web',
]);
$role->syncPermissions($request->validated('permissions'));
AuditLog::create([
'admin_id' => $request->user()->id,
'action' => 'role_created',
'resource_type' => 'role',
'resource_id' => $role->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => [
'name' => $role->name,
'permissions' => $request->validated('permissions'),
],
]);
return redirect()
->route('admin.roles.index')
->with('success', "Role \"{$role->name}\" created successfully.");
}
public function edit(Role $role): Response
{
$permissions = $this->groupedPermissions();
$rolePermissions = $role->permissions->pluck('name')->toArray();
$usersCount = $role->users()->count();
return Inertia::render('Admin/Staff/Roles/Edit', [
'role' => [
'id' => $role->id,
'name' => $role->name,
'is_protected' => in_array($role->name, self::PROTECTED_ROLES, true),
'permissions' => $rolePermissions,
],
'permissionGroups' => $permissions,
'usersCount' => $usersCount,
]);
}
public function update(UpdateRoleRequest $request, Role $role): RedirectResponse
{
$oldPermissions = $role->permissions->pluck('name')->toArray();
// Only update name if not a protected role
if (! in_array($role->name, self::PROTECTED_ROLES, true) && $request->has('name')) {
$role->update(['name' => $request->validated('name')]);
}
$role->syncPermissions($request->validated('permissions'));
AuditLog::create([
'admin_id' => $request->user()->id,
'action' => 'role_updated',
'resource_type' => 'role',
'resource_id' => $role->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => [
'name' => $role->name,
'old_permissions' => $oldPermissions,
'new_permissions' => $request->validated('permissions'),
],
]);
return redirect()
->route('admin.roles.index')
->with('success', "Role \"{$role->name}\" updated successfully.");
}
public function destroy(Role $role): RedirectResponse
{
if (in_array($role->name, self::PROTECTED_ROLES, true)) {
return redirect()
->route('admin.roles.index')
->with('error', "Cannot delete the built-in \"{$role->name}\" role.");
}
// Check if any users are assigned to this role
if ($role->users()->count() > 0) {
return redirect()
->route('admin.roles.index')
->with('error', "Cannot delete \"{$role->name}\" — it still has assigned users.");
}
$roleName = $role->name;
AuditLog::create([
'admin_id' => auth()->id(),
'action' => 'role_deleted',
'resource_type' => 'role',
'resource_id' => $role->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'changes' => ['name' => $roleName],
]);
$role->delete();
return redirect()
->route('admin.roles.index')
->with('success', "Role \"{$roleName}\" deleted successfully.");
}
/**
* Get all permissions grouped by category.
*
* @return array<string, list<string>>
*/
private function groupedPermissions(): array
{
$permissions = Permission::where('guard_name', 'web')
->orderBy('name')
->pluck('name')
->toArray();
$grouped = [];
foreach ($permissions as $permission) {
if (str_contains($permission, '.')) {
$parts = explode('.', $permission, 2);
$grouped[$parts[0]][] = $permission;
} else {
$grouped['legacy'][] = $permission;
}
}
// Sort groups alphabetically
ksort($grouped);
return $grouped;
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\LoginHistory;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Role;
class StaffController extends Controller
{
public function index(Request $request): Response
{
$adminRoles = Role::where('guard_name', 'web')
->whereNotIn('name', ['customer'])
->pluck('name')
->toArray();
$query = User::role($adminRoles)
->with('roles');
// Search by name or email
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by role
if ($role = $request->input('role')) {
$query->role($role);
}
$staffMembers = $query
->orderBy('name')
->paginate(20)
->withQueryString();
// Attach last login info
$staffMembers->getCollection()->transform(function (User $user): User {
$lastLogin = LoginHistory::query()
->forUser($user->id)
->where('success', true)
->latest()
->first();
$user->setAttribute('last_login_at', $lastLogin?->created_at);
$user->setAttribute('last_login_ip', $lastLogin?->ip_address);
$user->setAttribute('role_names', $user->roles->pluck('name')->toArray());
return $user;
});
$roles = Role::where('guard_name', 'web')
->whereNotIn('name', ['customer'])
->withCount('users')
->get()
->map(fn (Role $role): array => [
'name' => $role->name,
'users_count' => $role->users_count,
]);
return Inertia::render('Admin/Staff/Index', [
'staff' => $staffMembers,
'roles' => $roles,
'filters' => [
'search' => $request->input('search', ''),
'role' => $request->input('role', ''),
],
]);
}
public function show(User $user): Response
{
$user->load(['roles.permissions']);
$allPermissions = $user->getAllPermissions()->pluck('name')->sort()->values();
$directPermissions = $user->getDirectPermissions()->pluck('name')->sort()->values();
$loginHistories = LoginHistory::query()
->forUser($user->id)
->latest()
->limit(20)
->get();
return Inertia::render('Admin/Staff/Show', [
'staffMember' => $user,
'allPermissions' => $allPermissions,
'directPermissions' => $directPermissions,
'loginHistories' => $loginHistories,
]);
}
}

View File

@@ -6,10 +6,15 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\CannedResponse;
use App\Models\Department;
use App\Models\SupportTicket; use App\Models\SupportTicket;
use App\Models\TicketReply; use App\Models\TicketReply;
use App\Models\TicketTag;
use App\Models\User;
use App\Notifications\TicketStaffReplyNotification; use App\Notifications\TicketStaffReplyNotification;
use App\Notifications\TicketStatusChangedNotification; use App\Notifications\TicketStatusChangedNotification;
use App\Services\Support\SlaService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -17,10 +22,14 @@ use Inertia\Response;
class TicketController extends Controller class TicketController extends Controller
{ {
public function __construct(
private readonly SlaService $slaService,
) {}
public function index(Request $request): Response public function index(Request $request): Response
{ {
$query = SupportTicket::query() $query = SupportTicket::query()
->with('user:id,name,email'); ->with(['user:id,name,email', 'assignee:id,name', 'tags', 'departmentRelation:id,name']);
if ($search = $request->input('search')) { if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void { $query->where(function ($q) use ($search): void {
@@ -44,17 +53,45 @@ class TicketController extends Controller
$query->where('department', $department); $query->where('department', $department);
} }
if ($assignedTo = $request->input('assigned_to')) {
$query->where('assigned_to', $assignedTo);
}
if ($tagIds = $request->input('tags')) {
$tagArray = is_array($tagIds) ? $tagIds : explode(',', $tagIds);
$query->whereHas('tags', function ($q) use ($tagArray): void {
$q->whereIn('ticket_tags.id', $tagArray);
});
}
if ($request->boolean('sla_breached')) {
$query->where(function ($q): void {
$q->where('sla_first_response_breached', true)
->orWhere('sla_resolution_breached', true);
});
}
$tickets = $query->latest('updated_at') $tickets = $query->latest('updated_at')
->paginate(25) ->paginate(25)
->withQueryString(); ->withQueryString();
$tags = TicketTag::query()->orderBy('name')->get();
$staffUsers = User::role('admin')->select('id', 'name')->orderBy('name')->get();
$departments = Department::query()->orderBy('sort_order')->get(['id', 'name', 'slug']);
return Inertia::render('Admin/Tickets/Index', [ return Inertia::render('Admin/Tickets/Index', [
'tickets' => $tickets, 'tickets' => $tickets,
'tags' => $tags,
'staffUsers' => $staffUsers,
'departments' => $departments,
'filters' => [ 'filters' => [
'search' => $request->input('search', ''), 'search' => $request->input('search', ''),
'status' => $request->input('status', ''), 'status' => $request->input('status', ''),
'priority' => $request->input('priority', ''), 'priority' => $request->input('priority', ''),
'department' => $request->input('department', ''), 'department' => $request->input('department', ''),
'assigned_to' => $request->input('assigned_to', ''),
'tags' => $request->input('tags', ''),
'sla_breached' => $request->boolean('sla_breached'),
], ],
]); ]);
} }
@@ -64,10 +101,30 @@ class TicketController extends Controller
$ticket->load([ $ticket->load([
'replies.user:id,name,email', 'replies.user:id,name,email',
'user:id,name,email,status,company', 'user:id,name,email,status,company',
'assignee:id,name,email',
'tags',
'departmentRelation:id,name,slug',
'slaPolicy',
'satisfactionRating',
]); ]);
$cannedResponses = CannedResponse::query()
->where('is_shared', true)
->orderBy('title')
->get(['id', 'title', 'content', 'category']);
$tags = TicketTag::query()->orderBy('name')->get();
$staffUsers = User::role('admin')->select('id', 'name', 'email')->orderBy('name')->get();
$departments = Department::query()->orderBy('sort_order')->get(['id', 'name', 'slug']);
return Inertia::render('Admin/Tickets/Show', [ return Inertia::render('Admin/Tickets/Show', [
'ticket' => $ticket, 'ticket' => $ticket,
'cannedResponses' => $cannedResponses,
'tags' => $tags,
'staffUsers' => $staffUsers,
'departments' => $departments,
]); ]);
} }
@@ -76,37 +133,66 @@ class TicketController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'body' => ['required', 'string', 'max:5000'], 'body' => ['required', 'string', 'max:5000'],
'status' => ['nullable', 'in:open,in_progress,waiting,closed'], 'status' => ['nullable', 'in:open,in_progress,waiting,closed'],
'is_internal' => ['boolean'],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:ticket_tags,id'],
]); ]);
$isInternal = $validated['is_internal'] ?? false;
$reply = TicketReply::query()->create([ $reply = TicketReply::query()->create([
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
'body' => $validated['body'], 'body' => $validated['body'],
'is_staff_reply' => true, 'is_staff_reply' => true,
'is_internal' => $isInternal,
]); ]);
$updateData = ['last_reply_at' => now()]; $updateData = ['last_reply_at' => now()];
if (! empty($validated['status'])) { if (! empty($validated['status'])) {
$updateData['status'] = $validated['status']; $updateData['status'] = $validated['status'];
if ($validated['status'] === 'closed') {
$updateData['resolved_at'] = now();
}
} }
$ticket->update($updateData); $ticket->update($updateData);
// Notify the customer about the staff reply // Record first response for SLA tracking (only for non-internal replies)
if (! $isInternal) {
$this->slaService->recordFirstResponse($ticket);
}
// Sync tags if provided
if (isset($validated['tags'])) {
$ticket->tags()->sync($validated['tags']);
}
// Only notify customer for non-internal replies
if (! $isInternal) {
$ticket->user?->notify(new TicketStaffReplyNotification($ticket, $reply)); $ticket->user?->notify(new TicketStaffReplyNotification($ticket, $reply));
}
// Track canned response usage if applicable
if ($cannedResponseId = $request->input('canned_response_id')) {
CannedResponse::query()
->where('id', $cannedResponseId)
->increment('usage_count');
}
AuditLog::query()->create([ AuditLog::query()->create([
'user_id' => $ticket->user_id, 'user_id' => $ticket->user_id,
'admin_id' => $request->user()->id, 'admin_id' => $request->user()->id,
'action' => 'reply_ticket', 'action' => $isInternal ? 'internal_note_ticket' : 'reply_ticket',
'resource_type' => 'support_ticket', 'resource_type' => 'support_ticket',
'resource_id' => $ticket->id, 'resource_id' => $ticket->id,
'ip_address' => $request->ip(), 'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(), 'user_agent' => $request->userAgent(),
]); ]);
return redirect()->back()->with('success', 'Reply sent successfully.'); return redirect()->back()->with('success', $isInternal ? 'Internal note added.' : 'Reply sent successfully.');
} }
public function updateStatus(Request $request, SupportTicket $ticket): RedirectResponse public function updateStatus(Request $request, SupportTicket $ticket): RedirectResponse
@@ -117,7 +203,13 @@ class TicketController extends Controller
$oldStatus = $ticket->status; $oldStatus = $ticket->status;
$ticket->update(['status' => $validated['status']]); $updateData = ['status' => $validated['status']];
if ($validated['status'] === 'closed') {
$updateData['resolved_at'] = now();
}
$ticket->update($updateData);
// Notify the customer about the status change // Notify the customer about the status change
$ticket->user?->notify(new TicketStatusChangedNotification($ticket, $oldStatus, $validated['status'])); $ticket->user?->notify(new TicketStatusChangedNotification($ticket, $oldStatus, $validated['status']));
@@ -135,4 +227,91 @@ class TicketController extends Controller
return redirect()->back()->with('success', 'Ticket status updated.'); return redirect()->back()->with('success', 'Ticket status updated.');
} }
public function assign(Request $request, SupportTicket $ticket): RedirectResponse
{
$validated = $request->validate([
'assigned_to' => ['nullable', 'exists:users,id'],
]);
$ticket->update([
'assigned_to' => $validated['assigned_to'],
]);
AuditLog::query()->create([
'user_id' => $ticket->user_id,
'admin_id' => $request->user()->id,
'action' => 'assign_ticket',
'resource_type' => 'support_ticket',
'resource_id' => $ticket->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'details' => json_encode(['assigned_to' => $validated['assigned_to']]),
]);
return redirect()->back()->with('success', 'Ticket assigned successfully.');
}
public function merge(Request $request, SupportTicket $ticket): RedirectResponse
{
$validated = $request->validate([
'target_ticket_id' => ['required', 'exists:support_tickets,id', 'different:ticket.id'],
]);
$targetTicket = SupportTicket::query()->findOrFail($validated['target_ticket_id']);
// Move all replies from source to target
$ticket->replies()->update(['ticket_id' => $targetTicket->id]);
// Move tags
$sourceTags = $ticket->tags()->pluck('ticket_tags.id')->toArray();
$existingTargetTags = $targetTicket->tags()->pluck('ticket_tags.id')->toArray();
$newTags = array_diff($sourceTags, $existingTargetTags);
if (! empty($newTags)) {
$targetTicket->tags()->attach($newTags);
}
// Mark source ticket as merged and closed
$ticket->update([
'merged_into_ticket_id' => $targetTicket->id,
'status' => 'closed',
'resolved_at' => now(),
]);
// Add a system note on the target ticket
TicketReply::query()->create([
'ticket_id' => $targetTicket->id,
'user_id' => $request->user()->id,
'body' => "Ticket #{$ticket->id} ({$ticket->ticket_reference}) was merged into this ticket.",
'is_staff_reply' => true,
'is_internal' => true,
]);
AuditLog::query()->create([
'user_id' => $ticket->user_id,
'admin_id' => $request->user()->id,
'action' => 'merge_ticket',
'resource_type' => 'support_ticket',
'resource_id' => $ticket->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'details' => json_encode(['merged_into' => $targetTicket->id]),
]);
return redirect()->route('admin.tickets.show', $targetTicket)
->with('success', "Ticket #{$ticket->id} merged into #{$targetTicket->id}.");
}
public function updateTags(Request $request, SupportTicket $ticket): RedirectResponse
{
$validated = $request->validate([
'tags' => ['array'],
'tags.*' => ['integer', 'exists:ticket_tags,id'],
]);
$ticket->tags()->sync($validated['tags'] ?? []);
return redirect()->back()->with('success', 'Tags updated successfully.');
}
} }

View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\SlaBusinessHours;
use App\Models\SlaPolicy;
use App\Models\TicketCustomField;
use App\Models\TicketTag;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class TicketSettingsController extends Controller
{
public function index(): Response
{
$departments = Department::query()
->with(['slaPolicy', 'autoAssignee:id,name,email'])
->orderBy('sort_order')
->get();
$slaPolicies = SlaPolicy::query()->orderBy('name')->get();
$businessHours = SlaBusinessHours::query()
->orderBy('day_of_week')
->get();
$tags = TicketTag::query()->orderBy('name')->get();
$customFields = TicketCustomField::query()
->with('department:id,name')
->orderBy('sort_order')
->get();
$staffUsers = User::role('admin')->select('id', 'name', 'email')->orderBy('name')->get();
return Inertia::render('Admin/Tickets/Settings', [
'departments' => $departments,
'slaPolicies' => $slaPolicies,
'businessHours' => $businessHours,
'tags' => $tags,
'customFields' => $customFields,
'staffUsers' => $staffUsers,
]);
}
// --- Departments ---
public function storeDepartment(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'unique:departments,slug'],
'email' => ['nullable', 'email', 'max:255'],
'auto_assign_to' => ['nullable', 'exists:users,id'],
'sla_policy_id' => ['nullable', 'exists:sla_policies,id'],
'sort_order' => ['integer'],
]);
$validated['slug'] = Str::slug($validated['slug']);
Department::query()->create($validated);
return redirect()->back()->with('success', 'Department created successfully.');
}
public function updateDepartment(Request $request, Department $department): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', Rule::unique('departments', 'slug')->ignore($department->id)],
'email' => ['nullable', 'email', 'max:255'],
'auto_assign_to' => ['nullable', 'exists:users,id'],
'sla_policy_id' => ['nullable', 'exists:sla_policies,id'],
'sort_order' => ['integer'],
]);
$validated['slug'] = Str::slug($validated['slug']);
$department->update($validated);
return redirect()->back()->with('success', 'Department updated successfully.');
}
public function destroyDepartment(Department $department): RedirectResponse
{
if ($department->tickets()->exists()) {
return redirect()->back()->with('error', 'Cannot delete department with existing tickets.');
}
$department->delete();
return redirect()->back()->with('success', 'Department deleted successfully.');
}
// --- SLA Policies ---
public function storeSlaPolicy(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'priority' => ['required', 'string', 'in:low,medium,high,urgent'],
'first_response_hours' => ['required', 'integer', 'min:1'],
'resolution_hours' => ['required', 'integer', 'min:1'],
'business_hours_only' => ['boolean'],
]);
SlaPolicy::query()->create($validated);
return redirect()->back()->with('success', 'SLA policy created successfully.');
}
public function updateSlaPolicy(Request $request, SlaPolicy $slaPolicy): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'priority' => ['required', 'string', 'in:low,medium,high,urgent'],
'first_response_hours' => ['required', 'integer', 'min:1'],
'resolution_hours' => ['required', 'integer', 'min:1'],
'business_hours_only' => ['boolean'],
]);
$slaPolicy->update($validated);
return redirect()->back()->with('success', 'SLA policy updated successfully.');
}
public function destroySlaPolicy(SlaPolicy $slaPolicy): RedirectResponse
{
if ($slaPolicy->departments()->exists()) {
return redirect()->back()->with('error', 'Cannot delete SLA policy assigned to departments.');
}
$slaPolicy->delete();
return redirect()->back()->with('success', 'SLA policy deleted successfully.');
}
// --- Business Hours ---
public function updateBusinessHours(Request $request): RedirectResponse
{
$validated = $request->validate([
'hours' => ['required', 'array'],
'hours.*.day_of_week' => ['required', 'integer', 'between:0,6'],
'hours.*.start_time' => ['required', 'date_format:H:i'],
'hours.*.end_time' => ['required', 'date_format:H:i', 'after:hours.*.start_time'],
'hours.*.is_holiday' => ['boolean'],
]);
// Replace all business hours with the new set
SlaBusinessHours::query()->delete();
foreach ($validated['hours'] as $hour) {
SlaBusinessHours::query()->create($hour);
}
return redirect()->back()->with('success', 'Business hours updated successfully.');
}
// --- Tags ---
public function storeTag(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'color' => ['required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]);
TicketTag::query()->create($validated);
return redirect()->back()->with('success', 'Tag created successfully.');
}
public function updateTag(Request $request, TicketTag $ticketTag): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'color' => ['required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]);
$ticketTag->update($validated);
return redirect()->back()->with('success', 'Tag updated successfully.');
}
public function destroyTag(TicketTag $ticketTag): RedirectResponse
{
$ticketTag->tickets()->detach();
$ticketTag->delete();
return redirect()->back()->with('success', 'Tag deleted successfully.');
}
// --- Custom Fields ---
public function storeCustomField(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'in:text,select,checkbox,number'],
'options' => ['nullable', 'array'],
'options.*' => ['string', 'max:255'],
'is_required' => ['boolean'],
'department_id' => ['nullable', 'exists:departments,id'],
'sort_order' => ['integer'],
]);
TicketCustomField::query()->create($validated);
return redirect()->back()->with('success', 'Custom field created successfully.');
}
public function updateCustomField(Request $request, TicketCustomField $ticketCustomField): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'in:text,select,checkbox,number'],
'options' => ['nullable', 'array'],
'options.*' => ['string', 'max:255'],
'is_required' => ['boolean'],
'department_id' => ['nullable', 'exists:departments,id'],
'sort_order' => ['integer'],
]);
$ticketCustomField->update($validated);
return redirect()->back()->with('success', 'Custom field updated successfully.');
}
public function destroyCustomField(TicketCustomField $ticketCustomField): RedirectResponse
{
$ticketCustomField->values()->delete();
$ticketCustomField->delete();
return redirect()->back()->with('success', 'Custom field deleted successfully.');
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\ServerHunterService;
use Illuminate\Http\JsonResponse;
class ServerHunterController extends Controller
{
public function __construct(
private ServerHunterService $serverHunterService,
) {}
public function feed(): JsonResponse
{
return response()->json($this->serverHunterService->buildFeed());
}
}

View File

@@ -24,22 +24,33 @@ class AdminAnalyticsController extends Controller
{ {
$totalCustomers = User::role('customer')->count(); $totalCustomers = User::role('customer')->count();
// MRR: sum of plan prices normalized to monthly // MRR: prefer recurring_amount, fall back to plan_prices
$mrr = (float) Subscription::query() $mrr = (float) (Subscription::query()
->where('subscriptions.stripe_status', 'active') ->where('subscriptions.stripe_status', 'active')
->whereNotNull('subscriptions.plan_id') ->whereNotNull('subscriptions.plan_id')
->join('plan_prices', function ($join): void { ->leftJoin('plan_prices', function ($join): void {
$join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id') $join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id')
->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle'); ->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle');
}) })
->selectRaw('SUM(CASE subscriptions.billing_cycle ->selectRaw('SUM(CASE
WHEN subscriptions.recurring_amount IS NOT NULL THEN
CASE subscriptions.billing_cycle
WHEN "monthly" THEN subscriptions.recurring_amount
WHEN "quarterly" THEN subscriptions.recurring_amount / 3
WHEN "semi_annual" THEN subscriptions.recurring_amount / 6
WHEN "annual" THEN subscriptions.recurring_amount / 12
ELSE subscriptions.recurring_amount
END
ELSE
CASE subscriptions.billing_cycle
WHEN "monthly" THEN plan_prices.price WHEN "monthly" THEN plan_prices.price
WHEN "quarterly" THEN plan_prices.price / 3 WHEN "quarterly" THEN plan_prices.price / 3
WHEN "semi_annual" THEN plan_prices.price / 6 WHEN "semi_annual" THEN plan_prices.price / 6
WHEN "annual" THEN plan_prices.price / 12 WHEN "annual" THEN plan_prices.price / 12
ELSE plan_prices.price ELSE plan_prices.price
END
END) as mrr') END) as mrr')
->value('mrr') ?? 0; ->value('mrr') ?? 0);
// ARR (Annual Recurring Revenue) // ARR (Annual Recurring Revenue)
$arr = $mrr * 12; $arr = $mrr * 12;

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Marketing;
use App\Http\Controllers\Controller;
use App\Models\ArticleVote;
use App\Models\KnowledgeBaseArticle;
use App\Models\KnowledgeBaseCategory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class KnowledgeBaseController extends Controller
{
public function index(): Response
{
$categories = KnowledgeBaseCategory::query()
->visible()
->root()
->withCount(['articles' => fn ($q) => $q->published()])
->with(['children' => fn ($q) => $q->visible()->withCount(['articles' => fn ($q2) => $q2->published()])])
->orderBy('sort_order')
->orderBy('name')
->get();
$featuredArticles = KnowledgeBaseArticle::query()
->published()
->featured()
->with('category:id,name,slug')
->orderByDesc('published_at')
->limit(6)
->get();
return Inertia::render('Marketing/KnowledgeBase/Index', [
'categories' => $categories,
'featuredArticles' => $featuredArticles,
]);
}
public function category(KnowledgeBaseCategory $category): Response
{
$category->loadCount(['articles' => fn ($q) => $q->published()]);
$articles = KnowledgeBaseArticle::query()
->where('category_id', $category->id)
->published()
->orderByDesc('published_at')
->paginate(15);
$subcategories = $category->children()
->visible()
->withCount(['articles' => fn ($q) => $q->published()])
->orderBy('sort_order')
->get();
$breadcrumbs = $category->getAncestors()->map(fn (KnowledgeBaseCategory $ancestor) => [
'name' => $ancestor->name,
'slug' => $ancestor->slug,
])->push([
'name' => $category->name,
'slug' => $category->slug,
])->all();
return Inertia::render('Marketing/KnowledgeBase/Category', [
'category' => $category,
'articles' => $articles,
'subcategories' => $subcategories,
'breadcrumbs' => $breadcrumbs,
]);
}
public function article(KnowledgeBaseCategory $category, KnowledgeBaseArticle $article): Response
{
if ($article->status !== 'published') {
abort(404);
}
$article->increment('view_count');
$article->load(['author:id,name', 'category:id,name,slug']);
$breadcrumbs = $category->getAncestors()->map(fn (KnowledgeBaseCategory $ancestor) => [
'name' => $ancestor->name,
'slug' => $ancestor->slug,
])->push([
'name' => $category->name,
'slug' => $category->slug,
])->all();
$relatedArticles = KnowledgeBaseArticle::query()
->where('category_id', $category->id)
->where('id', '!=', $article->id)
->published()
->orderByDesc('view_count')
->limit(5)
->get(['id', 'title', 'slug', 'excerpt', 'view_count', 'published_at']);
$htmlContent = str($article->content)->markdown()->toString();
return Inertia::render('Marketing/KnowledgeBase/Article', [
'article' => $article,
'htmlContent' => $htmlContent,
'breadcrumbs' => $breadcrumbs,
'relatedArticles' => $relatedArticles,
]);
}
public function search(Request $request): JsonResponse
{
$request->validate([
'q' => ['required', 'string', 'min:2', 'max:100'],
]);
$query = $request->input('q');
$articles = KnowledgeBaseArticle::query()
->published()
->search($query)
->with('category:id,name,slug')
->limit(20)
->get(['id', 'title', 'slug', 'excerpt', 'category_id', 'view_count', 'published_at']);
return response()->json([
'articles' => $articles,
]);
}
public function vote(Request $request, KnowledgeBaseArticle $article): JsonResponse
{
$request->validate([
'is_helpful' => ['required', 'boolean'],
]);
$isHelpful = $request->boolean('is_helpful');
$userId = $request->user()?->id;
$ipAddress = $request->ip();
$existingVote = ArticleVote::query()
->where('article_id', $article->id)
->when($userId, fn ($q) => $q->where('user_id', $userId))
->when(! $userId, fn ($q) => $q->whereNull('user_id')->where('ip_address', $ipAddress))
->first();
if ($existingVote) {
if ($existingVote->is_helpful === $isHelpful) {
return response()->json([
'message' => 'You have already voted.',
'helpful_count' => $article->helpful_count,
'not_helpful_count' => $article->not_helpful_count,
]);
}
$existingVote->update(['is_helpful' => $isHelpful]);
if ($isHelpful) {
$article->increment('helpful_count');
$article->decrement('not_helpful_count');
} else {
$article->decrement('helpful_count');
$article->increment('not_helpful_count');
}
} else {
ArticleVote::query()->create([
'article_id' => $article->id,
'user_id' => $userId,
'is_helpful' => $isHelpful,
'ip_address' => $ipAddress,
]);
if ($isHelpful) {
$article->increment('helpful_count');
} else {
$article->increment('not_helpful_count');
}
}
$article->refresh();
return response()->json([
'message' => 'Thank you for your feedback!',
'helpful_count' => $article->helpful_count,
'not_helpful_count' => $article->not_helpful_count,
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Quote;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class QuoteAcceptController extends Controller
{
public function show(Quote $quote): Response
{
$quote->load(['user:id,name,email', 'creator:id,name,email']);
return Inertia::render('Quotes/Show', [
'quote' => $quote,
'isExpired' => $quote->isExpired(),
]);
}
public function accept(Quote $quote): RedirectResponse
{
if ($quote->isExpired()) {
return redirect()->back()->with('error', 'This quote has expired.');
}
if ($quote->status !== 'sent') {
return redirect()->back()->with('error', 'This quote cannot be accepted.');
}
$quote->markAccepted();
return redirect()->back()->with('success', 'Quote accepted! We will be in touch shortly.');
}
public function reject(Quote $quote): RedirectResponse
{
if (! in_array($quote->status, ['sent', 'draft'])) {
return redirect()->back()->with('error', 'This quote cannot be rejected.');
}
$quote->update(['status' => 'rejected']);
return redirect()->back()->with('success', 'Quote has been declined.');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\ServerHunterService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AllowServerHunterSpider
{
public function __construct(
private ServerHunterService $serverHunterService,
) {}
public function handle(Request $request, Closure $next): Response
{
$allowedIps = $this->serverHunterService->getSpiderIps();
if (empty($allowedIps)) {
// If we couldn't fetch the IP list, deny all to be safe
abort(403, 'ServerHunter spider IP list unavailable.');
}
if (! in_array($request->ip(), $allowedIps, true)) {
abort(403, 'Access denied.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Affiliate;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TrackAffiliateReferral
{
public function handle(Request $request, Closure $next): Response
{
$refCode = $request->query('ref');
if (is_string($refCode) && $refCode !== '') {
$affiliate = Affiliate::where('referral_code', $refCode)
->where('status', 'active')
->first();
if ($affiliate) {
$response = $next($request);
$cookieLifetime = (int) config('affiliate.cookie_lifetime_days', 30) * 60 * 24;
if ($response instanceof \Illuminate\Http\Response || $response instanceof \Illuminate\Http\RedirectResponse) {
$response->withCookie(cookie(
'affiliate_id',
(string) $affiliate->id,
$cookieLifetime,
));
}
return $response;
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class StoreCreditNoteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'user_id' => ['required', 'exists:users,id'],
'amount' => ['required', 'numeric', 'min:0.01'],
'reason' => ['nullable', 'string', 'max:2000'],
'invoice_id' => ['nullable', 'exists:invoices,id'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'user_id.required' => 'Please select a customer.',
'user_id.exists' => 'The selected customer does not exist.',
'amount.required' => 'Amount is required.',
'amount.min' => 'Amount must be at least $0.01.',
'invoice_id.exists' => 'The selected invoice does not exist.',
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreDebitNoteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'user_id' => ['required', 'exists:users,id'],
'amount' => ['required', 'numeric', 'min:0.01'],
'reason_type' => ['required', Rule::in(['late_fee', 'adjustment', 'underpayment'])],
'reason' => ['nullable', 'string', 'max:2000'],
'invoice_id' => ['nullable', 'exists:invoices,id'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'user_id.required' => 'Please select a customer.',
'user_id.exists' => 'The selected customer does not exist.',
'amount.required' => 'Amount is required.',
'amount.min' => 'Amount must be at least $0.01.',
'reason_type.required' => 'Please select a reason type.',
'reason_type.in' => 'Invalid reason type selected.',
'invoice_id.exists' => 'The selected invoice does not exist.',
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreKbArticleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', Rule::unique('knowledge_base_articles', 'slug')],
'category_id' => ['required', 'integer', Rule::exists('knowledge_base_categories', 'id')],
'content' => ['required', 'string'],
'excerpt' => ['nullable', 'string', 'max:1000'],
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['boolean'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'title.required' => 'Article title is required.',
'slug.required' => 'Article slug is required.',
'slug.unique' => 'This slug is already in use.',
'category_id.required' => 'Please select a category.',
'category_id.exists' => 'The selected category does not exist.',
'content.required' => 'Article content is required.',
'status.in' => 'Status must be draft, published, or archived.',
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreKbCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', Rule::unique('knowledge_base_categories', 'slug')],
'description' => ['nullable', 'string', 'max:5000'],
'icon' => ['nullable', 'string', 'max:255'],
'parent_id' => ['nullable', 'integer', Rule::exists('knowledge_base_categories', 'id')],
'sort_order' => ['integer', 'min:0'],
'is_visible' => ['boolean'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'name.required' => 'Category name is required.',
'slug.required' => 'Category slug is required.',
'slug.unique' => 'This slug is already in use.',
'parent_id.exists' => 'The selected parent category does not exist.',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class StoreQuoteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'user_id' => ['nullable', 'exists:users,id'],
'prospect_email' => ['nullable', 'email', 'max:255', 'required_without:user_id'],
'prospect_name' => ['nullable', 'string', 'max:255'],
'items' => ['required', 'array', 'min:1'],
'items.*.description' => ['required', 'string', 'max:500'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
'tax' => ['nullable', 'numeric', 'min:0'],
'currency' => ['required', 'string', 'size:3'],
'notes' => ['nullable', 'string', 'max:5000'],
'valid_until' => ['nullable', 'date', 'after:today'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'prospect_email.required_without' => 'Please select a customer or enter a prospect email.',
'items.required' => 'At least one line item is required.',
'items.min' => 'At least one line item is required.',
'items.*.description.required' => 'Each item must have a description.',
'items.*.quantity.required' => 'Each item must have a quantity.',
'items.*.unit_price.required' => 'Each item must have a unit price.',
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Permission;
class StoreRoleRequest extends FormRequest
{
public function authorize(): bool
{
return true; // admin middleware handles auth
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
$validPermissions = Permission::where('guard_name', 'web')
->pluck('name')
->toArray();
return [
'name' => [
'required',
'string',
'max:255',
'regex:/^[a-z][a-z0-9_]*$/',
Rule::unique('roles', 'name')->where('guard_name', 'web'),
],
'permissions' => ['required', 'array', 'min:1'],
'permissions.*' => ['required', 'string', Rule::in($validPermissions)],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'name.regex' => 'Role name must be lowercase, start with a letter, and contain only letters, numbers, and underscores.',
'permissions.required' => 'At least one permission must be selected.',
'permissions.min' => 'At least one permission must be selected.',
];
}
}

View File

@@ -24,6 +24,8 @@ class UpdateCustomerRequest extends FormRequest
'company' => ['nullable', 'string', 'max:255'], 'company' => ['nullable', 'string', 'max:255'],
'status' => ['required', 'in:active,suspended,banned'], 'status' => ['required', 'in:active,suspended,banned'],
'admin_notes' => ['nullable', 'string', 'max:10000'], 'admin_notes' => ['nullable', 'string', 'max:10000'],
'override_days_to_suspend' => ['nullable', 'integer', 'min:1'],
'override_days_to_terminate' => ['nullable', 'integer', 'min:1'],
]; ];
} }
} }

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateKbArticleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
$articleId = $this->route('article')?->id;
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', Rule::unique('knowledge_base_articles', 'slug')->ignore($articleId)],
'category_id' => ['required', 'integer', Rule::exists('knowledge_base_categories', 'id')],
'content' => ['required', 'string'],
'excerpt' => ['nullable', 'string', 'max:1000'],
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['boolean'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'title.required' => 'Article title is required.',
'slug.required' => 'Article slug is required.',
'slug.unique' => 'This slug is already in use.',
'category_id.required' => 'Please select a category.',
'category_id.exists' => 'The selected category does not exist.',
'content.required' => 'Article content is required.',
'status.in' => 'Status must be draft, published, or archived.',
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateKbCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
$categoryId = $this->route('category')?->id;
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', Rule::unique('knowledge_base_categories', 'slug')->ignore($categoryId)],
'description' => ['nullable', 'string', 'max:5000'],
'icon' => ['nullable', 'string', 'max:255'],
'parent_id' => ['nullable', 'integer', Rule::exists('knowledge_base_categories', 'id')],
'sort_order' => ['integer', 'min:0'],
'is_visible' => ['boolean'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'name.required' => 'Category name is required.',
'slug.required' => 'Category slug is required.',
'slug.unique' => 'This slug is already in use.',
'parent_id.exists' => 'The selected parent category does not exist.',
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Permission;
class UpdateRoleRequest extends FormRequest
{
public function authorize(): bool
{
return true; // admin middleware handles auth
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
$validPermissions = Permission::where('guard_name', 'web')
->pluck('name')
->toArray();
return [
'name' => [
'sometimes',
'string',
'max:255',
'regex:/^[a-z][a-z0-9_]*$/',
Rule::unique('roles', 'name')
->where('guard_name', 'web')
->ignore($this->route('role')),
],
'permissions' => ['required', 'array', 'min:1'],
'permissions.*' => ['required', 'string', Rule::in($validPermissions)],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'name.regex' => 'Role name must be lowercase, start with a letter, and contain only letters, numbers, and underscores.',
'permissions.required' => 'At least one permission must be selected.',
'permissions.min' => 'At least one permission must be selected.',
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RequestAffiliatePayoutRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'method' => ['required', Rule::in(['credit', 'paypal', 'bank_transfer'])],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'method.required' => 'Payout method is required.',
'method.in' => 'Payout method must be credit, paypal, or bank_transfer.',
];
}
}

View File

@@ -38,6 +38,10 @@ class StorePlanRequest extends FormRequest
'provisioning_config.hypervisor_id' => ['nullable', 'integer'], 'provisioning_config.hypervisor_id' => ['nullable', 'integer'],
'stock_quantity' => ['nullable', 'integer', 'min:0'], 'stock_quantity' => ['nullable', 'integer', 'min:0'],
'sort_order' => ['integer', 'min:0'], 'sort_order' => ['integer', 'min:0'],
'days_to_suspend' => ['nullable', 'integer', 'min:1'],
'days_to_terminate' => ['nullable', 'integer', 'min:1'],
'auto_suspend_enabled' => ['boolean'],
'auto_terminate_enabled' => ['boolean'],
]; ];
} }

View File

@@ -0,0 +1,48 @@
<?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 Illuminate\Database\Eloquent\Relations\MorphTo;
class AccountCredit extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'amount',
'currency',
'type',
'description',
'reference_type',
'reference_id',
'created_by',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function referenceable(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'reference_type', 'reference_id');
}
}

View File

@@ -0,0 +1,79 @@
<?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 Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class Affiliate extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'referral_code',
'status',
'commission_type',
'commission_rate',
'recurring_commissions',
'minimum_payout',
'total_earned',
'total_paid',
'pending_balance',
'approved_at',
];
protected function casts(): array
{
return [
'commission_rate' => 'decimal:2',
'minimum_payout' => 'decimal:2',
'total_earned' => 'decimal:2',
'total_paid' => 'decimal:2',
'pending_balance' => 'decimal:2',
'recurring_commissions' => 'boolean',
'approved_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function referrals(): HasMany
{
return $this->hasMany(AffiliateReferral::class);
}
public function commissions(): HasMany
{
return $this->hasMany(AffiliateCommission::class);
}
public function payouts(): HasMany
{
return $this->hasMany(AffiliatePayout::class);
}
public static function generateReferralCode(): string
{
do {
$code = Str::lower(Str::random(8));
} while (self::where('referral_code', $code)->exists());
return $code;
}
public function referralUrl(): string
{
$marketingDomain = config('app.domains.marketing');
return 'https://'.$marketingDomain.'?ref='.$this->referral_code;
}
}

View File

@@ -0,0 +1,50 @@
<?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 AffiliateCommission extends Model
{
use HasFactory;
protected $fillable = [
'affiliate_id',
'referral_id',
'payment_transaction_id',
'amount',
'currency',
'type',
'status',
'approved_at',
'paid_at',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'approved_at' => 'datetime',
'paid_at' => 'datetime',
];
}
public function affiliate(): BelongsTo
{
return $this->belongsTo(Affiliate::class);
}
public function referral(): BelongsTo
{
return $this->belongsTo(AffiliateReferral::class, 'referral_id');
}
public function paymentTransaction(): BelongsTo
{
return $this->belongsTo(PaymentTransaction::class);
}
}

View File

@@ -0,0 +1,37 @@
<?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 AffiliatePayout extends Model
{
use HasFactory;
protected $fillable = [
'affiliate_id',
'amount',
'currency',
'method',
'status',
'reference',
'processed_at',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'processed_at' => 'datetime',
];
}
public function affiliate(): BelongsTo
{
return $this->belongsTo(Affiliate::class);
}
}

View File

@@ -0,0 +1,47 @@
<?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 AffiliateReferral extends Model
{
use HasFactory;
protected $fillable = [
'affiliate_id',
'referred_user_id',
'subscription_id',
'status',
'signup_ip',
'referral_url',
'approved_at',
];
protected function casts(): array
{
return [
'approved_at' => 'datetime',
];
}
public function affiliate(): BelongsTo
{
return $this->belongsTo(Affiliate::class);
}
public function referredUser(): BelongsTo
{
return $this->belongsTo(User::class, 'referred_user_id');
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArticleRevision extends Model
{
protected $fillable = [
'article_id',
'content',
'edited_by',
];
public function article(): BelongsTo
{
return $this->belongsTo(KnowledgeBaseArticle::class, 'article_id');
}
public function editor(): BelongsTo
{
return $this->belongsTo(User::class, 'edited_by');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArticleVote extends Model
{
protected $fillable = [
'article_id',
'user_id',
'is_helpful',
'ip_address',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'is_helpful' => 'boolean',
];
}
public function article(): BelongsTo
{
return $this->belongsTo(KnowledgeBaseArticle::class, 'article_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,36 @@
<?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 CannedResponse extends Model
{
use HasFactory;
protected $fillable = [
'title',
'content',
'category',
'is_shared',
'created_by',
'usage_count',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'is_shared' => 'boolean',
];
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@@ -0,0 +1,44 @@
<?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 CartItem extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'session_id',
'plan_id',
'billing_cycle',
'quantity',
'config_selections',
'provisioning_config',
'coupon_code',
];
protected function casts(): array
{
return [
'quantity' => 'integer',
'config_selections' => 'array',
'provisioning_config' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
}

View File

@@ -0,0 +1,61 @@
<?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 Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
class CreditNote extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'invoice_id',
'number',
'amount',
'currency',
'reason',
'status',
'issued_at',
'created_by',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'issued_at' => 'datetime',
];
}
public static function generateNumber(): string
{
return 'CN-'.strtoupper(Str::random(6));
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function accountCredits(): MorphMany
{
return $this->morphMany(AccountCredit::class, 'referenceable', 'reference_type', 'reference_id');
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Currency extends Model
{
use HasFactory;
protected $fillable = [
'code',
'symbol',
'name',
'decimal_places',
'exchange_rate',
'is_base',
'is_enabled',
'last_synced_at',
];
protected function casts(): array
{
return [
'decimal_places' => 'integer',
'exchange_rate' => 'decimal:6',
'is_base' => 'boolean',
'is_enabled' => 'boolean',
'last_synced_at' => 'datetime',
];
}
public function scopeEnabled(Builder $query): Builder
{
return $query->where('is_enabled', true);
}
public function scopeBase(Builder $query): Builder
{
return $query->where('is_base', true);
}
}

View File

@@ -0,0 +1,56 @@
<?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 Illuminate\Support\Str;
class DebitNote extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'invoice_id',
'number',
'amount',
'currency',
'reason_type',
'reason',
'status',
'issued_at',
'created_by',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'issued_at' => 'datetime',
];
}
public static function generateNumber(): string
{
return 'DN-'.strtoupper(Str::random(6));
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@@ -0,0 +1,39 @@
<?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 Illuminate\Database\Eloquent\Relations\HasMany;
class Department extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'email',
'auto_assign_to',
'sla_policy_id',
'sort_order',
];
public function tickets(): HasMany
{
return $this->hasMany(SupportTicket::class);
}
public function slaPolicy(): BelongsTo
{
return $this->belongsTo(SlaPolicy::class);
}
public function autoAssignee(): BelongsTo
{
return $this->belongsTo(User::class, 'auto_assign_to');
}
}

View File

@@ -0,0 +1,93 @@
<?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 KnowledgeBaseArticle extends Model
{
use HasFactory;
protected $fillable = [
'category_id',
'author_id',
'title',
'slug',
'content',
'excerpt',
'status',
'is_featured',
'view_count',
'helpful_count',
'not_helpful_count',
'published_at',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'is_featured' => 'boolean',
'view_count' => 'integer',
'helpful_count' => 'integer',
'not_helpful_count' => 'integer',
'published_at' => 'datetime',
];
}
public function category(): BelongsTo
{
return $this->belongsTo(KnowledgeBaseCategory::class, 'category_id');
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
public function revisions(): HasMany
{
return $this->hasMany(ArticleRevision::class, 'article_id');
}
public function votes(): HasMany
{
return $this->hasMany(ArticleVote::class, 'article_id');
}
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')
->where('published_at', '<=', now());
}
public function scopeFeatured(Builder $query): Builder
{
return $query->where('is_featured', true);
}
public function scopeSearch(Builder $query, string $search): Builder
{
return $query->whereRaw(
'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)',
[$search.'*']
);
}
public function helpfulPercentage(): float
{
$total = $this->helpful_count + $this->not_helpful_count;
if ($total === 0) {
return 0.0;
}
return round(($this->helpful_count / $total) * 100, 1);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class KnowledgeBaseCategory extends Model
{
use HasFactory;
protected $fillable = [
'parent_id',
'name',
'slug',
'description',
'icon',
'sort_order',
'is_visible',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'sort_order' => 'integer',
'is_visible' => 'boolean',
];
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
public function articles(): HasMany
{
return $this->hasMany(KnowledgeBaseArticle::class, 'category_id');
}
public function scopeVisible(Builder $query): Builder
{
return $query->where('is_visible', true);
}
public function scopeRoot(Builder $query): Builder
{
return $query->whereNull('parent_id');
}
/** @return Collection<int, self> */
public function getAncestors(): Collection
{
$ancestors = new Collection;
$current = $this->parent;
while ($current) {
$ancestors->prepend($current);
$current = $current->parent;
}
return $ancestors;
}
}

View File

@@ -0,0 +1,49 @@
<?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 OrderRiskAssessment extends Model
{
use HasFactory;
protected $fillable = [
'order_id',
'user_id',
'risk_score',
'risk_level',
'checks',
'auto_action',
'reviewed_by',
'reviewed_at',
];
protected function casts(): array
{
return [
'checks' => 'array',
'risk_score' => 'integer',
'reviewed_at' => 'datetime',
];
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by');
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Services\Billing\CurrencyService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -30,6 +31,10 @@ class Plan extends Model
'stock_quantity', 'stock_quantity',
'status', 'status',
'sort_order', 'sort_order',
'days_to_suspend',
'days_to_terminate',
'auto_suspend_enabled',
'auto_terminate_enabled',
]; ];
protected function casts(): array protected function casts(): array
@@ -40,6 +45,10 @@ class Plan extends Model
'provisioning_config' => 'array', 'provisioning_config' => 'array',
'stock_quantity' => 'integer', 'stock_quantity' => 'integer',
'sort_order' => 'integer', 'sort_order' => 'integer',
'days_to_suspend' => 'integer',
'days_to_terminate' => 'integer',
'auto_suspend_enabled' => 'boolean',
'auto_terminate_enabled' => 'boolean',
]; ];
} }
@@ -81,6 +90,18 @@ class Plan extends Model
return true; return true;
} }
public function priceInCurrency(string $currency): float
{
if (strtoupper($currency) === strtoupper($this->currency ?? 'USD')) {
return (float) $this->price;
}
/** @var CurrencyService $currencyService */
$currencyService = app(CurrencyService::class);
return $currencyService->convert((float) $this->price, $this->currency ?? 'USD', $currency);
}
public function scopePublic(Builder $query): Builder public function scopePublic(Builder $query): Builder
{ {
return $query->whereNotIn('status', ['hidden', 'internal', 'inactive']); return $query->whereNotIn('status', ['hidden', 'internal', 'inactive']);

View File

@@ -0,0 +1,96 @@
<?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 Illuminate\Support\Str;
class Quote extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'prospect_email',
'prospect_name',
'number',
'status',
'items',
'subtotal',
'tax',
'total',
'currency',
'notes',
'valid_until',
'accepted_at',
'sent_at',
'created_by',
];
protected function casts(): array
{
return [
'items' => 'array',
'subtotal' => 'decimal:2',
'tax' => 'decimal:2',
'total' => 'decimal:2',
'valid_until' => 'date',
'accepted_at' => 'datetime',
'sent_at' => 'datetime',
];
}
public static function generateNumber(): string
{
return 'QT-'.strtoupper(Str::random(6));
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function isExpired(): bool
{
if (! $this->valid_until) {
return false;
}
return $this->valid_until->isPast();
}
public function markAccepted(): void
{
$this->update([
'status' => 'accepted',
'accepted_at' => now(),
]);
}
public function markSent(): void
{
$this->update([
'status' => 'sent',
'sent_at' => now(),
]);
}
public function getRecipientEmail(): ?string
{
return $this->user?->email ?? $this->prospect_email;
}
public function getRecipientName(): ?string
{
return $this->user?->name ?? $this->prospect_name;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SlaBusinessHours extends Model
{
protected $fillable = [
'day_of_week',
'start_time',
'end_time',
'is_holiday',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'is_holiday' => 'boolean',
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SlaPolicy extends Model
{
use HasFactory;
protected $fillable = [
'name',
'priority',
'first_response_hours',
'resolution_hours',
'business_hours_only',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'business_hours_only' => 'boolean',
];
}
public function departments(): HasMany
{
return $this->hasMany(Department::class);
}
}

View File

@@ -8,7 +8,9 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class SupportTicket extends Model class SupportTicket extends Model
{ {
@@ -22,6 +24,16 @@ class SupportTicket extends Model
'status', 'status',
'priority', 'priority',
'department', 'department',
'department_id',
'assigned_to',
'sla_policy_id',
'first_response_due_at',
'resolution_due_at',
'first_responded_at',
'resolved_at',
'sla_first_response_breached',
'sla_resolution_breached',
'merged_into_ticket_id',
'last_reply_at', 'last_reply_at',
]; ];
@@ -30,6 +42,12 @@ class SupportTicket extends Model
{ {
return [ return [
'last_reply_at' => 'datetime', 'last_reply_at' => 'datetime',
'first_response_due_at' => 'datetime',
'resolution_due_at' => 'datetime',
'first_responded_at' => 'datetime',
'resolved_at' => 'datetime',
'sla_first_response_breached' => 'boolean',
'sla_resolution_breached' => 'boolean',
]; ];
} }
@@ -61,4 +79,39 @@ class SupportTicket extends Model
{ {
return $this->hasMany(TicketReply::class, 'ticket_id'); return $this->hasMany(TicketReply::class, 'ticket_id');
} }
public function departmentRelation(): BelongsTo
{
return $this->belongsTo(Department::class, 'department_id');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function slaPolicy(): BelongsTo
{
return $this->belongsTo(SlaPolicy::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(TicketTag::class, 'support_ticket_tag', 'ticket_id', 'tag_id');
}
public function customFieldValues(): HasMany
{
return $this->hasMany(TicketCustomFieldValue::class, 'ticket_id');
}
public function satisfactionRating(): HasOne
{
return $this->hasOne(TicketSatisfactionRating::class, 'ticket_id');
}
public function mergedInto(): BelongsTo
{
return $this->belongsTo(self::class, 'merged_into_ticket_id');
}
} }

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class TicketCustomField extends Model
{
protected $fillable = [
'name',
'type',
'options',
'is_required',
'department_id',
'sort_order',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'options' => 'array',
'is_required' => 'boolean',
];
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function values(): HasMany
{
return $this->hasMany(TicketCustomFieldValue::class, 'custom_field_id');
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketCustomFieldValue extends Model
{
protected $fillable = [
'ticket_id',
'custom_field_id',
'value',
];
public function ticket(): BelongsTo
{
return $this->belongsTo(SupportTicket::class, 'ticket_id');
}
public function customField(): BelongsTo
{
return $this->belongsTo(TicketCustomField::class, 'custom_field_id');
}
}

View File

@@ -20,6 +20,7 @@ class TicketReply extends Model
'message_id', 'message_id',
'from_email', 'from_email',
'via_email', 'via_email',
'is_internal',
]; ];
/** @return array<string, string> */ /** @return array<string, string> */
@@ -28,6 +29,7 @@ class TicketReply extends Model
return [ return [
'is_staff_reply' => 'boolean', 'is_staff_reply' => 'boolean',
'via_email' => 'boolean', 'via_email' => 'boolean',
'is_internal' => 'boolean',
]; ];
} }

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketSatisfactionRating extends Model
{
protected $fillable = [
'ticket_id',
'user_id',
'rating',
'comment',
];
public function ticket(): BelongsTo
{
return $this->belongsTo(SupportTicket::class, 'ticket_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class TicketTag extends Model
{
use HasFactory;
protected $fillable = [
'name',
'color',
];
public function tickets(): BelongsToMany
{
return $this->belongsToMany(SupportTicket::class, 'support_ticket_tag', 'tag_id', 'ticket_id');
}
}

View File

@@ -30,6 +30,10 @@ class User extends Authenticatable implements MustVerifyEmail
'company', 'company',
'admin_notes', 'admin_notes',
'virtfusion_user_id', 'virtfusion_user_id',
'override_days_to_suspend',
'override_days_to_terminate',
'credit_balance',
'currency',
]; ];
/** @var list<string> */ /** @var list<string> */
@@ -47,6 +51,9 @@ class User extends Authenticatable implements MustVerifyEmail
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'passkey_credentials' => 'json', 'passkey_credentials' => 'json',
'override_days_to_suspend' => 'integer',
'override_days_to_terminate' => 'integer',
'credit_balance' => 'decimal:2',
]; ];
} }
@@ -101,9 +108,44 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(TrustedDevice::class); return $this->hasMany(TrustedDevice::class);
} }
public function accountCredits(): HasMany
{
return $this->hasMany(AccountCredit::class);
}
public function creditNotes(): HasMany
{
return $this->hasMany(CreditNote::class);
}
public function debitNotes(): HasMany
{
return $this->hasMany(DebitNote::class);
}
public function cartItems(): HasMany
{
return $this->hasMany(CartItem::class);
}
public function quotes(): HasMany
{
return $this->hasMany(Quote::class);
}
public function affiliate(): HasOne
{
return $this->hasOne(Affiliate::class);
}
public function isAdmin(): bool public function isAdmin(): bool
{ {
return $this->hasRole('admin'); return $this->hasRole(['admin', 'super_admin', 'billing_admin', 'support_agent', 'support_lead', 'readonly_admin']);
}
public function isSuperAdmin(): bool
{
return $this->hasRole(['admin', 'super_admin']);
} }
public function isCustomer(): bool public function isCustomer(): bool

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Quote;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class QuoteSentNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Quote $quote,
public string $signedUrl,
) {}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$total = number_format((float) $this->quote->total, 2);
$currency = strtoupper($this->quote->currency);
$recipientName = $this->quote->getRecipientName() ?? 'there';
$validUntil = $this->quote->valid_until?->format('M d, Y');
$message = (new MailMessage)
->subject("Quote {$this->quote->number} from EZSCALE")
->greeting("Hello {$recipientName}!")
->line("We've prepared a quote for you.")
->line("**Quote #:** {$this->quote->number}")
->line("**Total:** {$currency} {$total}");
if ($validUntil) {
$message->line("**Valid Until:** {$validUntil}");
}
if ($this->quote->notes) {
$message->line("**Notes:** {$this->quote->notes}");
}
$message->action('View Quote', $this->signedUrl)
->line('You can accept or decline this quote using the link above.')
->line('Thank you for considering EZSCALE!');
return $message;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Service;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ServiceSuspendedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Service $service,
) {}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
$billingUrl = 'https://'.config('app.domains.account').'/billing';
$mail = (new MailMessage)
->subject('Service Suspended - Payment Required')
->greeting("Hello {$notifiable->name},")
->line('Your service has been suspended due to an overdue payment.');
if ($this->service->hostname) {
$mail->line("Hostname: **{$this->service->hostname}**");
}
if ($this->service->ipv4_address) {
$mail->line("IP Address: **{$this->service->ipv4_address}**");
}
$mail->line("Service Type: **{$this->service->service_type}**");
return $mail
->action('Pay Now', $billingUrl)
->line('Your service will be reactivated automatically once payment is received.')
->line('If the balance remains unpaid, the service will be terminated permanently.');
}
/** @return array<string, mixed> */
public function toArray(object $notifiable): array
{
return [
'type' => 'service_suspended',
'service_id' => $this->service->id,
'service_type' => $this->service->service_type,
'hostname' => $this->service->hostname,
'ip_address' => $this->service->ipv4_address,
'message' => "Your {$this->service->service_type} service has been suspended due to overdue payment.",
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Service;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ServiceSuspensionWarningNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Service $service,
) {}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
$billingUrl = 'https://'.config('app.domains.account').'/billing';
$mail = (new MailMessage)
->subject('Action Required: Service Suspension Warning')
->greeting("Hello {$notifiable->name},")
->line('Your service is at risk of suspension due to an overdue payment.')
->line('**If payment is not received within 24 hours, your service will be suspended.**');
if ($this->service->hostname) {
$mail->line("Hostname: **{$this->service->hostname}**");
}
if ($this->service->ipv4_address) {
$mail->line("IP Address: **{$this->service->ipv4_address}**");
}
$mail->line("Service Type: **{$this->service->service_type}**");
return $mail
->action('Update Payment Method', $billingUrl)
->line('Please update your payment method or pay the outstanding invoice to avoid service interruption.');
}
/** @return array<string, mixed> */
public function toArray(object $notifiable): array
{
return [
'type' => 'service_suspension_warning',
'service_id' => $this->service->id,
'service_type' => $this->service->service_type,
'hostname' => $this->service->hostname,
'ip_address' => $this->service->ipv4_address,
'message' => "Your {$this->service->service_type} service will be suspended in 24 hours due to overdue payment.",
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Service;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ServiceTerminatedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Service $service,
) {}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
$billingUrl = 'https://'.config('app.domains.account').'/billing';
$mail = (new MailMessage)
->subject('Service Terminated')
->greeting("Hello {$notifiable->name},")
->line('Your service has been permanently terminated due to prolonged non-payment.');
if ($this->service->hostname) {
$mail->line("Hostname: **{$this->service->hostname}**");
}
if ($this->service->ipv4_address) {
$mail->line("IP Address: **{$this->service->ipv4_address}**");
}
$mail->line("Service Type: **{$this->service->service_type}**");
return $mail
->action('View Account', $billingUrl)
->line('All data associated with this service has been permanently removed.')
->line('If you wish to start a new service, please visit our plans page.');
}
/** @return array<string, mixed> */
public function toArray(object $notifiable): array
{
return [
'type' => 'service_terminated',
'service_id' => $this->service->id,
'service_type' => $this->service->service_type,
'hostname' => $this->service->hostname,
'ip_address' => $this->service->ipv4_address,
'message' => "Your {$this->service->service_type} service has been permanently terminated.",
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Service;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ServiceTerminationWarningNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Service $service,
) {}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
$billingUrl = 'https://'.config('app.domains.account').'/billing';
$mail = (new MailMessage)
->subject('Urgent: Service Termination Warning')
->greeting("Hello {$notifiable->name},")
->line('Your suspended service is scheduled for **permanent termination in 7 days**.')
->line('Once terminated, all data associated with this service will be permanently deleted and cannot be recovered.');
if ($this->service->hostname) {
$mail->line("Hostname: **{$this->service->hostname}**");
}
if ($this->service->ipv4_address) {
$mail->line("IP Address: **{$this->service->ipv4_address}**");
}
$mail->line("Service Type: **{$this->service->service_type}**");
return $mail
->action('Pay Now to Reactivate', $billingUrl)
->line('Please pay the outstanding balance immediately to prevent permanent data loss.');
}
/** @return array<string, mixed> */
public function toArray(object $notifiable): array
{
return [
'type' => 'service_termination_warning',
'service_id' => $this->service->id,
'service_type' => $this->service->service_type,
'hostname' => $this->service->hostname,
'ip_address' => $this->service->ipv4_address,
'message' => "Your {$this->service->service_type} service will be terminated in 7 days. Pay now to prevent data loss.",
];
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Affiliate;
use App\Models\AffiliateCommission;
use App\Models\AffiliatePayout;
use App\Models\AffiliateReferral;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class AffiliateService
{
public function apply(User $user): Affiliate
{
return Affiliate::create([
'user_id' => $user->id,
'referral_code' => Affiliate::generateReferralCode(),
'status' => 'pending',
'commission_type' => config('affiliate.default_commission_type', 'percentage'),
'commission_rate' => config('affiliate.default_commission_rate', 10.00),
'recurring_commissions' => config('affiliate.default_recurring_commissions', false),
'minimum_payout' => config('affiliate.default_minimum_payout', 50.00),
]);
}
public function approve(Affiliate $affiliate): void
{
$affiliate->update([
'status' => 'active',
'approved_at' => now(),
]);
}
public function recordReferral(
Affiliate $affiliate,
User $referredUser,
?string $ip = null,
?string $url = null,
): AffiliateReferral {
return AffiliateReferral::create([
'affiliate_id' => $affiliate->id,
'referred_user_id' => $referredUser->id,
'status' => 'pending',
'signup_ip' => $ip,
'referral_url' => $url,
]);
}
public function recordCommission(
AffiliateReferral $referral,
PaymentTransaction $transaction,
string $type,
): AffiliateCommission {
$affiliate = $referral->affiliate;
$amount = $affiliate->commission_type === 'percentage'
? round((float) $transaction->amount * ((float) $affiliate->commission_rate / 100), 2)
: (float) $affiliate->commission_rate;
$commission = AffiliateCommission::create([
'affiliate_id' => $affiliate->id,
'referral_id' => $referral->id,
'payment_transaction_id' => $transaction->id,
'amount' => $amount,
'currency' => $transaction->currency ?? 'USD',
'type' => $type,
'status' => 'pending',
]);
$affiliate->increment('pending_balance', $amount);
return $commission;
}
public function approveCommission(AffiliateCommission $commission): void
{
DB::transaction(function () use ($commission): void {
$commission->update([
'status' => 'approved',
'approved_at' => now(),
]);
$affiliate = $commission->affiliate;
$affiliate->increment('total_earned', (float) $commission->amount);
});
}
public function requestPayout(Affiliate $affiliate, string $method): AffiliatePayout
{
$amount = (float) $affiliate->pending_balance;
if ($amount < (float) $affiliate->minimum_payout) {
throw new \InvalidArgumentException(
"Pending balance ({$amount}) is below minimum payout threshold ({$affiliate->minimum_payout})."
);
}
return DB::transaction(function () use ($affiliate, $method, $amount): AffiliatePayout {
$payout = AffiliatePayout::create([
'affiliate_id' => $affiliate->id,
'amount' => $amount,
'currency' => 'USD',
'method' => $method,
'status' => 'pending',
]);
$affiliate->decrement('pending_balance', $amount);
return $payout;
});
}
public function processPayout(AffiliatePayout $payout): void
{
DB::transaction(function () use ($payout): void {
$payout->update([
'status' => 'completed',
'processed_at' => now(),
'reference' => 'PAY-'.strtoupper(bin2hex(random_bytes(6))),
]);
$affiliate = $payout->affiliate;
$affiliate->increment('total_paid', (float) $payout->amount);
// Mark associated pending commissions as paid
$affiliate->commissions()
->where('status', 'approved')
->update([
'status' => 'paid',
'paid_at' => now(),
]);
});
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\CartItem;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
class CartService
{
/**
* Add an item to the cart.
*/
public function addItem(
User|string $userOrSession,
Plan $plan,
string $cycle,
int $qty = 1,
?array $config = null,
?array $provisioningConfig = null,
?string $couponCode = null,
): CartItem {
$attributes = $this->resolveOwnerAttributes($userOrSession);
// Check if the same plan + cycle already exists in the cart
$existing = CartItem::query()
->where($attributes)
->where('plan_id', $plan->id)
->where('billing_cycle', $cycle)
->first();
if ($existing) {
$existing->update(['quantity' => $existing->quantity + $qty]);
return $existing;
}
return CartItem::create(array_merge($attributes, [
'plan_id' => $plan->id,
'billing_cycle' => $cycle,
'quantity' => $qty,
'config_selections' => $config,
'provisioning_config' => $provisioningConfig,
'coupon_code' => $couponCode,
]));
}
/**
* Remove an item from the cart.
*/
public function removeItem(int $cartItemId): void
{
CartItem::query()->where('id', $cartItemId)->delete();
}
/**
* Update the quantity of a cart item.
*/
public function updateQuantity(int $cartItemId, int $qty): void
{
if ($qty <= 0) {
$this->removeItem($cartItemId);
return;
}
CartItem::query()->where('id', $cartItemId)->update(['quantity' => $qty]);
}
/**
* Get all cart items for a user or session.
*
* @return Collection<int, CartItem>
*/
public function getItems(User|string $userOrSession): Collection
{
$attributes = $this->resolveOwnerAttributes($userOrSession);
return CartItem::query()
->where($attributes)
->with('plan.prices')
->orderBy('created_at')
->get();
}
/**
* Get the total price of all cart items.
*/
public function getTotal(User|string $userOrSession): float
{
$items = $this->getItems($userOrSession);
$total = 0.0;
foreach ($items as $item) {
$price = $this->getItemPrice($item);
$total += $price * $item->quantity;
}
return round($total, 2);
}
/**
* Get the number of items in the cart.
*/
public function getItemCount(User|string $userOrSession): int
{
$attributes = $this->resolveOwnerAttributes($userOrSession);
return (int) CartItem::query()->where($attributes)->sum('quantity');
}
/**
* Merge session cart items into a user's cart on login.
*/
public function mergeSessionToUser(string $sessionId, User $user): void
{
$sessionItems = CartItem::query()
->where('session_id', $sessionId)
->whereNull('user_id')
->get();
foreach ($sessionItems as $sessionItem) {
$existing = CartItem::query()
->where('user_id', $user->id)
->where('plan_id', $sessionItem->plan_id)
->where('billing_cycle', $sessionItem->billing_cycle)
->first();
if ($existing) {
$existing->update(['quantity' => $existing->quantity + $sessionItem->quantity]);
$sessionItem->delete();
} else {
$sessionItem->update([
'user_id' => $user->id,
'session_id' => null,
]);
}
}
}
/**
* Clear all cart items for a user or session.
*/
public function clear(User|string $userOrSession): void
{
$attributes = $this->resolveOwnerAttributes($userOrSession);
CartItem::query()->where($attributes)->delete();
}
/**
* Get the price for a single cart item based on its billing cycle.
*/
public function getItemPrice(CartItem $item): float
{
$plan = $item->plan;
if (! $plan) {
return 0.0;
}
$planPrice = $plan->priceForCycle($item->billing_cycle);
if ($planPrice) {
return (float) $planPrice->price;
}
return (float) $plan->price;
}
/**
* Resolve owner attributes for querying (user_id or session_id).
*
* @return array<string, mixed>
*/
private function resolveOwnerAttributes(User|string $userOrSession): array
{
if ($userOrSession instanceof User) {
return ['user_id' => $userOrSession->id];
}
return ['session_id' => $userOrSession];
}
}

View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\AccountCredit;
use App\Models\CreditNote;
use App\Models\DebitNote;
use App\Models\Invoice;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class CreditService
{
/**
* Issue a credit note and add funds to the customer's account credit balance.
*/
public function issueCreditNote(
User $user,
float $amount,
?string $reason = null,
?Invoice $invoice = null,
?User $creator = null,
): CreditNote {
return DB::transaction(function () use ($user, $amount, $reason, $invoice, $creator): CreditNote {
$creditNote = CreditNote::create([
'user_id' => $user->id,
'invoice_id' => $invoice?->id,
'number' => CreditNote::generateNumber(),
'amount' => $amount,
'currency' => 'USD',
'reason' => $reason,
'status' => 'issued',
'issued_at' => now(),
'created_by' => $creator?->id,
]);
AccountCredit::create([
'user_id' => $user->id,
'amount' => $amount,
'currency' => 'USD',
'type' => 'credit_note',
'description' => "Credit note {$creditNote->number}".($reason ? ": {$reason}" : ''),
'reference_type' => CreditNote::class,
'reference_id' => $creditNote->id,
'created_by' => $creator?->id,
]);
$user->increment('credit_balance', $amount);
return $creditNote;
});
}
/**
* Issue a debit note to reduce the customer's credit balance.
* If balance is insufficient, reduces to 0 (does not go negative).
*/
public function issueDebitNote(
User $user,
float $amount,
string $reasonType,
?string $reason = null,
?Invoice $invoice = null,
?User $creator = null,
): DebitNote {
return DB::transaction(function () use ($user, $amount, $reasonType, $reason, $invoice, $creator): DebitNote {
$debitNote = DebitNote::create([
'user_id' => $user->id,
'invoice_id' => $invoice?->id,
'number' => DebitNote::generateNumber(),
'amount' => $amount,
'currency' => 'USD',
'reason_type' => $reasonType,
'reason' => $reason,
'status' => 'issued',
'issued_at' => now(),
'created_by' => $creator?->id,
]);
// Deduct from balance, but don't go below 0
$user->refresh();
$deduction = min((float) $user->credit_balance, $amount);
if ($deduction > 0) {
$user->decrement('credit_balance', $deduction);
AccountCredit::create([
'user_id' => $user->id,
'amount' => -$deduction,
'currency' => 'USD',
'type' => 'admin_adjustment',
'description' => "Debit note {$debitNote->number}".($reason ? ": {$reason}" : ''),
'reference_type' => DebitNote::class,
'reference_id' => $debitNote->id,
'created_by' => $creator?->id,
]);
}
return $debitNote;
});
}
/**
* Manually adjust a customer's credit balance (admin adjustment).
*/
public function adjustBalance(
User $user,
float $amount,
string $description,
?User $creator = null,
): AccountCredit {
return DB::transaction(function () use ($user, $amount, $description, $creator): AccountCredit {
$credit = AccountCredit::create([
'user_id' => $user->id,
'amount' => $amount,
'currency' => 'USD',
'type' => 'admin_adjustment',
'description' => $description,
'created_by' => $creator?->id,
]);
if ($amount >= 0) {
$user->increment('credit_balance', $amount);
} else {
$user->decrement('credit_balance', abs($amount));
}
return $credit;
});
}
/**
* Apply account credits to an invoice during checkout/payment.
* Returns the amount of credit applied.
*/
public function applyCreditsToInvoice(User $user, Invoice $invoice): float
{
return DB::transaction(function () use ($user, $invoice): float {
$user->refresh();
$balance = (float) $user->credit_balance;
if ($balance <= 0) {
return 0.0;
}
$invoiceTotal = (float) $invoice->total;
if ($invoiceTotal <= 0) {
return 0.0;
}
$amountToApply = min($balance, $invoiceTotal);
// Create a payment transaction recording the credit application
PaymentTransaction::create([
'user_id' => $user->id,
'invoice_id' => $invoice->id,
'gateway' => 'credit',
'gateway_transaction_id' => null,
'amount' => $amountToApply,
'currency' => $invoice->currency ?? 'USD',
'status' => 'succeeded',
'payment_method' => 'account_credit',
'description' => "Account credit applied to invoice {$invoice->number}",
]);
AccountCredit::create([
'user_id' => $user->id,
'amount' => -$amountToApply,
'currency' => 'USD',
'type' => 'admin_adjustment',
'description' => "Applied to invoice {$invoice->number}",
'reference_type' => Invoice::class,
'reference_id' => $invoice->id,
]);
$user->decrement('credit_balance', $amountToApply);
// If credit covers the full invoice, mark it as paid
if ($amountToApply >= $invoiceTotal) {
$invoice->update([
'status' => 'paid',
'paid_at' => now(),
]);
}
return $amountToApply;
});
}
/**
* Refund a payment transaction to account credit instead of the original payment method.
*/
public function refundToCredit(
User $user,
PaymentTransaction $transaction,
float $amount,
): AccountCredit {
return DB::transaction(function () use ($user, $transaction, $amount): AccountCredit {
$credit = AccountCredit::create([
'user_id' => $user->id,
'amount' => $amount,
'currency' => $transaction->currency ?? 'USD',
'type' => 'refund',
'description' => "Refund from transaction #{$transaction->id}",
'reference_type' => PaymentTransaction::class,
'reference_id' => $transaction->id,
]);
$user->increment('credit_balance', $amount);
return $credit;
});
}
/**
* Void a credit note: reverse the credit and reduce the user's balance.
*/
public function voidCreditNote(CreditNote $creditNote): void
{
DB::transaction(function () use ($creditNote): void {
$creditNote->update(['status' => 'voided']);
$user = $creditNote->user;
$amount = (float) $creditNote->amount;
// Deduct the amount back (to 0 minimum)
$deduction = min((float) $user->credit_balance, $amount);
if ($deduction > 0) {
$user->decrement('credit_balance', $deduction);
}
AccountCredit::create([
'user_id' => $user->id,
'amount' => -$amount,
'currency' => 'USD',
'type' => 'admin_adjustment',
'description' => "Voided credit note {$creditNote->number}",
'reference_type' => CreditNote::class,
'reference_id' => $creditNote->id,
]);
});
}
/**
* Void a debit note: restore the debited amount to the user's balance.
*/
public function voidDebitNote(DebitNote $debitNote): void
{
DB::transaction(function () use ($debitNote): void {
$debitNote->update(['status' => 'voided']);
$user = $debitNote->user;
$amount = (float) $debitNote->amount;
$user->increment('credit_balance', $amount);
AccountCredit::create([
'user_id' => $user->id,
'amount' => $amount,
'currency' => 'USD',
'type' => 'admin_adjustment',
'description' => "Voided debit note {$debitNote->number}",
'reference_type' => DebitNote::class,
'reference_id' => $debitNote->id,
]);
});
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\Currency;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
class CurrencyService
{
/**
* Convert an amount from one currency to another.
*/
public function convert(float $amount, string $from, string $to): float
{
$from = strtoupper($from);
$to = strtoupper($to);
if ($from === $to) {
return $amount;
}
$currencies = $this->getEnabledCurrencies()->keyBy('code');
$fromCurrency = $currencies->get($from);
$toCurrency = $currencies->get($to);
if (! $fromCurrency || ! $toCurrency) {
return $amount;
}
// Convert to base currency first, then to target
$fromRate = (float) $fromCurrency->exchange_rate;
$toRate = (float) $toCurrency->exchange_rate;
if ($fromRate <= 0) {
return $amount;
}
$amountInBase = $amount / $fromRate;
return round($amountInBase * $toRate, (int) $toCurrency->decimal_places);
}
/**
* Get all enabled currencies, cached for 1 hour.
*
* @return Collection<int, Currency>
*/
public function getEnabledCurrencies(): Collection
{
return Cache::remember('currencies:enabled', 3600, function (): Collection {
return Currency::query()->enabled()->orderBy('code')->get();
});
}
/**
* Get the base currency.
*/
public function getBaseCurrency(): Currency
{
return Cache::remember('currencies:base', 3600, function (): Currency {
return Currency::query()->base()->firstOrFail();
});
}
/**
* Format a price with the currency symbol.
*/
public function formatPrice(float $amount, string $currencyCode): string
{
$currency = $this->getEnabledCurrencies()->firstWhere('code', strtoupper($currencyCode));
if (! $currency) {
return number_format($amount, 2).' '.$currencyCode;
}
$formatted = number_format($amount, (int) $currency->decimal_places);
return $currency->symbol.$formatted;
}
}

View File

@@ -4,32 +4,56 @@ declare(strict_types=1);
namespace App\Services\Billing; namespace App\Services\Billing;
use App\Events\ServiceSuspended;
use App\Events\ServiceSuspending;
use App\Events\ServiceTerminated;
use App\Events\ServiceTerminating;
use App\Events\ServiceUnsuspended;
use App\Events\ServiceUnsuspending;
use App\Models\Service; use App\Models\Service;
use App\Models\User; use App\Models\User;
use App\Notifications\ServiceSuspendedNotification;
use App\Notifications\ServiceSuspensionWarningNotification;
use App\Notifications\ServiceTerminatedNotification;
use App\Notifications\ServiceTerminationWarningNotification;
use App\Services\Provisioning\ProvisioningFactory;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription; use Laravel\Cashier\Subscription;
class DunningService class DunningService
{ {
public function __construct(
private ProvisioningFactory $provisioningFactory,
) {}
/** /**
* Suspend services for subscriptions that are past due beyond the grace period. * Suspend services for subscriptions that are past due beyond the grace period.
* Uses per-plan and per-customer threshold overrides.
*/ */
public function suspendOverdueSubscriptions(): int public function suspendOverdueSubscriptions(): int
{ {
$graceDays = config('billing.suspension.days_past_due_to_suspend');
$cutoff = now()->subDays($graceDays);
$subscriptions = Subscription::query() $subscriptions = Subscription::query()
->where('stripe_status', 'past_due') ->where('stripe_status', 'past_due')
->where('updated_at', '<=', $cutoff) ->with('user')
->get(); ->get();
$count = 0; $count = 0;
foreach ($subscriptions as $subscription) { foreach ($subscriptions as $subscription) {
$effectiveDays = $this->getEffectiveSuspendDays($subscription);
if ($effectiveDays === null) {
// Auto-suspend disabled for this plan
continue;
}
$cutoff = now()->subDays($effectiveDays);
if ($subscription->updated_at->lte($cutoff)) {
$this->suspendServicesForSubscription($subscription); $this->suspendServicesForSubscription($subscription);
$count++; $count++;
} }
}
if ($count > 0) { if ($count > 0) {
Log::info("Dunning: suspended services for {$count} overdue subscriptions."); Log::info("Dunning: suspended services for {$count} overdue subscriptions.");
@@ -40,59 +64,286 @@ class DunningService
/** /**
* Terminate services for subscriptions that have been suspended too long. * Terminate services for subscriptions that have been suspended too long.
* Uses per-plan and per-customer threshold overrides.
*/ */
public function terminateLongSuspendedSubscriptions(): int public function terminateLongSuspendedSubscriptions(): int
{ {
$terminateDays = config('billing.suspension.days_suspended_to_terminate');
$cutoff = now()->subDays($terminateDays);
$services = Service::query() $services = Service::query()
->where('status', 'suspended') ->where('status', 'suspended')
->where('suspended_at', '<=', $cutoff) ->whereNotNull('suspended_at')
->with(['user', 'plan'])
->get(); ->get();
$count = 0; $count = 0;
foreach ($services as $service) { foreach ($services as $service) {
$effectiveDays = $this->getEffectiveTerminateDays($service);
if ($effectiveDays === null) {
// Auto-terminate disabled for this plan
continue;
}
$cutoff = now()->subDays($effectiveDays);
if ($service->suspended_at->lte($cutoff)) {
$user = $service->user;
ServiceTerminating::dispatch($user, $service);
$service->update([ $service->update([
'status' => 'terminated', 'status' => 'terminated',
'terminated_at' => now(), 'terminated_at' => now(),
'auto_renew' => false, 'auto_renew' => false,
]); ]);
try {
$this->provisioningFactory->make($service->service_type)->terminate($service);
} catch (\Throwable $e) {
Log::error("Dunning: failed to terminate service #{$service->id} on platform", [
'service_id' => $service->id,
'service_type' => $service->service_type,
'error' => $e->getMessage(),
]);
}
ServiceTerminated::dispatch($user, $service);
$user->notify(new ServiceTerminatedNotification($service));
$count++; $count++;
Log::info("Dunning: terminated service #{$service->id} for user #{$service->user_id}."); Log::info("Dunning: terminated service #{$service->id} for user #{$service->user_id}.");
} }
}
return $count; return $count;
} }
/** /**
* Suspend all services tied to a specific subscription. * Suspend all services tied to a specific subscription.
* Calls provisioning API for each service and fires events.
*/ */
public function suspendServicesForSubscription(Subscription $subscription): void public function suspendServicesForSubscription(Subscription $subscription): void
{ {
Service::query() $services = Service::query()
->where('subscription_id', $subscription->id) ->where('subscription_id', $subscription->id)
->where('status', 'active') ->where('status', 'active')
->update([ ->with(['user', 'plan'])
->get();
foreach ($services as $service) {
$user = $service->user;
ServiceSuspending::dispatch($user, $service);
$service->update([
'status' => 'suspended', 'status' => 'suspended',
'suspended_at' => now(), 'suspended_at' => now(),
]); ]);
try {
$this->provisioningFactory->make($service->service_type)->suspend($service);
} catch (\Throwable $e) {
Log::error("Dunning: failed to suspend service #{$service->id} on platform", [
'service_id' => $service->id,
'service_type' => $service->service_type,
'error' => $e->getMessage(),
]);
}
ServiceSuspended::dispatch($user, $service);
$user->notify(new ServiceSuspendedNotification($service));
}
} }
/** /**
* Reactivate services when a past-due subscription becomes active again. * Reactivate services when a past-due subscription becomes active again.
* Calls provisioning API for each service and fires events.
*/ */
public function reactivateServicesForSubscription(Subscription $subscription): void public function reactivateServicesForSubscription(Subscription $subscription): void
{ {
Service::query() $services = Service::query()
->where('subscription_id', $subscription->id) ->where('subscription_id', $subscription->id)
->where('status', 'suspended') ->where('status', 'suspended')
->update([ ->with('user')
->get();
foreach ($services as $service) {
$user = $service->user;
ServiceUnsuspending::dispatch($user, $service);
$service->update([
'status' => 'active', 'status' => 'active',
'suspended_at' => null, 'suspended_at' => null,
]); ]);
try {
$this->provisioningFactory->make($service->service_type)->unsuspend($service);
} catch (\Throwable $e) {
Log::error("Dunning: failed to unsuspend service #{$service->id} on platform", [
'service_id' => $service->id,
'service_type' => $service->service_type,
'error' => $e->getMessage(),
]);
}
ServiceUnsuspended::dispatch($user, $service);
}
}
/**
* Send suspension warning notifications to users whose services will be suspended soon.
* Warns users whose subscriptions are past due and within the warning window.
*/
public function sendSuspensionWarnings(): int
{
$warningDays = config('billing.suspension.warning_days_before_suspend');
$subscriptions = Subscription::query()
->where('stripe_status', 'past_due')
->with('user')
->get();
$count = 0;
foreach ($subscriptions as $subscription) {
$effectiveDays = $this->getEffectiveSuspendDays($subscription);
if ($effectiveDays === null) {
continue;
}
$daysPastDue = (int) now()->diffInDays($subscription->updated_at);
$daysUntilSuspend = $effectiveDays - $daysPastDue;
// Send warning if within the warning window (e.g., 1 day before suspension)
if ($daysUntilSuspend <= $warningDays && $daysUntilSuspend > 0) {
$services = Service::query()
->where('subscription_id', $subscription->id)
->where('status', 'active')
->get();
$user = $subscription->user;
foreach ($services as $service) {
$user->notify(new ServiceSuspensionWarningNotification($service));
$count++;
}
}
}
if ($count > 0) {
Log::info("Dunning: sent {$count} suspension warning notification(s).");
}
return $count;
}
/**
* Send termination warning notifications to users whose services will be terminated soon.
* Warns users whose services are suspended and within the warning window.
*/
public function sendTerminationWarnings(): int
{
$warningDays = config('billing.suspension.warning_days_before_terminate');
$services = Service::query()
->where('status', 'suspended')
->whereNotNull('suspended_at')
->with(['user', 'plan'])
->get();
$count = 0;
foreach ($services as $service) {
$effectiveDays = $this->getEffectiveTerminateDays($service);
if ($effectiveDays === null) {
continue;
}
$daysSuspended = (int) now()->diffInDays($service->suspended_at);
$daysUntilTerminate = $effectiveDays - $daysSuspended;
// Send warning if within the warning window (e.g., 7 days before termination)
if ($daysUntilTerminate <= $warningDays && $daysUntilTerminate > 0) {
$service->user->notify(new ServiceTerminationWarningNotification($service));
$count++;
}
}
if ($count > 0) {
Log::info("Dunning: sent {$count} termination warning notification(s).");
}
return $count;
}
/**
* Get the effective number of days past due before suspension.
* Resolution order: Customer override -> Plan setting -> Global config.
*
* Returns null if auto-suspend is disabled for the plan.
*/
public function getEffectiveSuspendDays(Subscription $subscription): ?int
{
// Load the plan via any service linked to this subscription
$service = Service::query()
->where('subscription_id', $subscription->id)
->with('plan')
->first();
$plan = $service?->plan;
// Check if auto-suspend is disabled at the plan level
if ($plan && ! $plan->auto_suspend_enabled) {
return null;
}
// Customer override takes priority
$user = $subscription->user;
if ($user && $user->override_days_to_suspend !== null) {
return $user->override_days_to_suspend;
}
// Plan-level setting
if ($plan && $plan->days_to_suspend !== null) {
return $plan->days_to_suspend;
}
// Global config fallback
return (int) config('billing.suspension.days_past_due_to_suspend');
}
/**
* Get the effective number of days suspended before termination.
* Resolution order: Customer override -> Plan setting -> Global config.
*
* Returns null if auto-terminate is disabled for the plan.
*/
public function getEffectiveTerminateDays(Service $service): ?int
{
$plan = $service->plan;
// Check if auto-terminate is disabled at the plan level
if ($plan && ! $plan->auto_terminate_enabled) {
return null;
}
// Customer override takes priority
$user = $service->user;
if ($user && $user->override_days_to_terminate !== null) {
return $user->override_days_to_terminate;
}
// Plan-level setting
if ($plan && $plan->days_to_terminate !== null) {
return $plan->days_to_terminate;
}
// Global config fallback
return (int) config('billing.suspension.days_suspended_to_terminate');
} }
/** /**

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\LoginHistory;
use App\Models\Order;
use App\Models\OrderRiskAssessment;
use App\Models\User;
class FraudDetectionService
{
/**
* Weight distribution for risk checks.
*
* @var array<string, int>
*/
private const WEIGHTS = [
'disposable_email' => 25,
'velocity' => 20,
'ip_country_mismatch' => 20,
'email_domain_age' => 15,
'previous_flags' => 20,
];
public function assess(User $user, ?Order $order = null): OrderRiskAssessment
{
if (! config('fraud.enabled', true)) {
return OrderRiskAssessment::create([
'order_id' => $order?->id,
'user_id' => $user->id,
'risk_score' => 0,
'risk_level' => 'low',
'checks' => [],
'auto_action' => 'approve',
]);
}
$ip = request()->ip() ?? '127.0.0.1';
$billingCountry = $user->profile?->billing_country;
$checks = [
'disposable_email' => $this->checkDisposableEmail($user->email),
'velocity' => $this->checkVelocity($user),
'ip_country_mismatch' => $this->checkIpCountryMismatch($ip, $billingCountry),
'email_domain_age' => $this->checkEmailDomainAge($user->email),
'previous_flags' => $this->checkPreviousFraudFlags($user),
];
$totalScore = 0;
foreach ($checks as $name => $check) {
$weight = self::WEIGHTS[$name] ?? 0;
$totalScore += (int) round($check['score'] * ($weight / 100));
}
$totalScore = min(100, max(0, $totalScore));
$thresholds = config('fraud.thresholds', []);
$autoApproveBelow = (int) ($thresholds['auto_approve_below'] ?? 30);
$autoHoldAbove = (int) ($thresholds['auto_hold_above'] ?? 60);
$autoRejectAbove = (int) ($thresholds['auto_reject_above'] ?? 85);
$autoAction = match (true) {
$totalScore >= $autoRejectAbove => 'reject',
$totalScore >= $autoHoldAbove => 'hold',
default => 'approve',
};
$riskLevel = match (true) {
$totalScore >= $autoRejectAbove => 'critical',
$totalScore >= $autoHoldAbove => 'high',
$totalScore >= $autoApproveBelow => 'medium',
default => 'low',
};
return OrderRiskAssessment::create([
'order_id' => $order?->id,
'user_id' => $user->id,
'risk_score' => $totalScore,
'risk_level' => $riskLevel,
'checks' => $checks,
'auto_action' => $autoAction,
]);
}
/**
* Check if the email domain is a known disposable email provider.
*
* @return array{score: int, details: string}
*/
public function checkDisposableEmail(string $email): array
{
$domain = strtolower(substr($email, strrpos($email, '@') + 1));
$disposableDomains = config('fraud.disposable_email_domains', []);
if (in_array($domain, $disposableDomains, true)) {
return [
'score' => 100,
'details' => "Disposable email domain detected: {$domain}",
];
}
return [
'score' => 0,
'details' => 'Email domain is legitimate',
];
}
/**
* Check signup velocity from the same IP in the last 24 hours.
*
* @return array{score: int, details: string}
*/
public function checkVelocity(User $user): array
{
$ip = request()->ip() ?? '127.0.0.1';
$recentSignups = LoginHistory::where('ip_address', $ip)
->where('created_at', '>=', now()->subDay())
->where('success', true)
->distinct('user_id')
->count('user_id');
if ($recentSignups >= 5) {
return [
'score' => 100,
'details' => "{$recentSignups} distinct user logins from IP {$ip} in 24h",
];
}
if ($recentSignups >= 3) {
return [
'score' => 60,
'details' => "{$recentSignups} distinct user logins from IP {$ip} in 24h",
];
}
return [
'score' => 0,
'details' => 'Normal signup velocity',
];
}
/**
* Check if the IP geolocation matches the billing country.
*
* @return array{score: int, details: string}
*/
public function checkIpCountryMismatch(string $ip, ?string $billingCountry): array
{
if ($billingCountry === null) {
return [
'score' => 10,
'details' => 'No billing country set for comparison',
];
}
try {
$location = geoip($ip);
$ipCountry = $location->iso_code ?? null;
if ($ipCountry === null) {
return [
'score' => 10,
'details' => 'Unable to determine IP country',
];
}
if (strtoupper($ipCountry) !== strtoupper($billingCountry)) {
return [
'score' => 80,
'details' => "IP country ({$ipCountry}) does not match billing country ({$billingCountry})",
];
}
return [
'score' => 0,
'details' => 'IP country matches billing country',
];
} catch (\Throwable) {
return [
'score' => 0,
'details' => 'GeoIP lookup unavailable, skipping check',
];
}
}
/**
* Check if the email domain has valid MX records.
*
* @return array{score: int, details: string}
*/
public function checkEmailDomainAge(string $email): array
{
$domain = substr($email, strrpos($email, '@') + 1);
try {
$mxRecords = [];
$hasMx = @getmxrr($domain, $mxRecords);
if (! $hasMx || empty($mxRecords)) {
return [
'score' => 80,
'details' => "No MX records found for domain: {$domain}",
];
}
return [
'score' => 0,
'details' => "Email domain {$domain} has valid MX records",
];
} catch (\Throwable) {
return [
'score' => 0,
'details' => 'DNS lookup unavailable, skipping check',
];
}
}
/**
* Check if the user has previous fraud flags or risk assessments.
*
* @return array{score: int, details: string}
*/
public function checkPreviousFraudFlags(User $user): array
{
$previousFlags = OrderRiskAssessment::where('user_id', $user->id)
->whereIn('risk_level', ['high', 'critical'])
->count();
if ($previousFlags >= 3) {
return [
'score' => 100,
'details' => "{$previousFlags} previous high/critical risk assessments",
];
}
if ($previousFlags >= 1) {
return [
'score' => 50,
'details' => "{$previousFlags} previous high/critical risk assessment(s)",
];
}
return [
'score' => 0,
'details' => 'No previous fraud flags',
];
}
}

View File

@@ -412,16 +412,27 @@ class FinancialReportService
$query->whereNull('subscriptions.cancelled_at') $query->whereNull('subscriptions.cancelled_at')
->orWhere('subscriptions.cancelled_at', '>', $date->endOfDay()); ->orWhere('subscriptions.cancelled_at', '>', $date->endOfDay());
}) })
->join('plan_prices', function ($join): void { ->leftJoin('plan_prices', function ($join): void {
$join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id') $join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id')
->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle'); ->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle');
}) })
->selectRaw('SUM(CASE subscriptions.billing_cycle ->selectRaw('SUM(CASE
WHEN subscriptions.recurring_amount IS NOT NULL THEN
CASE subscriptions.billing_cycle
WHEN "monthly" THEN subscriptions.recurring_amount
WHEN "quarterly" THEN subscriptions.recurring_amount / 3
WHEN "semi_annual" THEN subscriptions.recurring_amount / 6
WHEN "annual" THEN subscriptions.recurring_amount / 12
ELSE subscriptions.recurring_amount
END
ELSE
CASE subscriptions.billing_cycle
WHEN "monthly" THEN plan_prices.price WHEN "monthly" THEN plan_prices.price
WHEN "quarterly" THEN plan_prices.price / 3 WHEN "quarterly" THEN plan_prices.price / 3
WHEN "semi_annual" THEN plan_prices.price / 6 WHEN "semi_annual" THEN plan_prices.price / 6
WHEN "annual" THEN plan_prices.price / 12 WHEN "annual" THEN plan_prices.price / 12
ELSE plan_prices.price ELSE plan_prices.price
END
END) as mrr') END) as mrr')
->value('mrr') ?? 0); ->value('mrr') ?? 0);
} }

View File

@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Plan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class ServerHunterService
{
/**
* Get all active VPS and dedicated plans as ServerHunter offers.
*
* @return array{version: int, offers: list<array<string, mixed>>}
*/
public function buildFeed(): array
{
$plans = Plan::query()
->whereIn('service_type', ['vps', 'dedicated'])
->where('status', 'active')
->with('prices')
->orderBy('service_type')
->orderBy('sort_order')
->orderBy('price')
->get();
$offers = $plans
->map(fn (Plan $plan): ?array => $this->transformPlan($plan))
->filter()
->values()
->all();
return [
'version' => 1,
'offers' => $offers,
];
}
/**
* Transform a Plan model into a ServerHunter offer array.
*
* @return array<string, mixed>|null
*/
public function transformPlan(Plan $plan): ?array
{
if (! in_array($plan->service_type, ['vps', 'dedicated'], true)) {
return null;
}
$config = $plan->provisioning_config ?? [];
$specs = $this->resolveSpecs($plan, $config);
$typeConfig = config("serverhunter.{$plan->service_type}", []);
$monthlyPrice = $this->getMonthlyPrice($plan);
if ($monthlyPrice === null) {
return null;
}
$stock = $this->resolveStock($plan);
return [
'name' => $plan->name,
'internal' => $plan->slug,
'url' => 'https://ezscale.cloud/pricing',
'currency' => config('serverhunter.currency'),
'price' => number_format((float) $monthlyPrice, 2, '.', ''),
'setup_fee' => $typeConfig['setup_fee'] ?? '0.00',
'stock' => $stock,
'billing_interval' => 'monthly',
'product_type' => $plan->service_type,
'virtualization' => $typeConfig['virtualization'] ?? 'kvm',
'visibility' => 'visible',
'gpu_name' => $specs['gpu_name'],
'cpu_type' => $specs['cpu_type'],
'cpu_name' => $specs['cpu_name'],
'cpu_amount' => (string) $specs['cpu_amount'],
'cpu_cores' => (string) $specs['cpu_cores'],
'cpu_speed' => $specs['cpu_speed'],
'memory_amount' => (string) $specs['memory_mb'],
'memory_type' => $specs['memory_type'],
'memory_ecc' => $specs['memory_ecc'],
'hdd_amount' => (string) $specs['hdd_amount'],
'hdd_capacity' => (string) $specs['hdd_capacity'],
'ssd_amount' => (string) $specs['ssd_amount'],
'ssd_capacity' => (string) $specs['ssd_capacity'],
'uplink' => (string) $specs['uplink_mbps'],
'traffic' => (string) $specs['bandwidth_tb'],
'unmetered' => $typeConfig['unmetered'] ?? [],
'operating_systems' => $typeConfig['operating_systems'] ?? [],
'control_panel' => [],
'country_code' => config('serverhunter.country_code'),
'location' => config('serverhunter.location'),
'coordinates' => config('serverhunter.coordinates'),
'payment_methods' => config('serverhunter.payment_methods'),
'features' => $typeConfig['features'] ?? [],
];
}
/**
* Push offers to the ServerHunter API.
*
* @param array{version: int, offers: list<array<string, mixed>>} $feed
*/
public function pushToApi(array $feed): array
{
$apiKey = config('serverhunter.api_key');
if (empty($apiKey)) {
throw new \RuntimeException('SERVERHUNTER_API_KEY is not configured.');
}
$response = Http::withToken($apiKey)
->timeout(30)
->post(config('serverhunter.api_url').'/v1/offers', $feed);
return [
'status' => $response->status(),
'body' => $response->json(),
'successful' => $response->successful(),
];
}
/**
* Fetch and cache the ServerHunter spider IP whitelist.
*
* @return list<string>
*/
public function getSpiderIps(): array
{
$ttl = (int) config('serverhunter.spider_ips_cache_ttl', 86400);
return Cache::remember('serverhunter:spider-ips', $ttl, function (): array {
$response = Http::timeout(10)
->get(config('serverhunter.spider_ips_url'));
if (! $response->successful()) {
return [];
}
$body = trim($response->body());
if (empty($body)) {
return [];
}
// The response is a newline-separated list of IPs
return array_values(array_filter(
array_map('trim', explode("\n", $body)),
fn (string $ip): bool => $ip !== '' && filter_var($ip, FILTER_VALIDATE_IP) !== false,
));
});
}
/**
* Get the monthly price for a plan.
*/
private function getMonthlyPrice(Plan $plan): ?string
{
// First try the plan_prices table for a monthly price
$monthlyPrice = $plan->priceForCycle('monthly');
if ($monthlyPrice !== null) {
return $monthlyPrice->price;
}
// Fall back to the plan's base price
if ($plan->price !== null && (float) $plan->price > 0) {
return $plan->price;
}
return null;
}
/**
* Resolve hardware specs from provisioning_config or derive from plan name.
*
* @param array<string, mixed> $config
* @return array<string, mixed>
*/
private function resolveSpecs(Plan $plan, array $config): array
{
$defaults = config('serverhunter.defaults', []);
$inferredCores = $this->inferCoresFromSlug($plan->slug);
$cpuCores = $config['cpu_cores'] ?? $inferredCores;
$diskGb = $config['disk_gb'] ?? $this->inferDiskFromSlug($plan->slug, $cpuCores);
$memoryMb = $config['memory_mb'] ?? ($cpuCores * 1024);
$diskType = $config['disk_type'] ?? $defaults['disk_type'] ?? 'nvme';
$isNvmeOrSsd = in_array($diskType, ['nvme', 'ssd'], true);
return [
'gpu_name' => $config['gpu_name'] ?? null,
'cpu_type' => $config['cpu_type'] ?? $defaults['cpu_type'] ?? 'amd',
'cpu_name' => $config['cpu_name'] ?? $defaults['cpu_name'] ?? 'EPYC',
'cpu_speed' => $config['cpu_speed'] ?? $defaults['cpu_speed'] ?? '3.70',
'cpu_amount' => $config['cpu_amount'] ?? 1,
'cpu_cores' => $cpuCores,
'memory_mb' => $memoryMb,
'memory_type' => $config['memory_type'] ?? $defaults['memory_type'] ?? 'ddr4',
'memory_ecc' => $config['memory_ecc'] ?? $defaults['memory_ecc'] ?? 'ecc',
'hdd_amount' => $isNvmeOrSsd ? 0 : ($config['hdd_amount'] ?? 1),
'hdd_capacity' => $isNvmeOrSsd ? 0 : ($config['hdd_capacity'] ?? $diskGb),
'ssd_amount' => $isNvmeOrSsd ? ($config['ssd_amount'] ?? 1) : 0,
'ssd_capacity' => $isNvmeOrSsd ? $diskGb : 0,
'uplink_mbps' => $config['uplink_mbps'] ?? $defaults['uplink_mbps'] ?? 1000,
'bandwidth_tb' => $config['bandwidth_tb'] ?? 0,
];
}
/**
* Try to infer the number of CPU cores from the plan slug.
* e.g., "vps-1" => 1, "vps-4" => 4. Storage plans default to 2 cores.
*/
private function inferCoresFromSlug(string $slug): int
{
// Storage plans are not CPU-based — use sensible defaults
if (str_starts_with($slug, 'stor-')) {
return 2;
}
if (preg_match('/^vps-(\d+)$/', $slug, $matches)) {
return max(1, (int) $matches[1]);
}
return 1;
}
/**
* Infer disk size from slug. Storage plans use their capacity, VPS uses cores * 25.
*/
private function inferDiskFromSlug(string $slug, int $cores): int
{
return match (true) {
$slug === 'stor-500' => 500,
$slug === 'stor-1tb' => 1000,
str_starts_with($slug, 'stor-36bay') => 4000,
default => $cores * 25,
};
}
/**
* Resolve stock status from plan attributes.
*/
private function resolveStock(Plan $plan): string
{
if ($plan->stock_quantity === null) {
return 'in_stock';
}
if ($plan->stock_quantity <= 0) {
return 'out_of_stock';
}
if ($plan->stock_quantity <= 5) {
return 'limited';
}
return 'in_stock';
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Services\Support;
use App\Models\SlaBusinessHours;
use App\Models\SlaPolicy;
use App\Models\SupportTicket;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class SlaService
{
/**
* Assign an SLA policy to a ticket based on its department and priority.
*/
public function assignSla(SupportTicket $ticket): void
{
$department = $ticket->departmentRelation;
if (! $department?->sla_policy_id) {
return;
}
$policy = SlaPolicy::query()->find($department->sla_policy_id);
if (! $policy) {
return;
}
// Find a policy matching the ticket priority, or fall back to the department's default
$priorityPolicy = SlaPolicy::query()
->where('priority', $ticket->priority)
->where('id', $policy->id)
->first();
$effectivePolicy = $priorityPolicy ?? $policy;
$now = Carbon::now();
$ticket->update([
'sla_policy_id' => $effectivePolicy->id,
'first_response_due_at' => $this->calculateDueTime(
$now,
$effectivePolicy->first_response_hours,
$effectivePolicy->business_hours_only
),
'resolution_due_at' => $this->calculateDueTime(
$now,
$effectivePolicy->resolution_hours,
$effectivePolicy->business_hours_only
),
]);
}
/**
* Calculate the due time accounting for business hours if required.
*/
public function calculateDueTime(Carbon $from, int $hours, bool $businessHoursOnly): Carbon
{
if (! $businessHoursOnly) {
return $from->copy()->addHours($hours);
}
$businessHoursConfig = SlaBusinessHours::query()
->where('is_holiday', false)
->orderBy('day_of_week')
->get();
if ($businessHoursConfig->isEmpty()) {
// No business hours configured, fall back to calendar hours
return $from->copy()->addHours($hours);
}
$remainingMinutes = $hours * 60;
$current = $from->copy();
$maxIterations = $hours * 5; // Safety valve
$iteration = 0;
while ($remainingMinutes > 0 && $iteration < $maxIterations) {
$iteration++;
$dayOfWeek = (int) $current->dayOfWeek;
$dayHours = $businessHoursConfig->where('day_of_week', $dayOfWeek)->first();
if (! $dayHours) {
// Not a business day, skip to next day
$current->addDay()->startOfDay();
continue;
}
$startTime = Carbon::parse($dayHours->start_time, $current->timezone)->setDate(
$current->year,
$current->month,
$current->day
);
$endTime = Carbon::parse($dayHours->end_time, $current->timezone)->setDate(
$current->year,
$current->month,
$current->day
);
if ($current->lt($startTime)) {
$current = $startTime->copy();
}
if ($current->gte($endTime)) {
$current->addDay()->startOfDay();
continue;
}
$availableMinutes = (int) $current->diffInMinutes($endTime);
if ($availableMinutes >= $remainingMinutes) {
$current->addMinutes($remainingMinutes);
$remainingMinutes = 0;
} else {
$remainingMinutes -= $availableMinutes;
$current->addDay()->startOfDay();
}
}
return $current;
}
/**
* Check all open tickets for SLA breaches.
*/
public function checkBreaches(): int
{
$now = Carbon::now();
$breachCount = 0;
// Check first response breaches
$firstResponseBreaches = SupportTicket::query()
->whereNotNull('first_response_due_at')
->whereNull('first_responded_at')
->where('first_response_due_at', '<', $now)
->where('sla_first_response_breached', false)
->whereIn('status', ['open', 'in_progress', 'waiting'])
->get();
foreach ($firstResponseBreaches as $ticket) {
$ticket->update(['sla_first_response_breached' => true]);
Log::warning('SLA first response breached', [
'ticket_id' => $ticket->id,
'ticket_reference' => $ticket->ticket_reference,
'due_at' => $ticket->first_response_due_at->toDateTimeString(),
]);
$breachCount++;
}
// Check resolution breaches
$resolutionBreaches = SupportTicket::query()
->whereNotNull('resolution_due_at')
->whereNull('resolved_at')
->where('resolution_due_at', '<', $now)
->where('sla_resolution_breached', false)
->whereIn('status', ['open', 'in_progress', 'waiting'])
->get();
foreach ($resolutionBreaches as $ticket) {
$ticket->update(['sla_resolution_breached' => true]);
Log::warning('SLA resolution breached', [
'ticket_id' => $ticket->id,
'ticket_reference' => $ticket->ticket_reference,
'due_at' => $ticket->resolution_due_at->toDateTimeString(),
]);
$breachCount++;
}
return $breachCount;
}
/**
* Record the first staff response on a ticket.
*/
public function recordFirstResponse(SupportTicket $ticket): void
{
if ($ticket->first_responded_at !== null) {
return;
}
$ticket->update([
'first_responded_at' => Carbon::now(),
]);
}
}

View File

@@ -13,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
then: function (): void { then: function (): void {
Route::domain(config('app.domains.marketing')) Route::domain(config('app.domains.marketing'))
->middleware('web') ->middleware(['web', 'track_affiliate'])
->group(base_path('routes/marketing.php')); ->group(base_path('routes/marketing.php'));
Route::domain(config('app.domains.account')) Route::domain(config('app.domains.account'))
@@ -21,7 +21,7 @@ return Application::configure(basePath: dirname(__DIR__))
->group(base_path('routes/account.php')); ->group(base_path('routes/account.php'));
Route::domain(config('app.domains.admin')) Route::domain(config('app.domains.admin'))
->middleware(['web', 'auth', 'verified', 'role:admin']) ->middleware(['web', 'auth', 'verified', 'role:admin|super_admin|billing_admin|support_agent|support_lead|readonly_admin'])
->group(base_path('routes/admin.php')); ->group(base_path('routes/admin.php'));
Route::domain(config('app.domains.account')) Route::domain(config('app.domains.account'))
@@ -59,6 +59,8 @@ return Application::configure(basePath: dirname(__DIR__))
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'ensure_not_suspended' => \App\Http\Middleware\EnsureUserNotSuspended::class, 'ensure_not_suspended' => \App\Http\Middleware\EnsureUserNotSuspended::class,
'serverhunter' => \App\Http\Middleware\AllowServerHunterSpider::class,
'track_affiliate' => \App\Http\Middleware\TrackAffiliateReferral::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
return [
'default_commission_type' => env('AFFILIATE_COMMISSION_TYPE', 'percentage'),
'default_commission_rate' => (float) env('AFFILIATE_COMMISSION_RATE', 10.00),
'default_recurring_commissions' => (bool) env('AFFILIATE_RECURRING_COMMISSIONS', false),
'default_minimum_payout' => (float) env('AFFILIATE_MINIMUM_PAYOUT', 50.00),
'cookie_lifetime_days' => (int) env('AFFILIATE_COOKIE_DAYS', 30),
'auto_approve' => (bool) env('AFFILIATE_AUTO_APPROVE', false),
];

View File

@@ -57,6 +57,8 @@ return [
'suspension' => [ 'suspension' => [
'days_past_due_to_suspend' => (int) env('SUSPENSION_DAYS_PAST_DUE', 7), 'days_past_due_to_suspend' => (int) env('SUSPENSION_DAYS_PAST_DUE', 7),
'days_suspended_to_terminate' => (int) env('SUSPENSION_DAYS_TO_TERMINATE', 30), 'days_suspended_to_terminate' => (int) env('SUSPENSION_DAYS_TO_TERMINATE', 30),
'warning_days_before_suspend' => (int) env('SUSPENSION_WARNING_DAYS', 1),
'warning_days_before_terminate' => (int) env('SUSPENSION_TERMINATE_WARNING_DAYS', 7),
], ],
/* /*

28
website/config/fraud.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
return [
'enabled' => env('FRAUD_DETECTION_ENABLED', true),
'thresholds' => [
'auto_approve_below' => (int) env('FRAUD_AUTO_APPROVE_BELOW', 30),
'auto_hold_above' => (int) env('FRAUD_AUTO_HOLD_ABOVE', 60),
'auto_reject_above' => (int) env('FRAUD_AUTO_REJECT_ABOVE', 85),
],
'disposable_email_domains' => [
'mailinator.com',
'guerrillamail.com',
'tempmail.com',
'throwaway.email',
'yopmail.com',
'sharklasers.com',
'guerrillamailblock.com',
'grr.la',
'dispostable.com',
'trashmail.com',
'temp-mail.org',
'10minutemail.com',
],
];

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
return [
'api_key' => env('SERVERHUNTER_API_KEY'),
'api_url' => 'https://api.serverhunter.com',
'location' => 'Atlanta, Georgia, United States of America',
'country_code' => 'US',
'coordinates' => '33.7490,-84.3880',
'currency' => 'USD',
'payment_methods' => ['creditcard', 'paypal'],
'spider_ips_url' => 'https://www.serverhunter.com/spider/ips/',
'spider_ips_cache_ttl' => 86400, // 24 hours
'vps' => [
'virtualization' => 'kvm',
'features' => ['ddos', 'ipv6', 'instant_setup', 'api'],
'operating_systems' => ['ubuntu', 'debian', 'centos', 'fedora', 'windows', 'custom'],
'unmetered' => ['inbound', 'outbound'],
'setup_fee' => '0.00',
],
'dedicated' => [
'virtualization' => 'none',
'features' => ['ddos', 'ipv6', 'api', 'hwraid', 'kvm'],
'operating_systems' => ['ubuntu', 'debian', 'centos', 'fedora', 'windows', 'custom', 'proxmox', 'vmware'],
'unmetered' => ['outbound'],
'setup_fee' => '0.00',
],
'defaults' => [
'cpu_type' => 'intel',
'cpu_name' => 'Xeon',
'cpu_speed' => '2.40',
'memory_type' => 'ddr4',
'memory_ecc' => 'ecc',
'disk_type' => 'ssd',
'uplink_mbps' => 1000,
],
];

Some files were not shown because too many files have changed in this diff Show More