Commit 58845233 authored by Mahmoud Aglan's avatar Mahmoud Aglan

El Captain Manager — SaaS control plane for managing El Captain instances

Full platform for operating El Captain as a service:
- Deploy new academies via one-click wizard (CapRover API integration)
- Flexible billing: monthly fee, platform %, or both per client
- Daily fee collection from instance databases (pull + webhook verify)
- Auto-suspend instances after grace period on overdue payments
- Dashboard with MRR, platform fees, instance health overview
- Complete audit log of all actions

Stack: Laravel + Livewire + Tailwind, deployed to CapRover
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 30f0e309
APP_NAME=Laravel
APP_ENV=local
APP_NAME="El Captain Manager"
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_DEBUG=false
APP_URL=https://manager.caprover.al-arcade.com
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_LEVEL=error
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=pgsql
DB_HOST=srv-captain--manager-db
DB_PORT=5432
DB_DATABASE=el_captain_manager
DB_USERNAME=manager
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# CapRover API (runs on same server)
CAPROVER_API_URL=http://captain-captain:3000
CAPROVER_PASSWORD=
CAPROVER_ROOT_DOMAIN=caprover.al-arcade.com
CAPROVER_SERVER_IP=18.192.166.221
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# El Captain Git Repo (source for deploying new instances)
ELCAPTAIN_GIT_REPO=https://gitlab.caprover.al-arcade.com/root/el-captain-sports-management.git
ELCAPTAIN_GIT_USER=root
ELCAPTAIN_GIT_PASSWORD=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# Billing enforcement
AUTO_SUSPEND_ENABLED=true
VITE_APP_NAME="${APP_NAME}"
TRUSTED_PROXIES=*
# El Captain Manager — Production Dockerfile
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev
# Production image
FROM php:8.4-fpm-alpine
RUN apk add --no-cache \
nginx \
supervisor \
postgresql-dev \
libzip-dev \
icu-dev \
oniguruma-dev \
curl \
&& docker-php-ext-install -j$(nproc) \
pdo_pgsql \
pgsql \
zip \
intl \
mbstring \
opcache \
pcntl \
&& rm -rf /var/cache/apk/*
COPY docker/php/php.ini /usr/local/etc/php/conf.d/99-app.ini
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
COPY docker/supervisor/supervisord.conf /etc/supervisord.conf
WORKDIR /var/www/html
COPY --from=vendor /app/vendor ./vendor
COPY . .
RUN mkdir -p storage/framework/{cache/data,sessions,views} storage/logs bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
<?php
namespace App\Console\Commands;
use App\Models\BillingPeriod;
use App\Models\Instance;
use App\Services\InstanceProvisionerService;
use Illuminate\Console\Command;
class EnforceBilling extends Command
{
protected $signature = 'manager:enforce-billing';
protected $description = 'Mark overdue billing periods and auto-suspend after grace period';
public function handle(InstanceProvisionerService $provisioner): int
{
$today = now()->toDateString();
// Mark overdue periods
$overdue = BillingPeriod::where('status', 'invoiced')
->where('due_date', '<', $today)
->where('balance_due', '>', 0)
->get();
foreach ($overdue as $period) {
$period->update([
'status' => 'overdue',
'overdue_days' => now()->diffInDays($period->due_date),
]);
}
$this->info("{$overdue->count()} period(s) marked overdue.");
if (!config('manager.auto_suspend_enabled')) {
$this->info('Auto-suspend disabled. Skipping enforcement.');
return self::SUCCESS;
}
// Auto-suspend instances past grace period
$toSuspend = BillingPeriod::where('status', 'overdue')
->with('instance.plan')
->get()
->filter(function ($period) {
$graceDays = $period->instance->plan->grace_days ?? 7;
return $period->overdue_days >= $graceDays
&& $period->instance->isActive();
});
foreach ($toSuspend as $period) {
$provisioner->suspend(
$period->instance,
"Auto-suspended: billing overdue by {$period->overdue_days} days (grace: {$period->instance->plan->grace_days})"
);
$this->warn("Suspended: {$period->instance->app_name}");
}
$this->info("{$toSuspend->count()} instance(s) suspended.");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Models\BillingPeriod;
use App\Models\Instance;
use App\Models\PlatformFee;
use Illuminate\Console\Command;
class GenerateBillingPeriods extends Command
{
protected $signature = 'manager:generate-billing';
protected $description = 'Generate monthly billing periods for all active instances';
public function handle(): int
{
$periodStart = now()->startOfMonth();
$periodEnd = now()->endOfMonth();
$dueDate = $periodStart->copy()->addDays(7);
$instances = Instance::whereIn('status', ['active', 'trial'])
->with('plan')
->get();
$created = 0;
foreach ($instances as $instance) {
$exists = BillingPeriod::where('instance_id', $instance->id)
->where('period_start', $periodStart)
->exists();
if ($exists) {
continue;
}
// Calculate platform fees for previous month
$prevMonthStart = now()->subMonth()->startOfMonth();
$prevMonthEnd = now()->subMonth()->endOfMonth();
$platformFees = PlatformFee::where('instance_id', $instance->id)
->where('source', 'pull')
->whereBetween('date', [$prevMonthStart, $prevMonthEnd])
->sum('fee_amount');
$monthlyFee = $instance->monthly_fee ?: $instance->plan->monthly_fee;
$totalDue = $monthlyFee + $platformFees;
BillingPeriod::create([
'instance_id' => $instance->id,
'period_start' => $periodStart,
'period_end' => $periodEnd,
'monthly_fee_amount' => $monthlyFee,
'platform_fee_amount' => $platformFees,
'total_due' => $totalDue,
'total_paid' => 0,
'balance_due' => $totalDue,
'status' => $totalDue > 0 ? 'invoiced' : 'paid',
'due_date' => $dueDate,
]);
$created++;
}
$this->info("{$created} billing period(s) created for {$periodStart->format('F Y')}.");
return self::SUCCESS;
}
}
<?php
namespace App\Console\Commands;
use App\Services\FeeCollectorService;
use Illuminate\Console\Command;
class PullPlatformFees extends Command
{
protected $signature = 'manager:pull-fees {--date= : Date to pull (default: yesterday)}';
protected $description = 'Pull platform fees from all active instances';
public function handle(FeeCollectorService $collector): int
{
$date = $this->option('date') ?? now()->subDay()->toDateString();
$this->info("Pulling fees for {$date}...");
$results = $collector->pullFeesForAllInstances($date);
$this->info(count($results) . ' instance(s) processed.');
$total = collect($results)->sum('fee_amount');
$this->info('Total platform fees: ' . number_format($total / 100, 2) . ' EGP');
return self::SUCCESS;
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Instance;
use App\Services\FeeCollectorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class FeeWebhookController extends Controller
{
public function store(Request $request, FeeCollectorService $collector): JsonResponse
{
$request->validate([
'app_name' => 'required|string',
'token' => 'required|string',
'date' => 'required|date',
'total_amount' => 'required|integer|min:0',
'fee_amount' => 'required|integer|min:0',
'transaction_count' => 'required|integer|min:0',
]);
$instance = Instance::where('app_name', $request->app_name)->first();
if (!$instance) {
return response()->json(['error' => 'Instance not found'], 404);
}
// Simple token verification (app_name + secret hash)
$expectedToken = hash('sha256', $instance->app_name . config('app.key'));
if (!hash_equals($expectedToken, $request->token)) {
return response()->json(['error' => 'Invalid token'], 403);
}
$collector->recordWebhookReport(
$instance,
$request->date,
$request->total_amount,
$request->fee_amount,
$request->transaction_count,
);
return response()->json(['status' => 'ok']);
}
}
<?php
namespace App\Livewire;
use App\Models\AuditLog;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class AuditLogList extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $action = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedAction(): void
{
$this->resetPage();
}
public function render()
{
$query = AuditLog::with(['user', 'instance']);
if ($this->search) {
$query->where(function ($q) {
$q->whereHas('user', fn ($u) => $u->where('name', 'ilike', "%{$this->search}%"))
->orWhereHas('instance', fn ($i) => $i->where('academy_name_ar', 'ilike', "%{$this->search}%")
->orWhere('academy_name_en', 'ilike', "%{$this->search}%"));
});
}
if ($this->action) {
$query->where('action', $this->action);
}
return view('livewire.audit-log-list', [
'logs' => $query->orderByDesc('created_at')->paginate(30),
])->layout('layouts.app', ['title' => 'Audit Log']);
}
}
<?php
namespace App\Livewire;
use App\Models\BillingPeriod;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class BillingOverview extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function render()
{
$query = BillingPeriod::with('instance');
if ($this->search) {
$query->whereHas('instance', function ($q) {
$q->where('academy_name_ar', 'ilike', "%{$this->search}%")
->orWhere('academy_name_en', 'ilike', "%{$this->search}%");
});
}
if ($this->status) {
$query->where('status', $this->status);
}
return view('livewire.billing-overview', [
'periods' => $query->orderByDesc('period_start')->paginate(20),
])->layout('layouts.app', ['title' => 'Billing']);
}
}
<?php
namespace App\Livewire;
use App\Models\Client;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class ClientList extends Component
{
use WithPagination;
#[Url]
public string $search = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function render()
{
$query = Client::withCount('instances');
if ($this->search) {
$query->where(function ($q) {
$q->where('name', 'ilike', "%{$this->search}%")
->orWhere('email', 'ilike', "%{$this->search}%")
->orWhere('company', 'ilike', "%{$this->search}%");
});
}
return view('livewire.client-list', [
'clients' => $query->orderByDesc('created_at')->paginate(15),
])->layout('layouts.app', ['title' => 'Clients']);
}
}
<?php
namespace App\Livewire;
use App\Models\BillingPeriod;
use App\Models\Instance;
use App\Models\PlatformFee;
use Livewire\Component;
class Dashboard extends Component
{
public function render()
{
$totalInstances = Instance::where('status', '!=', 'deleted')->count();
$activeInstances = Instance::where('status', 'active')->count();
$trialInstances = Instance::where('status', 'trial')->count();
$suspendedInstances = Instance::where('status', 'suspended')->count();
$currentMonth = now()->startOfMonth();
$monthlyFeesDue = BillingPeriod::where('period_start', '>=', $currentMonth)
->sum('total_due');
$monthlyFeesPaid = BillingPeriod::where('period_start', '>=', $currentMonth)
->sum('total_paid');
$platformFeesThisMonth = PlatformFee::where('date', '>=', $currentMonth)
->where('source', 'pull')
->sum('fee_amount');
$overdue = BillingPeriod::where('status', 'overdue')->count();
$recentInstances = Instance::with('client', 'plan')
->where('status', '!=', 'deleted')
->orderByDesc('created_at')
->limit(5)
->get();
return view('livewire.dashboard', [
'totalInstances' => $totalInstances,
'activeInstances' => $activeInstances,
'trialInstances' => $trialInstances,
'suspendedInstances' => $suspendedInstances,
'monthlyFeesDue' => $monthlyFeesDue,
'monthlyFeesPaid' => $monthlyFeesPaid,
'platformFeesThisMonth' => $platformFeesThisMonth,
'overdue' => $overdue,
'recentInstances' => $recentInstances,
])->layout('layouts.app', ['title' => 'Dashboard']);
}
}
<?php
namespace App\Livewire;
use App\Models\Client;
use App\Models\Instance;
use App\Models\Plan;
use App\Services\InstanceProvisionerService;
use Livewire\Component;
class DeployInstanceWizard extends Component
{
public int $step = 1;
public int $totalSteps = 4;
// Step 1: Client
public ?int $client_id = null;
public bool $newClient = false;
public string $client_name = '';
public string $client_email = '';
public string $client_phone = '';
public string $client_company = '';
// Step 2: Plan
public ?int $plan_id = null;
public string $custom_monthly_fee = '';
public string $custom_platform_fee = '';
// Step 3: Academy Details
public string $academy_name_ar = '';
public string $academy_name_en = '';
public string $admin_email = '';
public string $admin_password = '';
// Step 4: Review + Deploy
public bool $deploying = false;
public bool $deployed = false;
public ?string $deployError = null;
public ?string $instanceUrl = null;
public function nextStep(): void
{
$this->validate($this->rulesForStep($this->step));
$this->step = min($this->step + 1, $this->totalSteps);
}
public function previousStep(): void
{
$this->step = max($this->step - 1, 1);
}
public function deploy(InstanceProvisionerService $provisioner): void
{
$this->deploying = true;
$this->deployError = null;
try {
$client = $this->resolveClient();
$plan = Plan::findOrFail($this->plan_id);
$instance = $provisioner->provision($client, $plan, [
'academy_name_ar' => $this->academy_name_ar,
'academy_name_en' => $this->academy_name_en,
'admin_email' => $this->admin_email,
'admin_password' => $this->admin_password,
'platform_fee_percent' => $this->custom_platform_fee !== '' ? (float) $this->custom_platform_fee : null,
'monthly_fee' => $this->custom_monthly_fee !== '' ? (int) round((float) $this->custom_monthly_fee * 100) : null,
]);
$this->deployed = true;
$this->instanceUrl = $instance->url;
} catch (\Throwable $e) {
$this->deployError = $e->getMessage();
} finally {
$this->deploying = false;
}
}
private function resolveClient(): Client
{
if ($this->client_id && !$this->newClient) {
return Client::findOrFail($this->client_id);
}
return Client::create([
'name' => $this->client_name,
'email' => $this->client_email,
'phone' => $this->client_phone ?: null,
'company' => $this->client_company ?: null,
'status' => 'active',
]);
}
private function rulesForStep(int $step): array
{
return match ($step) {
1 => $this->newClient ? [
'client_name' => 'required|string|max:200',
'client_email' => 'required|email|unique:clients,email',
] : [
'client_id' => 'required|exists:clients,id',
],
2 => [
'plan_id' => 'required|exists:plans,id',
],
3 => [
'academy_name_ar' => 'required|string|max:200',
'academy_name_en' => 'required|string|max:200',
'admin_email' => 'required|email',
'admin_password' => 'required|string|min:8',
],
default => [],
};
}
public function render()
{
return view('livewire.deploy-instance-wizard', [
'clients' => Client::where('status', 'active')->orderBy('name')->get(),
'plans' => Plan::where('is_active', true)->orderBy('monthly_fee')->get(),
])->layout('layouts.app', ['title' => 'Deploy New Instance']);
}
}
<?php
namespace App\Livewire;
use App\Models\Instance;
use App\Services\InstanceProvisionerService;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class InstanceList extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function suspend(int $id): void
{
$instance = Instance::findOrFail($id);
app(InstanceProvisionerService::class)->suspend($instance, 'Manual suspension from dashboard');
}
public function resume(int $id): void
{
$instance = Instance::findOrFail($id);
app(InstanceProvisionerService::class)->resume($instance);
}
public function render()
{
$query = Instance::with('client', 'plan')
->where('status', '!=', 'deleted');
if ($this->search) {
$query->where(function ($q) {
$q->where('academy_name_ar', 'ilike', "%{$this->search}%")
->orWhere('academy_name_en', 'ilike', "%{$this->search}%")
->orWhere('app_name', 'ilike', "%{$this->search}%")
->orWhereHas('client', fn ($c) => $c->where('name', 'ilike', "%{$this->search}%"));
});
}
if ($this->status) {
$query->where('status', $this->status);
}
return view('livewire.instance-list', [
'instances' => $query->orderByDesc('created_at')->paginate(15),
])->layout('layouts.app', ['title' => 'Instances']);
}
}
<?php
namespace App\Livewire;
use App\Models\Instance;
use App\Services\InstanceProvisionerService;
use Livewire\Component;
class InstanceShow extends Component
{
public Instance $instance;
public function mount(Instance $instance): void
{
$this->instance = $instance;
}
public function suspend(): void
{
app(InstanceProvisionerService::class)->suspend($this->instance, 'Manual suspension from dashboard');
$this->instance->refresh();
}
public function resume(): void
{
app(InstanceProvisionerService::class)->resume($this->instance);
$this->instance->refresh();
}
public function delete(): void
{
app(InstanceProvisionerService::class)->delete($this->instance);
$this->redirect(route('instances.index'), navigate: true);
}
public function render()
{
$this->instance->load(['client', 'plan', 'billingPeriods' => function ($q) {
$q->orderByDesc('period_start')->limit(10);
}, 'platformFees' => function ($q) {
$q->orderByDesc('created_at')->limit(10);
}]);
return view('livewire.instance-show')
->layout('layouts.app', ['title' => $this->instance->academy_name_en ?? $this->instance->app_name]);
}
}
<?php
namespace App\Livewire;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class Login extends Component
{
public string $email = '';
public string $password = '';
public bool $remember = false;
public ?string $error = null;
public function login(): void
{
$this->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
session()->regenerate();
$this->redirect(route('dashboard'));
return;
}
$this->error = 'Invalid credentials.';
}
public function render()
{
return view('livewire.login')->layout('layouts.guest');
}
}
<?php
namespace App\Livewire;
use App\Models\Plan;
use Livewire\Component;
use Livewire\WithPagination;
class PlanList extends Component
{
use WithPagination;
public bool $showForm = false;
public ?int $editingId = null;
public string $name = '';
public string $slug = '';
public string $monthly_fee = '';
public string $platform_fee_percent = '';
public string $trial_days = '14';
public string $grace_days = '3';
public string $max_participants = '';
public string $max_branches = '';
public bool $is_active = true;
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'slug' => 'required|string|max:100',
'monthly_fee' => 'required|numeric|min:0',
'platform_fee_percent' => 'required|numeric|min:0|max:100',
'trial_days' => 'required|integer|min:0',
'grace_days' => 'required|integer|min:0',
'max_participants' => 'nullable|integer|min:0',
'max_branches' => 'nullable|integer|min:0',
'is_active' => 'boolean',
];
}
public function create(): void
{
$this->resetForm();
$this->showForm = true;
}
public function edit(int $id): void
{
$plan = Plan::findOrFail($id);
$this->editingId = $plan->id;
$this->name = $plan->name;
$this->slug = $plan->slug;
$this->monthly_fee = (string) ($plan->monthly_fee / 100);
$this->platform_fee_percent = (string) $plan->platform_fee_percent;
$this->trial_days = (string) $plan->trial_days;
$this->grace_days = (string) $plan->grace_days;
$this->max_participants = (string) ($plan->max_participants ?? '');
$this->max_branches = (string) ($plan->max_branches ?? '');
$this->is_active = $plan->is_active;
$this->showForm = true;
}
public function save(): void
{
$validated = $this->validate();
$data = [
'name' => $validated['name'],
'slug' => $validated['slug'],
'monthly_fee' => (int) round((float) $validated['monthly_fee'] * 100),
'platform_fee_percent' => (float) $validated['platform_fee_percent'],
'trial_days' => (int) $validated['trial_days'],
'grace_days' => (int) $validated['grace_days'],
'max_participants' => $validated['max_participants'] ? (int) $validated['max_participants'] : null,
'max_branches' => $validated['max_branches'] ? (int) $validated['max_branches'] : null,
'is_active' => $validated['is_active'],
];
if ($this->editingId) {
Plan::findOrFail($this->editingId)->update($data);
session()->flash('success', 'Plan updated.');
} else {
Plan::create($data);
session()->flash('success', 'Plan created.');
}
$this->resetForm();
}
public function toggleActive(int $id): void
{
$plan = Plan::findOrFail($id);
$plan->update(['is_active' => !$plan->is_active]);
}
public function cancelForm(): void
{
$this->resetForm();
}
private function resetForm(): void
{
$this->showForm = false;
$this->editingId = null;
$this->name = '';
$this->slug = '';
$this->monthly_fee = '';
$this->platform_fee_percent = '';
$this->trial_days = '14';
$this->grace_days = '3';
$this->max_participants = '';
$this->max_branches = '';
$this->is_active = true;
}
public function render()
{
return view('livewire.plan-list', [
'plans' => Plan::orderBy('monthly_fee')->paginate(20),
])->layout('layouts.app', ['title' => 'Plans']);
}
}
<?php
namespace App\Livewire;
use App\Models\Client;
use App\Models\Instance;
use App\Models\PaymentReceived;
use Livewire\Component;
class RecordPayment extends Component
{
public $client_id = '';
public $instance_id = '';
public $amount = '';
public string $method = '';
public string $reference = '';
public string $notes = '';
public string $paid_at = '';
public function mount(): void
{
$this->paid_at = now()->format('Y-m-d');
}
public function updatedClientId(): void
{
$this->instance_id = '';
}
public function rules(): array
{
return [
'client_id' => 'required|exists:clients,id',
'instance_id' => 'required|exists:instances,id',
'amount' => 'required|numeric|min:0.01',
'method' => 'required|in:cash,bank_transfer,vodafone_cash,instapay,other',
'reference' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:1000',
'paid_at' => 'required|date',
];
}
public function save(): void
{
$validated = $this->validate();
PaymentReceived::create([
'client_id' => $validated['client_id'],
'instance_id' => $validated['instance_id'],
'amount' => (int) round((float) $validated['amount'] * 100),
'method' => $validated['method'],
'reference' => $validated['reference'],
'notes' => $validated['notes'],
'paid_at' => $validated['paid_at'],
'recorded_by' => auth()->id(),
]);
session()->flash('success', 'Payment recorded successfully.');
$this->redirect(route('billing.index'), navigate: true);
}
public function render()
{
$clients = Client::orderBy('name')->get();
$instances = $this->client_id
? Instance::where('client_id', $this->client_id)->where('status', '!=', 'deleted')->get()
: collect();
return view('livewire.record-payment', [
'clients' => $clients,
'instances' => $instances,
])->layout('layouts.app', ['title' => 'Record Payment']);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
public $timestamps = false;
protected $fillable = [
'user_id',
'instance_id',
'action',
'details',
'ip_address',
'created_at',
];
protected $casts = [
'details' => 'array',
'created_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
public static function record(string $action, ?Instance $instance = null, array $details = []): static
{
return static::create([
'user_id' => auth()->id(),
'instance_id' => $instance?->id,
'action' => $action,
'details' => $details,
'ip_address' => request()->ip(),
'created_at' => now(),
]);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BillingPeriod extends Model
{
protected $fillable = [
'instance_id',
'period_start',
'period_end',
'monthly_fee_amount',
'platform_fee_amount',
'total_due',
'total_paid',
'balance_due',
'status',
'due_date',
'overdue_days',
];
protected $casts = [
'period_start' => 'date',
'period_end' => 'date',
'due_date' => 'date',
'monthly_fee_amount' => 'integer',
'platform_fee_amount' => 'integer',
'total_due' => 'integer',
'total_paid' => 'integer',
'balance_due' => 'integer',
'overdue_days' => 'integer',
];
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
public function payments(): HasMany
{
return $this->hasMany(PaymentReceived::class);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class Client extends Model
{
protected $fillable = [
'uuid',
'name',
'contact_name',
'email',
'phone',
'company',
'notes',
'status',
];
protected static function booted(): void
{
static::creating(function (Client $client) {
$client->uuid ??= Str::uuid()->toString();
});
}
public function instances(): HasMany
{
return $this->hasMany(Instance::class);
}
public function payments(): HasMany
{
return $this->hasMany(PaymentReceived::class);
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class Instance extends Model
{
protected $fillable = [
'uuid',
'client_id',
'plan_id',
'app_name',
'academy_name_ar',
'academy_name_en',
'domain',
'admin_email',
'status',
'db_app_name',
'db_name',
'db_user',
'db_password_encrypted',
'platform_fee_percent',
'monthly_fee',
'trial_ends_at',
'suspended_at',
'suspension_reason',
'caprover_deploy_log',
];
protected $casts = [
'platform_fee_percent' => 'decimal:2',
'monthly_fee' => 'integer',
'trial_ends_at' => 'date',
'suspended_at' => 'date',
];
protected static function booted(): void
{
static::creating(function (Instance $instance) {
$instance->uuid ??= Str::uuid()->toString();
});
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
public function billingPeriods(): HasMany
{
return $this->hasMany(BillingPeriod::class);
}
public function platformFees(): HasMany
{
return $this->hasMany(PlatformFee::class);
}
public function auditLogs(): HasMany
{
return $this->hasMany(AuditLog::class);
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function getUrlAttribute(): string
{
if ($this->domain) {
return "https://{$this->domain}";
}
return "https://{$this->app_name}." . config('manager.caprover_root_domain');
}
public function isActive(): bool
{
return $this->status === 'active';
}
public function isTrial(): bool
{
return $this->status === 'trial' && $this->trial_ends_at?->isFuture();
}
public function isSuspended(): bool
{
return $this->status === 'suspended';
}
public function isTrialExpired(): bool
{
return $this->status === 'trial' && $this->trial_ends_at?->isPast();
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class PaymentReceived extends Model
{
protected $table = 'payments_received';
protected $fillable = [
'uuid',
'client_id',
'instance_id',
'billing_period_id',
'amount',
'method',
'reference',
'notes',
'paid_at',
'recorded_by',
];
protected $casts = [
'amount' => 'integer',
'paid_at' => 'date',
];
protected static function booted(): void
{
static::creating(function (PaymentReceived $payment) {
$payment->uuid ??= Str::uuid()->toString();
});
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
public function billingPeriod(): BelongsTo
{
return $this->belongsTo(BillingPeriod::class);
}
public function recorder(): BelongsTo
{
return $this->belongsTo(User::class, 'recorded_by');
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Plan extends Model
{
protected $fillable = [
'name',
'slug',
'monthly_fee',
'platform_fee_percent',
'trial_days',
'grace_days',
'max_participants',
'max_branches',
'features',
'is_active',
];
protected $casts = [
'monthly_fee' => 'integer',
'platform_fee_percent' => 'decimal:2',
'trial_days' => 'integer',
'grace_days' => 'integer',
'max_participants' => 'integer',
'max_branches' => 'integer',
'features' => 'array',
'is_active' => 'boolean',
];
public function instances(): HasMany
{
return $this->hasMany(Instance::class);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlatformFee extends Model
{
protected $fillable = [
'instance_id',
'date',
'total_transactions_amount',
'fee_amount',
'source',
'transaction_count',
'verified',
];
protected $casts = [
'date' => 'date',
'total_transactions_amount' => 'integer',
'fee_amount' => 'integer',
'transaction_count' => 'integer',
'verified' => 'boolean',
];
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class CapRoverService
{
private string $baseUrl;
private ?string $token = null;
public function __construct()
{
$this->baseUrl = config('manager.caprover_api_url');
}
private function authenticate(): string
{
if ($this->token) {
return $this->token;
}
$response = Http::post("{$this->baseUrl}/api/v2/login", [
'password' => config('manager.caprover_password'),
])->json();
if (($response['status'] ?? 0) !== 100) {
throw new RuntimeException('CapRover authentication failed: ' . ($response['description'] ?? 'unknown'));
}
$this->token = $response['data']['token'];
return $this->token;
}
private function api(string $method, string $endpoint, array $data = []): array
{
$token = $this->authenticate();
$response = Http::withHeaders([
'x-namespace' => 'captain',
'x-captain-auth' => $token,
])->{$method}("{$this->baseUrl}{$endpoint}", $data);
$result = $response->json();
if (($result['status'] ?? 0) !== 100) {
Log::error('CapRover API error', [
'endpoint' => $endpoint,
'response' => $result,
]);
throw new RuntimeException('CapRover API error: ' . ($result['description'] ?? 'unknown'));
}
return $result['data'] ?? [];
}
public function createApp(string $appName, bool $hasPersistentData = false): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/register', [
'appName' => $appName,
'hasPersistentData' => $hasPersistentData,
]);
}
public function deleteApp(string $appName): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/delete', [
'appName' => $appName,
]);
}
public function deployImage(string $appName, string $imageName): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
'instanceCount' => 1,
'captainDefinitionContent' => json_encode([
'schemaVersion' => 2,
'imageName' => $imageName,
]),
]);
}
public function setEnvVars(string $appName, array $envVars): array
{
$formatted = array_map(fn ($key, $value) => ['key' => $key, 'value' => (string) $value], array_keys($envVars), array_values($envVars));
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
'envVars' => $formatted,
]);
}
public function addVolume(string $appName, string $containerPath, string $volumeName): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
'volumes' => [
['containerPath' => $containerPath, 'volumeName' => $volumeName],
],
]);
}
public function enableSsl(string $appName): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/enablebasedomainssl', [
'appName' => $appName,
]);
}
public function forceSsl(string $appName): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
'forceSsl' => true,
]);
}
public function scaleApp(string $appName, int $instanceCount): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
'instanceCount' => $instanceCount,
]);
}
public function suspendApp(string $appName): array
{
return $this->scaleApp($appName, 0);
}
public function resumeApp(string $appName): array
{
return $this->scaleApp($appName, 1);
}
public function getAppInfo(string $appName): array
{
return $this->api('get', "/api/v2/user/apps/appDefinitions/{$appName}");
}
public function getAppLogs(string $appName, string $encoding = 'utf-8'): string
{
$token = $this->authenticate();
$response = Http::withHeaders([
'x-namespace' => 'captain',
'x-captain-auth' => $token,
])->post("{$this->baseUrl}/api/v2/user/apps/appDefinitions/logs", [
'appName' => $appName,
'encoding' => $encoding,
]);
$result = $response->json();
return $result['data']['logs'] ?? '';
}
public function listApps(): array
{
$data = $this->api('get', '/api/v2/user/apps/appDefinitions');
return $data['appDefinitions'] ?? [];
}
public function setContainerHttpPort(string $appName, int $port): array
{
return $this->api('post', '/api/v2/user/apps/appDefinitions/update', [
'appName' => $appName,
'containerHttpPort' => $port,
]);
}
public function deployFromGit(string $appName, string $repoUrl, string $branch = 'main', ?string $user = null, ?string $password = null): array
{
$gitData = [
'appName' => $appName,
'gitRepoUrl' => $repoUrl,
'branch' => $branch,
];
if ($user && $password) {
$gitData['gitUser'] = $user;
$gitData['gitPassword'] = $password;
}
return $this->api('post', '/api/v2/user/apps/appDefinitions/triggerbuild', $gitData);
}
}
<?php
namespace App\Services;
use App\Models\Instance;
use App\Models\PlatformFee;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class FeeCollectorService
{
public function pullFeesForDate(Instance $instance, string $date): ?PlatformFee
{
if (!$instance->isActive() && !$instance->isTrial()) {
return null;
}
try {
$dbPassword = Crypt::decryptString($instance->db_password_encrypted);
$rootDomain = config('manager.caprover_root_domain');
$config = [
'driver' => 'pgsql',
'host' => config('manager.caprover_server_ip'),
'port' => $this->resolveDbPort($instance),
'database' => $instance->db_name,
'username' => $instance->db_user,
'password' => $dbPassword,
];
config(["database.connections.instance_{$instance->id}" => $config]);
$result = DB::connection("instance_{$instance->id}")
->table('invoices')
->whereDate('created_at', $date)
->where('status', '!=', 'cancelled')
->selectRaw('COUNT(*) as count, COALESCE(SUM(total_amount), 0) as total, COALESCE(SUM(service_fee_amount), 0) as fees')
->first();
DB::disconnect("instance_{$instance->id}");
return PlatformFee::updateOrCreate(
['instance_id' => $instance->id, 'date' => $date, 'source' => 'pull'],
[
'total_transactions_amount' => (int) $result->total,
'fee_amount' => (int) $result->fees,
'transaction_count' => (int) $result->count,
'verified' => true,
]
);
} catch (\Throwable $e) {
Log::warning("Fee pull failed for instance {$instance->app_name}: {$e->getMessage()}");
return null;
}
}
public function pullFeesForAllInstances(string $date): array
{
$results = [];
$instances = Instance::whereIn('status', ['active', 'trial'])->get();
foreach ($instances as $instance) {
$fee = $this->pullFeesForDate($instance, $date);
if ($fee) {
$results[] = $fee;
}
}
return $results;
}
public function recordWebhookReport(Instance $instance, string $date, int $totalAmount, int $feeAmount, int $count): PlatformFee
{
return PlatformFee::updateOrCreate(
['instance_id' => $instance->id, 'date' => $date, 'source' => 'report'],
[
'total_transactions_amount' => $totalAmount,
'fee_amount' => $feeAmount,
'transaction_count' => $count,
'verified' => false,
]
);
}
public function verifyReport(Instance $instance, string $date): bool
{
$report = PlatformFee::where('instance_id', $instance->id)
->where('date', $date)
->where('source', 'report')
->first();
$pull = PlatformFee::where('instance_id', $instance->id)
->where('date', $date)
->where('source', 'pull')
->first();
if (!$report || !$pull) {
return false;
}
$tolerance = 100; // 1 EGP tolerance for rounding
$match = abs($report->fee_amount - $pull->fee_amount) <= $tolerance;
if ($match) {
$report->update(['verified' => true]);
} else {
Log::warning("Fee discrepancy for {$instance->app_name} on {$date}", [
'reported' => $report->fee_amount,
'actual' => $pull->fee_amount,
'diff' => abs($report->fee_amount - $pull->fee_amount),
]);
}
return $match;
}
private function resolveDbPort(Instance $instance): int
{
// CapRover DB containers expose PG on internal docker network port 5432
// We access via the server's mapped port or internal network
return 5432;
}
}
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\Client;
use App\Models\Instance;
use App\Models\Plan;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class InstanceProvisionerService
{
public function __construct(
private CapRoverService $caprover,
) {}
public function provision(Client $client, Plan $plan, array $data): Instance
{
$appName = $this->generateAppName($data['academy_name_en']);
$dbAppName = "{$appName}-db";
$dbName = str_replace('-', '_', $appName);
$dbUser = $dbName;
$dbPassword = Str::random(24);
$adminPassword = $data['admin_password'] ?? Str::random(12);
return DB::transaction(function () use ($client, $plan, $data, $appName, $dbAppName, $dbName, $dbUser, $dbPassword, $adminPassword) {
$instance = Instance::create([
'client_id' => $client->id,
'plan_id' => $plan->id,
'app_name' => $appName,
'academy_name_ar' => $data['academy_name_ar'],
'academy_name_en' => $data['academy_name_en'],
'admin_email' => $data['admin_email'],
'status' => 'provisioning',
'db_app_name' => $dbAppName,
'db_name' => $dbName,
'db_user' => $dbUser,
'db_password_encrypted' => Crypt::encryptString($dbPassword),
'platform_fee_percent' => $data['platform_fee_percent'] ?? $plan->platform_fee_percent,
'monthly_fee' => $data['monthly_fee'] ?? $plan->monthly_fee,
'trial_ends_at' => $plan->trial_days > 0 ? now()->addDays($plan->trial_days) : null,
]);
try {
$this->deployToCapRover($instance, $dbPassword, $adminPassword);
$instance->update(['status' => $plan->trial_days > 0 ? 'trial' : 'active']);
} catch (\Throwable $e) {
$instance->update([
'status' => 'provisioning',
'caprover_deploy_log' => $e->getMessage(),
]);
throw new RuntimeException("Provisioning failed: {$e->getMessage()}", 0, $e);
}
AuditLog::record('deployed', $instance, [
'plan' => $plan->slug,
'admin_email' => $data['admin_email'],
]);
return $instance;
});
}
private function deployToCapRover(Instance $instance, string $dbPassword, string $adminPassword): void
{
$rootDomain = config('manager.caprover_root_domain');
$gitRepoUrl = config('manager.elcaptain_git_repo');
$gitUser = config('manager.elcaptain_git_user');
$gitPassword = config('manager.elcaptain_git_password');
// 1. Create the PostgreSQL database app
$this->caprover->createApp($instance->db_app_name, true);
$this->caprover->deployImage($instance->db_app_name, 'postgres:16-alpine');
$this->caprover->setEnvVars($instance->db_app_name, [
'POSTGRES_DB' => $instance->db_name,
'POSTGRES_USER' => $instance->db_user,
'POSTGRES_PASSWORD' => $dbPassword,
]);
$this->caprover->addVolume(
$instance->db_app_name,
'/var/lib/postgresql/data',
"{$instance->db_app_name}-data"
);
// 2. Create the main app
$this->caprover->createApp($instance->app_name, true);
$this->caprover->setContainerHttpPort($instance->app_name, 80);
$this->caprover->addVolume(
$instance->app_name,
'/var/www/html/storage/app',
"{$instance->app_name}-storage"
);
// 3. Set environment variables
$this->caprover->setEnvVars($instance->app_name, [
'APP_NAME' => $instance->academy_name_ar,
'APP_ENV' => 'production',
'APP_DEBUG' => 'false',
'APP_URL' => "https://{$instance->app_name}.{$rootDomain}",
'APP_LOCALE' => 'ar',
'APP_FALLBACK_LOCALE' => 'en',
'APP_FAKER_LOCALE' => 'ar_EG',
'APP_MAINTENANCE_DRIVER' => 'file',
'BCRYPT_ROUNDS' => '12',
'LOG_CHANNEL' => 'stack',
'LOG_STACK' => 'single',
'LOG_LEVEL' => 'error',
'DB_CONNECTION' => 'pgsql',
'DB_HOST' => "srv-captain--{$instance->db_app_name}",
'DB_PORT' => '5432',
'DB_DATABASE' => $instance->db_name,
'DB_USERNAME' => $instance->db_user,
'DB_PASSWORD' => $dbPassword,
'SESSION_DRIVER' => 'database',
'SESSION_LIFETIME' => '120',
'BROADCAST_CONNECTION' => 'log',
'FILESYSTEM_DISK' => 'local',
'QUEUE_CONNECTION' => 'database',
'CACHE_STORE' => 'database',
'TRUSTED_PROXIES' => '*',
'PLATFORM_SERVICE_FEE_PERCENT' => (string) $instance->platform_fee_percent,
'ADMIN_EMAIL' => $instance->admin_email,
'ADMIN_PASSWORD' => $adminPassword,
'ADMIN_NAME' => $instance->academy_name_ar,
'ACADEMY_NAME_AR' => $instance->academy_name_ar,
'ACADEMY_NAME_EN' => $instance->academy_name_en,
'RUN_SEED_ON_FIRST_DEPLOY' => 'true',
'MAIL_MAILER' => 'log',
'MAIL_VERIFY_PEER' => 'false',
]);
// 4. Deploy from git repo
$this->caprover->deployFromGit(
$instance->app_name,
$gitRepoUrl,
'main',
$gitUser,
$gitPassword,
);
// 5. Enable SSL (wildcard already covers *.caprover.al-arcade.com)
sleep(2);
try {
$this->caprover->enableSsl($instance->app_name);
$this->caprover->forceSsl($instance->app_name);
} catch (\Throwable $e) {
// SSL might fail if wildcard covers it — not critical
}
}
public function suspend(Instance $instance, string $reason): void
{
$this->caprover->suspendApp($instance->app_name);
$instance->update([
'status' => 'suspended',
'suspended_at' => now(),
'suspension_reason' => $reason,
]);
AuditLog::record('suspended', $instance, ['reason' => $reason]);
}
public function resume(Instance $instance): void
{
$this->caprover->resumeApp($instance->app_name);
$instance->update([
'status' => 'active',
'suspended_at' => null,
'suspension_reason' => null,
]);
AuditLog::record('resumed', $instance);
}
public function delete(Instance $instance): void
{
try {
$this->caprover->deleteApp($instance->app_name);
} catch (\Throwable $e) {
// App might already be gone
}
try {
$this->caprover->deleteApp($instance->db_app_name);
} catch (\Throwable $e) {
// DB app might already be gone
}
AuditLog::record('deleted', $instance);
$instance->update(['status' => 'deleted']);
}
public function updateEnvVars(Instance $instance, array $vars): void
{
$this->caprover->setEnvVars($instance->app_name, $vars);
AuditLog::record('env_updated', $instance, ['keys' => array_keys($vars)]);
}
private function generateAppName(string $academyName): string
{
$base = Str::slug($academyName);
$base = Str::limit($base, 30, '');
if (!Instance::where('app_name', $base)->exists()) {
return $base;
}
return $base . '-' . Str::random(4);
}
}
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
......@@ -8,7 +8,8 @@
"require": {
"php": "^8.3",
"laravel/framework": "^13.8",
"laravel/tinker": "^3.0"
"laravel/tinker": "^3.0",
"livewire/livewire": "^4.3"
},
"require-dev": {
"fakerphp/faker": "^1.23",
......
......@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "36793882908b6cbdd344b5a4840231db",
"content-hash": "15d459f8e83fdae144d5144f811f3d68",
"packages": [
{
"name": "brick/math",
......@@ -2029,6 +2029,82 @@
],
"time": "2026-03-08T20:05:35+00:00"
},
{
"name": "livewire/livewire",
"version": "v4.3.3",
"source": {
"type": "git",
"url": "https://github.com/livewire/livewire.git",
"reference": "8021f2561865c4c297a3bfca37212a99034377e7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/livewire/zipball/8021f2561865c4c297a3bfca37212a99034377e7",
"reference": "8021f2561865c4c297a3bfca37212a99034377e7",
"shasum": ""
},
"require": {
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"illuminate/validation": "^10.0|^11.0|^12.0|^13.0",
"laravel/prompts": "^0.1.24|^0.2|^0.3",
"league/mime-type-detection": "^1.9",
"php": "^8.1",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-kernel": "^6.2|^7.0|^8.0"
},
"require-dev": {
"calebporzio/sushi": "^2.1",
"laravel/framework": "^10.15.0|^11.0|^12.0|^13.0",
"mockery/mockery": "^1.3.1",
"orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0",
"orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0",
"phpunit/phpunit": "^10.4|^11.5|^12.5",
"psy/psysh": "^0.11.22|^0.12"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Livewire": "Livewire\\Livewire"
},
"providers": [
"Livewire\\LivewireServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Livewire\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Caleb Porzio",
"email": "calebporzio@gmail.com"
}
],
"description": "A front-end framework for Laravel.",
"support": {
"issues": "https://github.com/livewire/livewire/issues",
"source": "https://github.com/livewire/livewire/tree/v4.3.3"
},
"funding": [
{
"url": "https://github.com/livewire",
"type": "github"
}
],
"time": "2026-06-27T03:16:11+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
......
<?php
return [
// CapRover API
'caprover_api_url' => env('CAPROVER_API_URL', 'http://localhost:3000'),
'caprover_password' => env('CAPROVER_PASSWORD', ''),
'caprover_root_domain' => env('CAPROVER_ROOT_DOMAIN', 'caprover.al-arcade.com'),
'caprover_server_ip' => env('CAPROVER_SERVER_IP', '18.192.166.221'),
// El Captain Git Repo (for deploying instances from source)
'elcaptain_git_repo' => env('ELCAPTAIN_GIT_REPO', 'https://gitlab.caprover.al-arcade.com/root/el-captain-sports-management.git'),
'elcaptain_git_user' => env('ELCAPTAIN_GIT_USER', 'root'),
'elcaptain_git_password' => env('ELCAPTAIN_GIT_PASSWORD', ''),
// Billing
'billing_day' => env('BILLING_DAY', 1), // day of month billing periods start
'auto_suspend_enabled' => env('AUTO_SUSPEND_ENABLED', true),
'warning_email_days' => [3, 7], // days after due date to send warnings
];
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->string('slug', 50)->unique();
$table->bigInteger('monthly_fee')->default(0); // piasters
$table->decimal('platform_fee_percent', 5, 2)->default(0);
$table->integer('trial_days')->default(14);
$table->integer('grace_days')->default(7); // days before auto-suspend
$table->integer('max_participants')->nullable(); // null = unlimited
$table->integer('max_branches')->default(1);
$table->jsonb('features')->default('{}');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('plans');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('name', 200);
$table->string('contact_name', 200)->nullable();
$table->string('email')->unique();
$table->string('phone', 30)->nullable();
$table->string('company', 200)->nullable();
$table->text('notes')->nullable();
$table->string('status', 20)->default('active'); // active, suspended, churned
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('clients');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('instances', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->foreignId('plan_id')->constrained();
$table->string('app_name', 100)->unique(); // CapRover app name
$table->string('academy_name_ar', 200);
$table->string('academy_name_en', 200);
$table->string('domain')->nullable(); // custom domain if any
$table->string('admin_email');
$table->string('status', 20)->default('provisioning');
// provisioning, active, trial, suspended, deleted
$table->string('db_app_name', 100); // CapRover DB app name
$table->string('db_name', 100);
$table->string('db_user', 100);
$table->text('db_password_encrypted');
$table->decimal('platform_fee_percent', 5, 2)->default(0); // override per instance
$table->bigInteger('monthly_fee')->default(0); // override per instance (piasters)
$table->date('trial_ends_at')->nullable();
$table->date('suspended_at')->nullable();
$table->text('suspension_reason')->nullable();
$table->text('caprover_deploy_log')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('instances');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('billing_periods', function (Blueprint $table) {
$table->id();
$table->foreignId('instance_id')->constrained()->cascadeOnDelete();
$table->date('period_start');
$table->date('period_end');
$table->bigInteger('monthly_fee_amount')->default(0); // piasters
$table->bigInteger('platform_fee_amount')->default(0); // piasters (calculated from instance transactions)
$table->bigInteger('total_due')->default(0); // monthly_fee + platform_fee
$table->bigInteger('total_paid')->default(0);
$table->bigInteger('balance_due')->default(0);
$table->string('status', 20)->default('open'); // open, invoiced, paid, overdue, waived
$table->date('due_date');
$table->integer('overdue_days')->default(0);
$table->timestamps();
$table->unique(['instance_id', 'period_start']);
});
}
public function down(): void
{
Schema::dropIfExists('billing_periods');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('platform_fees', function (Blueprint $table) {
$table->id();
$table->foreignId('instance_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->bigInteger('total_transactions_amount')->default(0); // total invoiced that day
$table->bigInteger('fee_amount')->default(0); // platform cut (piasters)
$table->string('source', 20)->default('pull'); // pull (from DB), report (webhook)
$table->integer('transaction_count')->default(0);
$table->boolean('verified')->default(false);
$table->timestamps();
$table->unique(['instance_id', 'date', 'source']);
});
}
public function down(): void
{
Schema::dropIfExists('platform_fees');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payments_received', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('client_id')->constrained();
$table->foreignId('instance_id')->nullable()->constrained();
$table->foreignId('billing_period_id')->nullable()->constrained('billing_periods');
$table->bigInteger('amount'); // piasters
$table->string('method', 30); // cash, bank_transfer, vodafone_cash, instapay, other
$table->string('reference', 200)->nullable(); // transfer ref, receipt number
$table->text('notes')->nullable();
$table->date('paid_at');
$table->foreignId('recorded_by')->constrained('users');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('payments_received');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained();
$table->foreignId('instance_id')->nullable()->constrained();
$table->string('action', 50); // deployed, suspended, resumed, deleted, env_updated, plan_changed
$table->jsonb('details')->default('{}');
$table->string('ip_address', 45)->nullable();
$table->timestamp('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};
......@@ -2,24 +2,74 @@
namespace Database\Seeders;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::firstOrCreate(
['email' => 'admin@al-arcade.com'],
[
'name' => 'Mahmoud Aglan',
'email' => 'admin@al-arcade.com',
'password' => Hash::make('Alarcade123#'),
]
);
Plan::firstOrCreate(['slug' => 'starter'], [
'name' => 'Starter',
'slug' => 'starter',
'monthly_fee' => 50000, // 500 EGP
'platform_fee_percent' => 3,
'trial_days' => 14,
'grace_days' => 7,
'max_participants' => 100,
'max_branches' => 1,
'features' => ['pos' => true, 'inventory' => false, 'hr' => false],
'is_active' => true,
]);
Plan::firstOrCreate(['slug' => 'professional'], [
'name' => 'Professional',
'slug' => 'professional',
'monthly_fee' => 150000, // 1500 EGP
'platform_fee_percent' => 2.5,
'trial_days' => 14,
'grace_days' => 10,
'max_participants' => 500,
'max_branches' => 3,
'features' => ['pos' => true, 'inventory' => true, 'hr' => true],
'is_active' => true,
]);
Plan::firstOrCreate(['slug' => 'enterprise'], [
'name' => 'Enterprise',
'slug' => 'enterprise',
'monthly_fee' => 350000, // 3500 EGP
'platform_fee_percent' => 2,
'trial_days' => 30,
'grace_days' => 14,
'max_participants' => null, // unlimited
'max_branches' => 10,
'features' => ['pos' => true, 'inventory' => true, 'hr' => true, 'api' => true],
'is_active' => true,
]);
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
Plan::firstOrCreate(['slug' => 'platform-only'], [
'name' => 'Platform % Only',
'slug' => 'platform-only',
'monthly_fee' => 0,
'platform_fee_percent' => 5,
'trial_days' => 14,
'grace_days' => 7,
'max_participants' => 200,
'max_branches' => 1,
'features' => ['pos' => true, 'inventory' => true, 'hr' => false],
'is_active' => true,
]);
}
}
#!/bin/sh
set -e
echo "==> Starting El Captain Manager..."
mkdir -p storage/framework/{cache/data,sessions,views} storage/logs bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache
chmod -R 775 storage bootstrap/cache
if [ -z "$APP_KEY" ]; then
php artisan key:generate --force --no-interaction
fi
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan migrate --force --no-interaction
php artisan db:seed --force --no-interaction 2>/dev/null || true
echo "==> Manager ready."
exec "$@"
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
charset utf-8;
client_max_body_size 10M;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 60s;
}
location ~ /\.(?!well-known) {
deny all;
}
}
[PHP]
memory_limit = 128M
max_execution_time = 60
upload_max_filesize = 10M
post_max_size = 12M
display_errors = Off
log_errors = On
error_log = /var/www/html/storage/logs/php_errors.log
date.timezone = Africa/Cairo
expose_php = Off
opcache.enable = 1
opcache.memory_consumption = 64
opcache.max_accelerated_files = 5000
opcache.validate_timestamps = 0
[www]
listen = /var/run/php-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 4
[supervisord]
nodaemon=true
user=root
logfile=/var/www/html/storage/logs/supervisord.log
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize
autostart=true
autorestart=true
priority=5
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
priority=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:scheduler]
command=/bin/sh -c "while true; do /usr/local/bin/php /var/www/html/artisan schedule:run --no-interaction >> /var/www/html/storage/logs/scheduler.log 2>&1; sleep 60; done"
autostart=true
autorestart=true
priority=20
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'El Captain Manager' }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', system-ui, sans-serif; }
[x-cloak] { display: none !important; }
</style>
@livewireStyles
</head>
<body class="h-full bg-gray-50">
<div class="min-h-full" x-data="{ sidebarOpen: false }">
{{-- Sidebar --}}
<aside class="fixed inset-y-0 left-0 z-30 w-64 bg-gray-900 transform transition-transform lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
<div class="flex items-center gap-3 px-6 py-5 border-b border-gray-800">
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
</div>
<span class="text-white font-semibold text-lg">Manager</span>
</div>
<nav class="px-4 py-4 space-y-1">
<a href="{{ route('dashboard') }}" wire:navigate
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ request()->routeIs('dashboard') ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
Dashboard
</a>
<a href="{{ route('instances.index') }}" wire:navigate
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ request()->routeIs('instances.*') ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg>
Instances
</a>
<a href="{{ route('clients.index') }}" wire:navigate
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ request()->routeIs('clients.*') ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Clients
</a>
<a href="{{ route('billing.index') }}" wire:navigate
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ request()->routeIs('billing.*') ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Billing
</a>
<a href="{{ route('plans.index') }}" wire:navigate
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ request()->routeIs('plans.*') ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
Plans
</a>
<a href="{{ route('audit.index') }}" wire:navigate
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium {{ request()->routeIs('audit.*') ? 'bg-gray-800 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Audit Log
</a>
</nav>
</aside>
{{-- Overlay --}}
<div x-show="sidebarOpen" @click="sidebarOpen = false" class="fixed inset-0 z-20 bg-black/50 lg:hidden" x-cloak></div>
{{-- Main content --}}
<div class="lg:pl-64">
<header class="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 sm:px-6 py-3 flex items-center justify-between">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden p-2 -ml-2 rounded-lg hover:bg-gray-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<h1 class="text-lg font-semibold text-gray-900">{{ $title ?? '' }}</h1>
<div class="flex items-center gap-3">
<span class="text-sm text-gray-500">{{ auth()->user()?->name ?? 'Admin' }}</span>
</div>
</header>
<main class="p-4 sm:p-6">
{{ $slot }}
</main>
</div>
</div>
@livewireScripts
</body>
</html>
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — El Captain Manager</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>body { font-family: 'Inter', system-ui, sans-serif; }</style>
@livewireStyles
</head>
<body class="h-full bg-gray-900 flex items-center justify-center">
{{ $slot }}
@livewireScripts
</body>
</html>
<div>
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div class="flex items-center gap-3">
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Search by user or instance..."
class="w-64 rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
<select wire:model.live="action" class="rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">All actions</option>
<option value="provisioned">Provisioned</option>
<option value="suspended">Suspended</option>
<option value="resumed">Resumed</option>
<option value="deleted">Deleted</option>
<option value="env_updated">Env Updated</option>
</select>
</div>
</div>
{{-- Table --}}
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-500">Time</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">User</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Action</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Instance</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Details</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($logs as $log)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">
{{ $log->created_at->format('M d, H:i') }}
</td>
<td class="px-4 py-3 text-gray-700">
{{ $log->user?->name ?? 'System' }}
</td>
<td class="px-4 py-3">
@php
$actionColors = [
'provisioned' => 'bg-green-100 text-green-700',
'suspended' => 'bg-red-100 text-red-700',
'resumed' => 'bg-blue-100 text-blue-700',
'deleted' => 'bg-gray-100 text-gray-700',
'env_updated' => 'bg-purple-100 text-purple-700',
];
@endphp
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $actionColors[$log->action] ?? 'bg-gray-100 text-gray-600' }}">
{{ $log->action }}
</span>
</td>
<td class="px-4 py-3">
@if($log->instance)
<a href="{{ route('instances.show', $log->instance) }}" wire:navigate class="text-blue-600 hover:underline">
{{ $log->instance->academy_name_en ?? $log->instance->app_name }}
</a>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3 text-gray-500 max-w-xs truncate font-mono text-xs">
@if($log->details)
{{ json_encode($log->details) }}
@else
-
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-12 text-center text-gray-400">
No audit log entries found.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $logs->links() }}
</div>
</div>
<div>
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div class="flex items-center gap-3">
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Search by academy..."
class="w-64 rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
<select wire:model.live="status" class="rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
<option value="partially_paid">Partially Paid</option>
<option value="overdue">Overdue</option>
</select>
</div>
<a href="{{ route('billing.collect') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Record Payment
</a>
</div>
{{-- Table --}}
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-500">Instance</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Period</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Total Due</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Paid</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Balance</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($periods as $period)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href="{{ route('instances.show', $period->instance) }}" wire:navigate class="font-medium text-blue-600 hover:underline">
{{ $period->instance->academy_name_ar }}
</a>
</td>
<td class="px-4 py-3 text-gray-600">
{{ $period->period_start->format('M d') }} - {{ $period->period_end->format('M d, Y') }}
</td>
<td class="px-4 py-3 text-right text-gray-900 font-medium">
{{ number_format($period->total_due / 100, 2) }} EGP
</td>
<td class="px-4 py-3 text-right text-gray-600">
{{ number_format($period->total_paid / 100, 2) }} EGP
</td>
<td class="px-4 py-3 text-right font-medium {{ $period->balance_due > 0 ? 'text-red-600' : 'text-green-600' }}">
{{ number_format($period->balance_due / 100, 2) }} EGP
</td>
<td class="px-4 py-3">
@php
$colors = [
'paid' => 'bg-green-100 text-green-700',
'pending' => 'bg-yellow-100 text-yellow-700',
'partially_paid' => 'bg-blue-100 text-blue-700',
'overdue' => 'bg-red-100 text-red-700',
];
@endphp
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $colors[$period->status] ?? 'bg-gray-100 text-gray-600' }}">
{{ str_replace('_', ' ', $period->status) }}
</span>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-12 text-center text-gray-400">
No billing periods found.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $periods->links() }}
</div>
</div>
<div>
<div class="flex items-center justify-between mb-6">
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Search clients..."
class="w-64 rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
</div>
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-500">Name</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Email</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Company</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Instances</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($clients as $client)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ $client->name }}</td>
<td class="px-4 py-3 text-gray-600">{{ $client->email }}</td>
<td class="px-4 py-3 text-gray-600">{{ $client->company ?? '-' }}</td>
<td class="px-4 py-3 text-gray-600">{{ $client->instances_count }}</td>
<td class="px-4 py-3">
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $client->status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' }}">
{{ $client->status }}
</span>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-12 text-center text-gray-400">No clients yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">{{ $clients->links() }}</div>
</div>
<div>
{{-- Stats Grid --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Total Instances</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ $totalInstances }}</p>
</div>
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg>
</div>
</div>
<div class="mt-3 flex gap-3 text-xs">
<span class="text-green-600">{{ $activeInstances }} active</span>
<span class="text-yellow-600">{{ $trialInstances }} trial</span>
<span class="text-red-600">{{ $suspendedInstances }} suspended</span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">MRR (Monthly Fees)</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ number_format($monthlyFeesDue / 100, 0) }} EGP</p>
</div>
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"/></svg>
</div>
</div>
<div class="mt-3 text-xs text-gray-500">
Collected: {{ number_format($monthlyFeesPaid / 100, 0) }} EGP
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Platform Fees (This Month)</p>
<p class="text-2xl font-bold text-gray-900 mt-1">{{ number_format($platformFeesThisMonth / 100, 0) }} EGP</p>
</div>
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
</div>
</div>
<div class="mt-3 text-xs text-gray-500">
From transaction %
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Overdue</p>
<p class="text-2xl font-bold {{ $overdue > 0 ? 'text-red-600' : 'text-gray-900' }} mt-1">{{ $overdue }}</p>
</div>
<div class="w-10 h-10 {{ $overdue > 0 ? 'bg-red-100' : 'bg-gray-100' }} rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 {{ $overdue > 0 ? 'text-red-600' : 'text-gray-400' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
</div>
</div>
<div class="mt-3 text-xs text-gray-500">
Pending enforcement
</div>
</div>
</div>
{{-- Actions + Recent --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Quick Actions --}}
<div class="bg-white rounded-xl border border-gray-200 p-5">
<h2 class="font-semibold text-gray-900 mb-4">Quick Actions</h2>
<div class="space-y-2">
<a href="{{ route('instances.deploy') }}" wire:navigate
class="flex items-center gap-3 w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-medium text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Deploy New Instance
</a>
<a href="{{ route('billing.collect') }}" wire:navigate
class="flex items-center gap-3 w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition font-medium text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
Record Payment
</a>
<a href="{{ route('instances.index') }}?status=suspended" wire:navigate
class="flex items-center gap-3 w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition font-medium text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
View Suspended
</a>
</div>
</div>
{{-- Recent Instances --}}
<div class="lg:col-span-2 bg-white rounded-xl border border-gray-200 p-5">
<h2 class="font-semibold text-gray-900 mb-4">Recent Instances</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100">
<th class="text-left py-2 font-medium text-gray-500">Academy</th>
<th class="text-left py-2 font-medium text-gray-500">Client</th>
<th class="text-left py-2 font-medium text-gray-500">Plan</th>
<th class="text-left py-2 font-medium text-gray-500">Status</th>
</tr>
</thead>
<tbody>
@forelse($recentInstances as $instance)
<tr class="border-b border-gray-50">
<td class="py-2.5">
<a href="{{ route('instances.show', $instance) }}" wire:navigate class="text-blue-600 hover:underline font-medium">
{{ $instance->academy_name_ar }}
</a>
</td>
<td class="py-2.5 text-gray-600">{{ $instance->client->name }}</td>
<td class="py-2.5 text-gray-600">{{ $instance->plan->name }}</td>
<td class="py-2.5">
@php
$colors = ['active' => 'bg-green-100 text-green-700', 'trial' => 'bg-yellow-100 text-yellow-700', 'suspended' => 'bg-red-100 text-red-700', 'provisioning' => 'bg-blue-100 text-blue-700'];
@endphp
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $colors[$instance->status] ?? 'bg-gray-100 text-gray-700' }}">
{{ $instance->status }}
</span>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="py-8 text-center text-gray-400">No instances yet. Deploy your first one!</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
This diff is collapsed.
<div>
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<div class="flex items-center gap-3">
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Search instances..."
class="w-64 rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
<select wire:model.live="status" class="rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="trial">Trial</option>
<option value="suspended">Suspended</option>
<option value="provisioning">Provisioning</option>
</select>
</div>
<a href="{{ route('instances.deploy') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Deploy New
</a>
</div>
{{-- Table --}}
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-500">Academy</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Client</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Plan</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Fee %</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($instances as $instance)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href="{{ route('instances.show', $instance) }}" wire:navigate class="font-medium text-blue-600 hover:underline">
{{ $instance->academy_name_ar }}
</a>
<p class="text-xs text-gray-400">{{ $instance->app_name }}.{{ config('manager.caprover_root_domain') }}</p>
</td>
<td class="px-4 py-3 text-gray-600">{{ $instance->client->name }}</td>
<td class="px-4 py-3 text-gray-600">{{ $instance->plan->name }}</td>
<td class="px-4 py-3">
@php
$colors = ['active' => 'bg-green-100 text-green-700', 'trial' => 'bg-yellow-100 text-yellow-700', 'suspended' => 'bg-red-100 text-red-700', 'provisioning' => 'bg-blue-100 text-blue-700'];
@endphp
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full {{ $colors[$instance->status] ?? 'bg-gray-100 text-gray-600' }}">
{{ $instance->status }}
</span>
@if($instance->isTrial())
<span class="text-xs text-gray-400 block mt-0.5">ends {{ $instance->trial_ends_at->format('M d') }}</span>
@endif
</td>
<td class="px-4 py-3 text-gray-600">{{ $instance->platform_fee_percent }}%</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<a href="{{ $instance->url }}" target="_blank" class="p-1.5 text-gray-400 hover:text-blue-600 rounded" title="Open">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
@if($instance->isActive() || $instance->isTrial())
<button wire:click="suspend({{ $instance->id }})" wire:confirm="Suspend this instance? The app will return 503."
class="p-1.5 text-gray-400 hover:text-red-600 rounded" title="Suspend">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</button>
@elseif($instance->isSuspended())
<button wire:click="resume({{ $instance->id }})" wire:confirm="Resume this instance?"
class="p-1.5 text-gray-400 hover:text-green-600 rounded" title="Resume">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</button>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-12 text-center text-gray-400">
No instances found. <a href="{{ route('instances.deploy') }}" wire:navigate class="text-blue-600 hover:underline">Deploy your first one</a>.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $instances->links() }}
</div>
</div>
This diff is collapsed.
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<div class="w-12 h-12 mx-auto bg-blue-600 rounded-xl flex items-center justify-center mb-4">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
</div>
<h1 class="text-xl font-bold text-white">El Captain Manager</h1>
<p class="text-gray-400 text-sm mt-1">Instance Management Platform</p>
</div>
<form wire:submit="login" class="bg-gray-800 rounded-xl p-6 border border-gray-700">
@if($error)
<div class="mb-4 p-3 bg-red-900/50 border border-red-700 text-red-300 rounded-lg text-sm">{{ $error }}</div>
@endif
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Email</label>
<input type="email" wire:model="email" autofocus
class="w-full rounded-lg bg-gray-700 border-gray-600 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Password</label>
<input type="password" wire:model="password"
class="w-full rounded-lg bg-gray-700 border-gray-600 text-white placeholder-gray-400 focus:border-blue-500 focus:ring-blue-500">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" wire:model="remember" class="rounded bg-gray-700 border-gray-600 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-400">Remember me</span>
</div>
</div>
<button type="submit" class="w-full mt-6 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition">
Sign In
</button>
</form>
</div>
<div>
{{-- Header --}}
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-gray-900">Plans</h2>
<button wire:click="create"
class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
New Plan
</button>
</div>
{{-- Form (slide down) --}}
@if($showForm)
<div class="bg-white rounded-xl border border-gray-200 p-6 mb-6">
<h3 class="text-sm font-semibold text-gray-900 mb-4">{{ $editingId ? 'Edit Plan' : 'Create Plan' }}</h3>
<form wire:submit="save" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input type="text" wire:model="name" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500" placeholder="Starter">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Slug</label>
<input type="text" wire:model="slug" dir="ltr" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500" placeholder="starter">
@error('slug') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Monthly Fee (EGP)</label>
<input type="number" wire:model="monthly_fee" step="1" min="0" dir="ltr" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500" placeholder="500">
@error('monthly_fee') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Platform Fee %</label>
<input type="number" wire:model="platform_fee_percent" step="0.5" min="0" max="100" dir="ltr" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500" placeholder="5">
@error('platform_fee_percent') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Trial Days</label>
<input type="number" wire:model="trial_days" min="0" dir="ltr" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
@error('trial_days') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Grace Days</label>
<input type="number" wire:model="grace_days" min="0" dir="ltr" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500">
@error('grace_days') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Max Participants</label>
<input type="number" wire:model="max_participants" min="0" dir="ltr" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500" placeholder="Unlimited">
@error('max_participants') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Max Branches</label>
<input type="number" wire:model="max_branches" min="0" dir="ltr" class="w-full rounded-lg border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500" placeholder="Unlimited">
@error('max_branches') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="is_active" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">Active</span>
</label>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
{{ $editingId ? 'Update' : 'Create' }}
</button>
<button type="button" wire:click="cancelForm" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">
Cancel
</button>
</div>
</form>
</div>
@endif
{{-- Table --}}
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-500">Name</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Monthly Fee</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Platform %</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Trial Days</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Grace Days</th>
<th class="text-center px-4 py-3 font-medium text-gray-500">Active</th>
<th class="text-right px-4 py-3 font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($plans as $plan)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ $plan->name }}</td>
<td class="px-4 py-3 text-right text-gray-600">{{ number_format($plan->monthly_fee / 100, 0) }} EGP</td>
<td class="px-4 py-3 text-right text-gray-600">{{ $plan->platform_fee_percent }}%</td>
<td class="px-4 py-3 text-right text-gray-600">{{ $plan->trial_days }}</td>
<td class="px-4 py-3 text-right text-gray-600">{{ $plan->grace_days }}</td>
<td class="px-4 py-3 text-center">
<button wire:click="toggleActive({{ $plan->id }})" class="inline-block">
@if($plan->is_active)
<span class="inline-block w-3 h-3 rounded-full bg-green-500" title="Active"></span>
@else
<span class="inline-block w-3 h-3 rounded-full bg-gray-300" title="Inactive"></span>
@endif
</button>
</td>
<td class="px-4 py-3 text-right">
<button wire:click="edit({{ $plan->id }})" class="text-sm text-blue-600 hover:underline">Edit</button>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-12 text-center text-gray-400">
No plans yet. Create your first one.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $plans->links() }}
</div>
</div>
<div class="max-w-xl mx-auto">
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-6">Record Payment</h2>
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700">
{{ session('success') }}
</div>
@endif
<form wire:submit="save" class="space-y-5">
{{-- Client --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Client</label>
<select wire:model.live="client_id" class="w-full rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500">
<option value="">-- Select Client --</option>
@foreach($clients as $client)
<option value="{{ $client->id }}">{{ $client->name }}</option>
@endforeach
</select>
@error('client_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Instance --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Instance</label>
<select wire:model="instance_id" class="w-full rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" {{ !$client_id ? 'disabled' : '' }}>
<option value="">-- Select Instance --</option>
@foreach($instances as $instance)
<option value="{{ $instance->id }}">{{ $instance->academy_name_ar }} ({{ $instance->academy_name_en }})</option>
@endforeach
</select>
@error('instance_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Amount --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Amount (EGP)</label>
<input type="number" wire:model="amount" step="0.01" min="0" dir="ltr"
class="w-full rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" placeholder="0.00">
@error('amount') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Method --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
<select wire:model="method" class="w-full rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500">
<option value="">-- Select Method --</option>
<option value="cash">Cash</option>
<option value="bank_transfer">Bank Transfer</option>
<option value="vodafone_cash">Vodafone Cash</option>
<option value="instapay">InstaPay</option>
<option value="other">Other</option>
</select>
@error('method') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Reference --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reference</label>
<input type="text" wire:model="reference"
class="w-full rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" placeholder="Transaction ID, receipt number, etc.">
@error('reference') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Paid At --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date Paid</label>
<input type="date" wire:model="paid_at" dir="ltr"
class="w-full rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500">
@error('paid_at') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Notes --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea wire:model="notes" rows="3"
class="w-full rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" placeholder="Optional notes..."></textarea>
@error('notes') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Submit --}}
<div class="flex items-center justify-between pt-4 border-t border-gray-100">
<a href="{{ route('billing.index') }}" wire:navigate class="text-sm text-gray-500 hover:text-gray-700">Cancel</a>
<button type="submit"
wire:loading.attr="disabled"
wire:target="save"
class="px-5 py-2.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition disabled:opacity-50">
<span wire:loading.remove wire:target="save">Save Payment</span>
<span wire:loading wire:target="save">Saving...</span>
</button>
</div>
</form>
</div>
</div>
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('manager:pull-fees')->dailyAt('02:00');
Schedule::command('manager:generate-billing')->monthlyOn(1, '06:00');
Schedule::command('manager:enforce-billing')->dailyAt('09:00');
<?php
use App\Livewire\AuditLogList;
use App\Livewire\BillingOverview;
use App\Livewire\ClientList;
use App\Livewire\Dashboard;
use App\Livewire\DeployInstanceWizard;
use App\Livewire\InstanceList;
use App\Livewire\InstanceShow;
use App\Livewire\Login;
use App\Livewire\PlanList;
use App\Livewire\RecordPayment;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
Route::get('/login', Login::class)->name('login')->middleware('guest');
Route::post('/logout', function () {
auth()->logout();
session()->invalidate();
session()->regenerateToken();
return redirect('/login');
})->name('logout');
Route::middleware(['auth'])->group(function () {
Route::get('/', Dashboard::class)->name('dashboard');
Route::get('/instances', InstanceList::class)->name('instances.index');
Route::get('/instances/deploy', DeployInstanceWizard::class)->name('instances.deploy');
Route::get('/instances/{instance}', InstanceShow::class)->name('instances.show');
Route::get('/clients', ClientList::class)->name('clients.index');
Route::get('/billing', BillingOverview::class)->name('billing.index');
Route::get('/billing/collect', RecordPayment::class)->name('billing.collect');
Route::get('/plans', PlanList::class)->name('plans.index');
Route::get('/audit', AuditLogList::class)->name('audit.index');
});
// Webhook endpoint for instance self-reports (no auth, uses token)
Route::post('/api/webhook/fee-report', [\App\Http\Controllers\FeeWebhookController::class, 'store'])
->name('api.fee-report');
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment