diff --git a/TASKS.md b/TASKS.md index 0619610..c9c361d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -64,40 +64,40 @@ - [x] All 53 tests passing, build clean ## Phase 3: Provisioning Automation -- [ ] Create `ProvisioningServiceInterface` abstraction -- [ ] Build VirtFusion provisioning service: - - [ ] Create VPS via API - - [ ] Suspend/unsuspend VPS - - [ ] Terminate VPS - - [ ] Get status and resource usage - - [ ] Credential generation and secure storage +- [x] Create `ProvisioningServiceInterface` abstraction +- [x] Build VirtFusion provisioning service: + - [x] Create VPS via API + - [x] Suspend/unsuspend VPS + - [x] Terminate VPS + - [x] Get status and resource usage + - [x] Credential generation and secure storage - [ ] Build Pterodactyl provisioning service: - [ ] Create game server via API - [ ] Suspend/unsuspend server - [ ] Delete server - [ ] Get server status and resources -- [ ] Build SynergyCP provisioning service: - - [ ] Provision dedicated server - - [ ] Suspend/unsuspend server - - [ ] Terminate server - - [ ] Get server details - - [ ] Handle limited hardware inventory (waitlist/semi-auto) -- [ ] Build Enhance provisioning service: - - [ ] Create web hosting account - - [ ] Suspend/delete account - - [ ] Get account status -- [ ] Implement event-driven provisioning (listen to `PaymentSucceeded` events) +- [x] Build SynergyCP provisioning service: + - [x] Provision dedicated server + - [x] Suspend/unsuspend server + - [x] Terminate server + - [x] Get server details + - [x] Handle limited hardware inventory (waitlist/semi-auto) +- [x] Build Enhance provisioning service: + - [x] Create web hosting account + - [x] Suspend/delete account + - [x] Get account status +- [x] Implement event-driven provisioning (listen to `PaymentSucceeded` events) - [ ] Build provisioning failure handling and retry logic - [ ] Send credentials email on successful provisioning -- [ ] Log all provisioning actions to `provisioning_logs` table +- [x] Log all provisioning actions to `provisioning_logs` table ## Phase 4: Customer Dashboard (account.ezscale.cloud) -- [ ] Build service overview dashboard: - - [ ] Active services list with status indicators +- [x] Build service overview dashboard: + - [x] Active services list with status indicators - [ ] Resource usage widgets (CPU, RAM, disk, bandwidth) - - [ ] Next invoice and payment due date + - [x] Next invoice and payment due date - [ ] Recent support tickets - - [ ] Quick actions (renew, upgrade, create ticket) + - [x] Quick actions (renew, upgrade, create ticket) - [ ] Build service detail pages: - [ ] VPS details (IP, credentials, resource graphs, control buttons) - [ ] Game server details (connect info, resource usage, restart button) @@ -111,61 +111,61 @@ - [ ] Upcoming renewals - [ ] Plan upgrade/downgrade flow (self-service with proration) - [ ] Subscription cancellation flow (with optional survey) -- [ ] Profile and account settings: - - [ ] Contact information - - [ ] Billing/shipping addresses - - [ ] Tax ID - - [ ] Password change - - [ ] 2FA setup (TOTP, passkeys) +- [x] Profile and account settings: + - [x] Contact information + - [x] Billing/shipping addresses + - [x] Tax ID + - [x] Password change + - [x] 2FA setup (TOTP, passkeys) - [ ] SupportPal integration: - [ ] SSO to SupportPal - [ ] View recent tickets widget - [ ] Create ticket button (opens SupportPal or API) ## Phase 5: Admin Panel (admin.ezscale.cloud) -- [ ] Analytics dashboard: - - [ ] MRR (Monthly Recurring Revenue) graph +- [x] Analytics dashboard: + - [x] MRR (Monthly Recurring Revenue) graph - [ ] ARR (Annual Recurring Revenue) - [ ] Churn rate calculation and graph - [ ] Customer growth chart - [ ] Revenue trends (daily, monthly, yearly) - - [ ] Popular plans and conversion rates - - [ ] Outstanding invoices total + - [x] Popular plans and conversion rates + - [x] Outstanding invoices total - [ ] Overdue accounts list -- [ ] Customer management: - - [ ] Customer list (searchable, filterable) - - [ ] Customer detail view (profile, services, billing history, notes) +- [x] Customer management: + - [x] Customer list (searchable, filterable) + - [x] Customer detail view (profile, services, billing history, notes) - [ ] Edit customer information - [ ] Impersonate customer (with audit logging) - [ ] Add admin notes to customer account - [ ] View customer audit log -- [ ] Service management: - - [ ] All services list (filter by type, status, platform) +- [x] Service management: + - [x] All services list (filter by type, status, platform) - [ ] Manually provision service - - [ ] Suspend/unsuspend service - - [ ] Terminate service + - [x] Suspend/unsuspend service + - [x] Terminate service - [ ] Modify service (change plan, extend expiry) - - [ ] View provisioning logs + - [x] View provisioning logs - [ ] Order management: - [ ] Pending orders list - [ ] Approve/reject orders (for semi-automated provisioning) - [ ] View order details -- [ ] Invoice management: - - [ ] All invoices list (filter by status, date, customer) +- [x] Invoice management: + - [x] All invoices list (filter by status, date, customer) - [ ] Create manual invoice - [ ] Edit invoice (before sending) - - [ ] Void/refund invoice + - [x] Void/refund invoice - [ ] Resend invoice email - [ ] Coupon management: - [ ] Create coupon (percentage, fixed, applies to plans) - [ ] Edit coupon details - [ ] View redemption history - [ ] Deactivate/delete coupon -- [ ] Plan management: - - [ ] Create new plan (set pricing, features, billing cycle) - - [ ] Edit existing plan - - [ ] Archive/hide plan - - [ ] Set stock quantity (for limited dedicated servers) +- [x] Plan management: + - [x] Create new plan (set pricing, features, billing cycle) + - [x] Edit existing plan + - [x] Archive/hide plan + - [x] Set stock quantity (for limited dedicated servers) - [ ] System configuration: - [ ] Email template editor - [ ] Tax rate configuration (by region) @@ -248,11 +248,11 @@ - [ ] Tutorials - [ ] Troubleshooting - [ ] API documentation -- [ ] Legal pages: - - [ ] Terms of Service - - [ ] Privacy Policy - - [ ] Acceptable Use Policy - - [ ] SLA (Service Level Agreement) +- [x] Legal pages: + - [x] Terms of Service + - [x] Privacy Policy + - [x] Acceptable Use Policy + - [x] SLA (Service Level Agreement) - [ ] Signup flow: - [ ] Plan selection - [ ] Account creation diff --git a/website/app/Http/Controllers/Account/ServiceController.php b/website/app/Http/Controllers/Account/ServiceController.php new file mode 100644 index 0000000..e049fe6 --- /dev/null +++ b/website/app/Http/Controllers/Account/ServiceController.php @@ -0,0 +1,41 @@ +user() + ->services() + ->with('plan:id,name,service_type,price,billing_cycle') + ->latest() + ->get(); + + return Inertia::render('Services/Index', [ + 'services' => $services, + ]); + } + + public function show(Request $request, Service $service): Response + { + abort_unless($service->user_id === $request->user()->id, 403); + + $service->load([ + 'plan:id,name,slug,description,service_type,price,billing_cycle,features', + 'subscription', + ]); + + return Inertia::render('Services/Show', [ + 'service' => $service, + ]); + } +} diff --git a/website/app/Http/Controllers/Admin/CouponController.php b/website/app/Http/Controllers/Admin/CouponController.php new file mode 100644 index 0000000..af63fc4 --- /dev/null +++ b/website/app/Http/Controllers/Admin/CouponController.php @@ -0,0 +1,102 @@ +withCount('redemptions') + ->orderByDesc('created_at') + ->paginate(25); + + return Inertia::render('Admin/Coupons/Index', [ + 'coupons' => $coupons, + ]); + } + + public function create(): Response + { + $plans = Plan::query() + ->where('status', 'active') + ->orderBy('name') + ->get(['id', 'name', 'service_type']); + + return Inertia::render('Admin/Coupons/Create', [ + 'plans' => $plans, + ]); + } + + public function store(StoreCouponRequest $request): RedirectResponse + { + Coupon::query()->create([ + 'code' => strtoupper($request->validated('code')), + 'type' => $request->validated('type'), + 'value' => $request->validated('value'), + 'currency' => 'USD', + 'max_uses' => $request->validated('max_uses'), + 'applies_to' => $request->validated('applies_to'), + 'expires_at' => $request->validated('expires_at'), + 'active' => true, + ]); + + return redirect() + ->route('admin.coupons.index') + ->with('success', 'Coupon created successfully.'); + } + + public function edit(Coupon $coupon): Response + { + $plans = Plan::query() + ->where('status', 'active') + ->orderBy('name') + ->get(['id', 'name', 'service_type']); + + $redemptions = $coupon->redemptions() + ->with('user:id,name,email') + ->orderByDesc('created_at') + ->get(); + + return Inertia::render('Admin/Coupons/Edit', [ + 'coupon' => $coupon, + 'plans' => $plans, + 'redemptions' => $redemptions, + ]); + } + + public function update(StoreCouponRequest $request, Coupon $coupon): RedirectResponse + { + $coupon->update([ + 'code' => strtoupper($request->validated('code')), + 'type' => $request->validated('type'), + 'value' => $request->validated('value'), + 'max_uses' => $request->validated('max_uses'), + 'applies_to' => $request->validated('applies_to'), + 'expires_at' => $request->validated('expires_at'), + ]); + + return redirect() + ->route('admin.coupons.index') + ->with('success', 'Coupon updated successfully.'); + } + + public function destroy(Coupon $coupon): RedirectResponse + { + $coupon->update(['active' => false]); + + return redirect() + ->route('admin.coupons.index') + ->with('success', 'Coupon deactivated successfully.'); + } +} diff --git a/website/app/Http/Requests/StoreCouponRequest.php b/website/app/Http/Requests/StoreCouponRequest.php new file mode 100644 index 0000000..0e7f4d8 --- /dev/null +++ b/website/app/Http/Requests/StoreCouponRequest.php @@ -0,0 +1,51 @@ +> */ + public function rules(): array + { + $uniqueCodeRule = Rule::unique('coupons', 'code'); + + if ($this->route('coupon')) { + $uniqueCodeRule->ignore($this->route('coupon')); + } + + return [ + 'code' => ['required', 'string', 'max:50', $uniqueCodeRule], + 'type' => ['required', Rule::in(['percentage', 'fixed'])], + 'value' => ['required', 'numeric', 'min:0.01'], + 'max_uses' => ['nullable', 'integer', 'min:1'], + 'expires_at' => ['nullable', 'date', 'after:now'], + 'applies_to' => ['nullable', 'array'], + 'applies_to.*' => ['integer', 'exists:plans,id'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'code.required' => 'Coupon code is required.', + 'code.unique' => 'This coupon code is already in use.', + 'type.required' => 'Discount type is required.', + 'type.in' => 'Discount type must be percentage or fixed.', + 'value.required' => 'Discount value is required.', + 'value.min' => 'Discount value must be at least 0.01.', + 'expires_at.after' => 'Expiry date must be in the future.', + 'applies_to.*.exists' => 'One or more selected plans do not exist.', + ]; + } +} diff --git a/website/app/Models/Coupon.php b/website/app/Models/Coupon.php index 078c0af..7f43e9b 100644 --- a/website/app/Models/Coupon.php +++ b/website/app/Models/Coupon.php @@ -20,6 +20,7 @@ class Coupon extends Model 'applies_to', 'max_uses', 'times_used', + 'active', 'expires_at', ]; @@ -30,6 +31,7 @@ class Coupon extends Model 'applies_to' => 'array', 'max_uses' => 'integer', 'times_used' => 'integer', + 'active' => 'boolean', 'expires_at' => 'datetime', ]; } @@ -41,6 +43,10 @@ class Coupon extends Model public function isValid(): bool { + if (! $this->active) { + return false; + } + if ($this->expires_at && $this->expires_at->isPast()) { return false; } diff --git a/website/database/factories/CouponFactory.php b/website/database/factories/CouponFactory.php index 1a9cb6e..dcc2e19 100644 --- a/website/database/factories/CouponFactory.php +++ b/website/database/factories/CouponFactory.php @@ -19,6 +19,7 @@ class CouponFactory extends Factory 'value' => fake()->randomFloat(2, 5, 50), 'max_uses' => fake()->optional()->numberBetween(10, 1000), 'times_used' => 0, + 'active' => true, 'expires_at' => fake()->optional()->dateTimeBetween('now', '+1 year'), ]; } diff --git a/website/database/migrations/2026_02_09_153142_add_active_to_coupons_table.php b/website/database/migrations/2026_02_09_153142_add_active_to_coupons_table.php new file mode 100644 index 0000000..4ee40df --- /dev/null +++ b/website/database/migrations/2026_02_09_153142_add_active_to_coupons_table.php @@ -0,0 +1,24 @@ +boolean('active')->default(true)->after('times_used'); + }); + } + + public function down(): void + { + Schema::table('coupons', function (Blueprint $table): void { + $table->dropColumn('active'); + }); + } +}; diff --git a/website/resources/ts/Pages/Admin/Coupons/Create.vue b/website/resources/ts/Pages/Admin/Coupons/Create.vue new file mode 100644 index 0000000..efe0252 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Coupons/Create.vue @@ -0,0 +1,210 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Coupons/Edit.vue b/website/resources/ts/Pages/Admin/Coupons/Edit.vue new file mode 100644 index 0000000..aa8cef3 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Coupons/Edit.vue @@ -0,0 +1,300 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Coupons/Index.vue b/website/resources/ts/Pages/Admin/Coupons/Index.vue new file mode 100644 index 0000000..36861ea --- /dev/null +++ b/website/resources/ts/Pages/Admin/Coupons/Index.vue @@ -0,0 +1,210 @@ + + + diff --git a/website/resources/ts/Pages/Services/Index.vue b/website/resources/ts/Pages/Services/Index.vue new file mode 100644 index 0000000..508d639 --- /dev/null +++ b/website/resources/ts/Pages/Services/Index.vue @@ -0,0 +1,150 @@ + + + diff --git a/website/resources/ts/Pages/Services/Show.vue b/website/resources/ts/Pages/Services/Show.vue new file mode 100644 index 0000000..f54b634 --- /dev/null +++ b/website/resources/ts/Pages/Services/Show.vue @@ -0,0 +1,390 @@ + + + diff --git a/website/resources/ts/navigation/account.ts b/website/resources/ts/navigation/account.ts index adca59e..4c951be 100644 --- a/website/resources/ts/navigation/account.ts +++ b/website/resources/ts/navigation/account.ts @@ -7,6 +7,7 @@ export interface NavItem { export const accountNavItems: NavItem[] = [ { title: 'Dashboard', href: '/dashboard', icon: 'tabler-smart-home', matchPrefix: '/dashboard' }, + { title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' }, { title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' }, { title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' }, { title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' }, diff --git a/website/resources/ts/navigation/admin.ts b/website/resources/ts/navigation/admin.ts index 6322932..9133a8b 100644 --- a/website/resources/ts/navigation/admin.ts +++ b/website/resources/ts/navigation/admin.ts @@ -6,4 +6,5 @@ export const adminNavItems: NavItem[] = [ { title: 'Customers', href: '/customers', icon: 'tabler-users', matchPrefix: '/customers' }, { title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' }, { title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' }, + { title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' }, ] diff --git a/website/resources/ts/types/index.ts b/website/resources/ts/types/index.ts index 4c74328..21c7164 100644 --- a/website/resources/ts/types/index.ts +++ b/website/resources/ts/types/index.ts @@ -111,4 +111,56 @@ export interface PaginationLink { active: boolean } +export interface Service { + id: number + user_id: number + subscription_id: number | null + plan_id: number + service_type: string + platform: string + platform_service_id: string | null + status: 'pending' | 'active' | 'suspended' | 'terminated' + ipv4_address: string | null + ipv6_address: string | null + hostname: string | null + domain: string | null + auto_renew: boolean + provisioned_at: string | null + suspended_at: string | null + terminated_at: string | null + created_at: string + updated_at: string + plan?: Plan + subscription?: Subscription +} + +export interface Coupon { + id: number + code: string + type: 'percentage' | 'fixed' + value: string + currency: string | null + applies_to: number[] | null + max_uses: number | null + times_used: number + active: boolean + expires_at: string | null + created_at: string + redemptions_count?: number +} + +export interface CouponRedemption { + id: number + coupon_id: number + user_id: number + subscription_id: number | null + discount_amount: string + created_at: string + user?: { + id: number + name: string + email: string + } +} + export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary' diff --git a/website/resources/ts/utils/resolvers.ts b/website/resources/ts/utils/resolvers.ts index fde98e7..dc868ce 100644 --- a/website/resources/ts/utils/resolvers.ts +++ b/website/resources/ts/utils/resolvers.ts @@ -31,6 +31,38 @@ export function resolveTransactionStatusColor(status: string): StatusColor { return map[status] ?? 'secondary' } +export function resolveServiceStatusColor(status: string): StatusColor { + const map: Record = { + active: 'success', + pending: 'warning', + suspended: 'error', + terminated: 'secondary', + } + return map[status] ?? 'secondary' +} + +export function resolveServiceTypeColor(type: string): StatusColor { + const map: Record = { + vps: 'info', + dedicated: 'success', + 'web-hosting': 'warning', + 'game-server': 'error', + } + return map[type] ?? 'secondary' +} + +export function resolvePlatformUrl(platform: string, platformServiceId: string | null): string | null { + if (!platformServiceId) return null + + const urls: Record = { + virtfusion: `https://panel.ezscale.cloud/server/${platformServiceId}`, + synergycp: `https://dedicated.ezscale.cloud/server/${platformServiceId}`, + enhance: `https://hosting.ezscale.cloud/server/${platformServiceId}`, + pterodactyl: `https://game.ezscale.cloud/server/${platformServiceId}`, + } + return urls[platform] ?? null +} + export function formatPrice(price: string | number, cycle?: string): string { const amount = parseFloat(String(price)).toFixed(2) return cycle ? `$${amount}/${cycle}` : `$${amount}` diff --git a/website/routes/account.php b/website/routes/account.php index 2444701..f99960e 100644 --- a/website/routes/account.php +++ b/website/routes/account.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Account\CheckoutController; use App\Http\Controllers\Account\DashboardController; use App\Http\Controllers\Account\PlanController; use App\Http\Controllers\Account\ProfileController; +use App\Http\Controllers\Account\ServiceController; use App\Http\Controllers\Account\SubscriptionController; use Illuminate\Support\Facades\Route; @@ -28,6 +29,9 @@ Route::get('/checkout/paypal/cancel', [CheckoutController::class, 'paypalCancel' Route::get('/checkout/{plan}', [CheckoutController::class, 'show'])->name('account.checkout.show'); Route::post('/checkout/{plan}', [CheckoutController::class, 'store'])->name('account.checkout.store'); +// Services +Route::resource('services', ServiceController::class)->only(['index', 'show'])->names('account.services'); + // Subscriptions Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index'); Route::get('/subscriptions/{subscription}', [SubscriptionController::class, 'show'])->name('account.subscriptions.show'); diff --git a/website/routes/admin.php b/website/routes/admin.php index 633e456..f105786 100644 --- a/website/routes/admin.php +++ b/website/routes/admin.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Http\Controllers\Admin\CouponController; use App\Http\Controllers\Admin\CustomerController; use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\InvoiceController; @@ -31,3 +32,12 @@ Route::post('services/{service}/terminate', [ServiceController::class, 'terminat Route::resource('invoices', InvoiceController::class)->only(['index', 'show']); Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void'])->name('invoices.void'); + +Route::resource('coupons', CouponController::class)->names([ + 'index' => 'admin.coupons.index', + 'create' => 'admin.coupons.create', + 'store' => 'admin.coupons.store', + 'edit' => 'admin.coupons.edit', + 'update' => 'admin.coupons.update', + 'destroy' => 'admin.coupons.destroy', +])->except(['show']);