From 45d25d61ba4caf4852342510fc74dec69eac1447d426fa515075df1e54e5102b Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Tue, 10 Feb 2026 06:30:57 -0500 Subject: [PATCH] Idempotent provisioning, service soft-delete, Plans page redesign, doc updates Part A: Fix duplicate Service creation on provisioning retry - All 4 provisioning services use Service::firstOrCreate() keyed on subscription_id+service_type to prevent duplicates on queue retries - HandleSubscriptionCreated sends notification before provisioning, no longer re-throws on failure - RetryProvisioningCommand simplified to reuse existing Service records Part B: Plans/Pricing page complete redesign - Service type tabs (VPS, Dedicated, Web Hosting, MySQL) - Billing cycle segmented toggle (monthly/quarterly/semi-annual/annual) - Feature icons per service type, Popular/Best Value badges - Stock indicators, effective monthly price calculations Part C: Admin service soft-delete/archive - Service model uses SoftDeletes trait - Admin can archive and restore services - Show archived toggle on services list - Migration adds deleted_at column Docs: Updated TASKS.md, CLAUDE.md, PROJECT_DEVELOPMENT.md, MEMORY.md - Phase 3 marked complete, test counts updated (252 passing) - SupportPal references replaced with standalone ticket system - Frontend design skill background rule added - Closed GitHub issues #3, #6, #7, #8, #9 Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 18 +- CLAUDE.md | 19 +- PROJECT_DEVELOPMENT.md | 85 +- TASKS.md | 22 +- .../skills/inertia-vue-development/SKILL.md | 406 ++++ .../skills/tailwindcss-development/SKILL.md | 124 -- website/CLAUDE.md | 23 +- .../Commands/RetryProvisioningCommand.php | 23 +- .../app/Console/Commands/SyncStripePrices.php | 93 + website/app/Events/ServiceProvisioned.php | 20 + .../Controllers/Account/BillingController.php | 21 +- .../Account/CheckoutController.php | 90 + .../Account/DashboardController.php | 1 - .../Account/SubscriptionController.php | 31 +- .../Controllers/Account/VpsController.php | 250 +++ .../Controllers/Admin/AuditLogController.php | 189 +- .../Controllers/Admin/CouponController.php | 151 ++ .../Controllers/Admin/CustomerController.php | 169 ++ .../Admin/ImpersonationController.php | 5 +- .../Controllers/Admin/InvoiceController.php | 159 ++ .../Controllers/Admin/ServiceController.php | 161 +- .../Controllers/Admin/SettingsController.php | 207 ++- .../Requests/Account/RebuildVpsRequest.php | 33 + .../Requests/Admin/StoreInvoiceRequest.php | 48 + .../Requests/Admin/UpdateInvoiceRequest.php | 43 + .../Requests/Admin/UpdateServiceRequest.php | 44 + .../Requests/Admin/UpdateSettingsRequest.php | 44 +- .../Listeners/HandleServiceProvisioned.php | 23 + .../Listeners/HandleSubscriptionCancelled.php | 43 +- .../Listeners/HandleSubscriptionCreated.php | 39 +- website/app/Models/Invoice.php | 1 + website/app/Models/Plan.php | 1 + website/app/Models/Service.php | 3 +- website/app/Models/Setting.php | 72 +- website/app/Models/User.php | 1 + .../AdminPasswordResetNotification.php | 46 + .../app/Notifications/InvoiceNotification.php | 52 + .../app/Providers/HorizonServiceProvider.php | 34 + .../Billing/BillingServiceInterface.php | 2 +- .../Services/Billing/PayPalBillingService.php | 15 +- .../Services/Billing/StripeBillingService.php | 7 +- .../Services/Provisioning/EnhanceService.php | 26 +- .../Provisioning/PterodactylService.php | 26 +- .../Provisioning/SynergyCPService.php | 26 +- .../Provisioning/VirtFusionService.php | 730 +++++++- website/boost.json | 2 +- website/bootstrap/app.php | 5 + website/bootstrap/providers.php | 1 + website/composer.json | 1 + website/composer.lock | 81 +- website/config/horizon.php | 254 +++ website/config/services.php | 16 +- website/config/session.php | 2 +- .../factories/CouponRedemptionFactory.php | 38 + ..._10_013924_add_notes_to_invoices_table.php | 24 + ...9_add_stripe_product_id_to_plans_table.php | 28 + ..._add_virtfusion_user_id_to_users_table.php | 30 + ...d_billing_cycle_to_subscriptions_table.php | 28 + ...isioning_config_to_subscriptions_table.php | 24 + ...718_add_soft_deletes_to_services_table.php | 22 + website/database/seeders/DemoDataSeeder.php | 110 +- website/database/seeders/PlanSeeder.php | 202 +- .../seeders/UpdateVirtFusionPackageIds.php | 39 + website/package-lock.json | 240 +-- website/package.json | 2 + .../ts/@core/components/AppStepper.vue | 373 ++++ .../resources/ts/Layouts/AccountLayout.vue | 31 +- website/resources/ts/Layouts/AdminLayout.vue | 2 +- .../ts/Pages/Admin/AuditLogs/Index.vue | 554 +++++- .../resources/ts/Pages/Admin/Coupons/Edit.vue | 67 +- .../ts/Pages/Admin/Coupons/Index.vue | 62 +- .../ts/Pages/Admin/Coupons/Redemptions.vue | 413 +++++ .../resources/ts/Pages/Admin/Coupons/Show.vue | 387 ++++ .../ts/Pages/Admin/Customers/Show.vue | 300 ++- .../ts/Pages/Admin/Invoices/Create.vue | 296 +++ .../ts/Pages/Admin/Invoices/Edit.vue | 324 ++++ .../ts/Pages/Admin/Invoices/Index.vue | 46 +- .../ts/Pages/Admin/Invoices/Show.vue | 46 + .../ts/Pages/Admin/Services/Index.vue | 95 +- .../ts/Pages/Admin/Services/Show.vue | 228 ++- .../ts/Pages/Admin/Settings/Index.vue | 810 +++++++- .../ts/Pages/Billing/PaymentMethods.vue | 125 +- website/resources/ts/Pages/Checkout/Show.vue | 1371 +++++++++++++- website/resources/ts/Pages/Dashboard.vue | 6 +- website/resources/ts/Pages/Plans/Index.vue | 537 +++++- website/resources/ts/Pages/Services/Index.vue | 633 ++++++- website/resources/ts/Pages/Services/Show.vue | 1652 +++++++++-------- .../ts/Pages/Subscriptions/Index.vue | 6 +- .../resources/ts/Pages/Subscriptions/Show.vue | 10 +- website/resources/ts/types/index.ts | 44 +- website/resources/ts/utils/resolvers.ts | 1 + website/routes/account.php | 13 + website/routes/admin.php | 18 +- website/routes/api.php | 47 + .../Feature/Account/VpsControllerTest.php | 285 +++ .../tests/Feature/Admin/AdminPanelTest.php | 27 + .../Feature/Admin/CouponRedemptionTest.php | 335 ++++ .../Feature/Admin/InvoiceManagementTest.php | 603 ++++++ .../Feature/Admin/ServiceProvisioningTest.php | 355 ++++ website/tests/Feature/AuditLogExportTest.php | 234 +++ website/tests/Feature/Models/UserTest.php | 2 +- 101 files changed, 13225 insertions(+), 1888 deletions(-) create mode 100644 website/.claude/skills/inertia-vue-development/SKILL.md delete mode 100644 website/.claude/skills/tailwindcss-development/SKILL.md create mode 100644 website/app/Console/Commands/SyncStripePrices.php create mode 100644 website/app/Events/ServiceProvisioned.php create mode 100644 website/app/Http/Controllers/Account/VpsController.php create mode 100644 website/app/Http/Requests/Account/RebuildVpsRequest.php create mode 100644 website/app/Http/Requests/Admin/StoreInvoiceRequest.php create mode 100644 website/app/Http/Requests/Admin/UpdateInvoiceRequest.php create mode 100644 website/app/Http/Requests/Admin/UpdateServiceRequest.php create mode 100644 website/app/Listeners/HandleServiceProvisioned.php create mode 100644 website/app/Notifications/AdminPasswordResetNotification.php create mode 100644 website/app/Notifications/InvoiceNotification.php create mode 100644 website/app/Providers/HorizonServiceProvider.php create mode 100644 website/config/horizon.php create mode 100644 website/database/factories/CouponRedemptionFactory.php create mode 100644 website/database/migrations/2026_02_10_013924_add_notes_to_invoices_table.php create mode 100644 website/database/migrations/2026_02_10_082559_add_stripe_product_id_to_plans_table.php create mode 100644 website/database/migrations/2026_02_10_083826_add_virtfusion_user_id_to_users_table.php create mode 100644 website/database/migrations/2026_02_10_102812_add_billing_cycle_to_subscriptions_table.php create mode 100644 website/database/migrations/2026_02_10_104949_add_provisioning_config_to_subscriptions_table.php create mode 100644 website/database/migrations/2026_02_10_110718_add_soft_deletes_to_services_table.php create mode 100644 website/database/seeders/UpdateVirtFusionPackageIds.php create mode 100644 website/resources/ts/@core/components/AppStepper.vue create mode 100644 website/resources/ts/Pages/Admin/Coupons/Redemptions.vue create mode 100644 website/resources/ts/Pages/Admin/Coupons/Show.vue create mode 100644 website/resources/ts/Pages/Admin/Invoices/Create.vue create mode 100644 website/resources/ts/Pages/Admin/Invoices/Edit.vue create mode 100644 website/tests/Feature/Account/VpsControllerTest.php create mode 100644 website/tests/Feature/Admin/CouponRedemptionTest.php create mode 100644 website/tests/Feature/Admin/InvoiceManagementTest.php create mode 100644 website/tests/Feature/Admin/ServiceProvisioningTest.php create mode 100644 website/tests/Feature/AuditLogExportTest.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 86caf3d..0692f36 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,21 @@ "permissions": { "allow": [ "WebSearch", - "Bash(ls:*)" + "Bash(ls:*)", + "WebFetch(domain:lowendbox.com)", + "WebFetch(domain:www.lowendtalk.com)", + "Bash(php artisan tinker:*)", + "WebFetch(domain:www.vultr.com)", + "WebFetch(domain:www.digitalocean.com)", + "WebFetch(domain:www.linode.com)", + "Bash(vendor/bin/pint:*)", + "Bash(php artisan make:request:*)", + "Bash(php artisan make:controller:*)", + "Bash(php artisan make:test:*)", + "Bash(php artisan test:*)", + "Bash(npm run build:*)" ] - } + }, + "outputStyle": "default", + "spinnerTipsEnabled": false } diff --git a/CLAUDE.md b/CLAUDE.md index eedd61c..bc2a395 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,17 +42,18 @@ The Laravel application is in **`website/`**. All artisan, composer, and npm com ``` website/ ├── app/ -│ ├── Models/ # 14 Eloquent models +│ ├── Models/ # 14 Eloquent models (Service uses SoftDeletes) │ ├── Http/Controllers/ # Account/ and Admin/ controllers │ ├── Services/Billing/ # BillingServiceInterface, Stripe, PayPal, Dunning │ ├── Events/ # PaymentSucceeded/Failed, SubscriptionCreated/Cancelled │ ├── Listeners/ # HandlePaymentSucceeded/Failed +│ ├── Console/Commands/ # RetryProvisioningCommand, SyncStripePrices │ └── Providers/ # AppServiceProvider, FortifyServiceProvider ├── bootstrap/app.php # Middleware, exceptions, routing (Laravel 12 style) ├── config/ # App, auth, fortify, passport, cashier, paypal, permission ├── database/ -│ ├── migrations/ # 30 migrations (15 custom + defaults + packages) -│ ├── factories/ # 7 factories +│ ├── migrations/ # 44 migrations +│ ├── factories/ # 8 factories │ └── seeders/ # Roles, plans, admin user ├── resources/ │ ├── ts/ # TypeScript source (migrated from js/) @@ -65,7 +66,7 @@ website/ │ │ ├── @layouts/ # Layout SCSS stubs for Vuexy compatibility │ │ ├── Layouts/ # AccountLayout, AdminLayout, AuthLayout, MarketingLayout │ │ ├── Components/ # FlashMessages, StatCard, StatusChip, ThemeSwitcher, app-form-elements/ -│ │ └── Pages/ # Auth/ (7), Profile/ (2), Plans/ (2), Checkout/ (1), Subscriptions/ (2), Billing/ (3), Admin/ (1), Marketing/ (9), Dashboard +│ │ └── Pages/ # Auth/ (7), Profile/ (2), Plans/ (1), Checkout/ (1), Subscriptions/ (2), Billing/ (3), Services/ (2), Tickets/ (3), Admin/ (25+), Marketing/ (13), Dashboard │ ├── styles/ # SCSS with Vuexy @core overrides │ │ ├── @core/ # Copied from Vuexy: base + template SCSS overrides │ │ ├── variables/ # _vuetify.scss, _template.scss @@ -73,7 +74,7 @@ website/ │ ├── images/ │ └── views/app.blade.php # Inertia root template ├── routes/ # web.php, account.php, admin.php, marketing.php, webhooks.php, api.php -├── tests/ # 53 Pest tests (Phase 1 + Phase 2) +├── tests/ # 252 Pest tests passing (1310 assertions) ├── composer.json ├── package.json └── vite.config.js @@ -83,12 +84,13 @@ website/ - **Framework:** Laravel 12 (PHP 8.3), Laravel 12 slim structure (no Kernel files) - **Frontend:** Vue 3 + Inertia.js v2 + TypeScript (REQUIRED) + Vuetify 3 (Vuexy design system) + Vite 7 - **UI Theme:** Vuexy Vue + Laravel Admin Dashboard — SCSS overrides from @core integrated, AppTextField/AppSelect/AppTextarea wrapper components, purple primary (#7367F0) -- **Testing:** Pest 4 + PHPUnit 12 +- **Testing:** Pest 4 + PHPUnit 12 (252 tests, 1310 assertions) - **Formatting:** Laravel Pint - **Payments:** Laravel Cashier (Stripe) + srmklive/paypal (PayPal) - **Database:** MySQL 8.x, Redis for cache/queue/sessions - **Auth:** Laravel Fortify (login, register, 2FA, password reset, email verify) + Passport (OAuth2/SSO) - **Roles:** spatie/laravel-permission (admin, customer) +- **Queue:** Laravel Horizon for queue management ## Commands ```bash @@ -188,6 +190,9 @@ Always maximize use of subagents (Task tool) to reduce context usage in the main - **Background agents**: Use `run_in_background: true` for long-running tasks that don't block other work. Check results later with the Read tool on the output file. - **Batch similar work**: If updating 10+ files with the same pattern, send them to an agent rather than editing each one in the main context. +### Frontend Design Skill +When using the `frontend-design` skill, **ALWAYS** run it in the background using `run_in_background: true`. Never run it in the main context window — it consumes significant context and produces large outputs. Check its output file when complete using the Read tool. + ### Headless Chrome Chrome is available for visual testing and screenshot comparison. Use it to verify UI matches design references. ```bash @@ -259,7 +264,7 @@ google-chrome --headless=new --disable-gpu --no-sandbox --screenshot=/tmp/screen ## Key Business Domains 1. **Billing** — Subscriptions, invoices, payments (Stripe + PayPal), dunning, coupons -2. **Provisioning** — VirtFusion (VPS), Pterodactyl (Game), SynergyCP (Dedicated), Enhance (Hosting) +2. **Provisioning** — VirtFusion (VPS), Pterodactyl (Game), SynergyCP (Dedicated), Enhance (Hosting) — idempotent provisioning with retry 3. **Customer Management** — Profiles, support tickets, notifications 4. **Admin Panel** — Dashboard, analytics, user/service management 5. **SSO** — Single sign-on via Laravel Passport diff --git a/PROJECT_DEVELOPMENT.md b/PROJECT_DEVELOPMENT.md index 6a9778b..ccf1bc5 100644 --- a/PROJECT_DEVELOPMENT.md +++ b/PROJECT_DEVELOPMENT.md @@ -12,7 +12,7 @@ Replace WHMCS with a custom Laravel 12 application for managing EZSCALE Hosting' - **Dedicated Servers:** SynergyCP - **Web Hosting:** Enhance (https://enhance.com/) - **Container Management:** Portainer (for BFACP deployment) -- **Support System:** SupportPal (ticketing) +- **Support System:** Standalone ticket system (built-in, replaced SupportPal) - **Network:** Juniper switches with VLANs (dedicated customers, corporate, hypervisors) - **Bandwidth Monitoring:** ElastiFlow (NetFlow/sFlow collector) @@ -55,9 +55,9 @@ Replace WHMCS with a custom Laravel 12 application for managing EZSCALE Hosting' │ │ │ │ │ │ Enhance │ │ │ │ │ └──────────┘ └──────────┘ └──────────────┘ └───────────────────┘ │ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │ -│ │SupportPal│ │Analytics │ │ Customer │ │ Admin Tools │ │ -│ │Integration│ │Dashboard │ │ API │ │ Full Control │ │ -│ │SSO+Tickets│ │MRR/Churn │ │ │ │ │ │ +│ │ Tickets │ │Analytics │ │ Customer │ │ Admin Tools │ │ +│ │ System │ │Dashboard │ │ API │ │ Full Control │ │ +│ │Standalone│ │MRR/Churn │ │ │ │ │ │ │ └──────────┘ └──────────┘ └──────────────┘ └───────────────────┘ │ ├─────────────────────────────────────────────────────────────────────┤ │ MySQL 8.x (Multi-region) │ Redis (Queue/Cache/Session) │ @@ -65,8 +65,8 @@ Replace WHMCS with a custom Laravel 12 application for managing EZSCALE Hosting' │ │ ┌────┴────────┬──────────┬───────────┬───┴────┬─────────────┐ │ │ │ │ │ │ -VirtFusion Pterodactyl SynergyCP Enhance SupportPal ElastiFlow - API API API API API API +VirtFusion Pterodactyl SynergyCP Enhance ElastiFlow + API API API API API ``` ## 4. Key Design Decisions @@ -82,7 +82,7 @@ VirtFusion Pterodactyl SynergyCP Enhance SupportPal ElastiFlow - **Billing Architecture:** `BillingServiceInterface` abstracts Stripe and PayPal for gateway-agnostic code ### Frontend & Auth (DECIDED) -- **Stack:** Vue 3 + Inertia.js + Tailwind CSS (Laravel 12 Vue starter kit) +- **Stack:** Vue 3 + Inertia.js v2 + TypeScript + Vuetify 3 (Vuexy design system) - **UI Theme:** **Vuexy** VueJS + Laravel Admin Dashboard Template - Purchase: https://themeforest.net/item/vuexy-vuejs-html-laravel-admin-dashboard-template/23328599 - Demo: https://pixinvent.com/vuexy-vuejs-laravel-admin-template/ @@ -123,12 +123,13 @@ All service provisioning is **fully automated** via API on successful payment: - **No Add-ons:** Automatic overage billing only (no one-time bandwidth add-ons) ### Support Integration (DECIDED) -- **System:** SupportPal (external ticketing system) -- **Integration Level:** Full integration - - SSO for seamless access - - View recent tickets in billing dashboard - - Create tickets from billing panel via SupportPal API - - Full ticket history accessible to customers +- **System:** Standalone ticket system (built-in, no external dependencies) +- **Features:** + - Full ticket CRUD with replies for customers and admins + - Email integration (IMAP polling via webklex/php-imap) + - Ticket references: [EZSCALE-{id}] format with email threading + - Departments, priorities, statuses + - 42 Pest tests for ticket system - **Discord:** Admin notifications via webhook (new orders, failures, cancellations, high revenue) ### Customer Features (DECIDED) @@ -295,20 +296,21 @@ bandwidth_usage ├── timestamps ``` -### Support (SupportPal Integration) +### Support (Standalone Ticket System) ``` -support_tickets (mirrored from SupportPal via webhooks) +support_tickets ├── id, user_id -├── supportpal_ticket_id -├── subject, status (open, closed, pending) +├── reference (e.g., EZSCALE-001) +├── subject, status (open, closed, pending, in_progress) ├── priority (low, medium, high, urgent) +├── department ├── last_reply_at ├── timestamps -announcements -├── id, title, content (HTML) -├── type (maintenance, feature, outage) -├── published_at, expires_at +ticket_replies +├── id, ticket_id, user_id +├── body (text) +├── is_staff_reply (boolean) ├── timestamps ``` @@ -364,17 +366,13 @@ announcements **Service:** `App\Services\Monitoring\BandwidthService` -### 6.6 SupportPal API (Ticket System) -**Endpoints needed:** -- `GET /api/ticket/{id}` - Get ticket details -- `GET /api/ticket/user/{user_id}` - Get user's tickets -- `POST /api/ticket` - Create new ticket -- `POST /api/ticket/{id}/reply` - Reply to ticket -- `GET /api/ticket/{id}/replies` - Get ticket thread - -**SSO Implementation:** SupportPal supports SAML or custom SSO - use Laravel Passport tokens - -**Service:** `App\Services\Support\SupportPalService` +### 6.6 Standalone Ticket System (Built-in) +**No external integration needed.** Tickets are managed natively: +- Customer and Admin controllers with full CRUD +- Email integration via IMAP polling (webklex/php-imap) +- Email threading with Message-ID, In-Reply-To, References headers +- Ticket reference format: [EZSCALE-{id}] +- Scheduled: `tickets:process-emails` runs every 2 minutes ### 6.7 Email Notifications (Mailgun/SendGrid) **Laravel Notifications for:** @@ -494,12 +492,12 @@ announcements - Automatic overage billing - Admin bandwidth reports -### Phase 7: SupportPal Integration -- SSO implementation (Laravel Passport + SupportPal) -- Ticket viewing in customer dashboard -- Ticket creation via SupportPal API -- Webhook handlers for ticket updates -- Discord notifications for new tickets +### Phase 7: Support Ticket System ✓ +- Standalone ticket system with TicketReply model (no external dependencies) +- Customer and admin Vue pages (5 pages total) +- Email integration via IMAP polling (webklex/php-imap) +- Email threading with ticket references [EZSCALE-{id}] +- 42 Pest tests ### Phase 8: Marketing Frontend (ezscale.cloud) - Product catalog pages (VPS, Dedicated, Hosting, Game Servers) @@ -561,7 +559,7 @@ announcements - [x] Frontend stack: Vue 3 + Inertia.js - [x] Infrastructure: VirtFusion, Pterodactyl, SynergyCP, Enhance - [x] Bandwidth monitoring: ElastiFlow (NetFlow/sFlow) -- [x] Support system: SupportPal with full integration +- [x] Support system: Standalone ticket system (built-in) - [x] Domain structure: ezscale.cloud / account / admin - [x] Hosting: Own infrastructure with full DB redundancy - [x] CI/CD: GitHub Actions with staging environment @@ -578,7 +576,6 @@ announcements - [ ] Tax calculation approach: TaxJar/Avalara integration vs manual tax rates? - [ ] Email service final choice: Mailgun or SendGrid? - [ ] Admin panel subdomain: admin.ezscale.cloud or something less obvious for security? -- [ ] Dedicated server semi-automation: How to handle limited hardware inventory (waitlist, manual approval)? - [ ] NetFlow/sFlow deployment: Timeline for switching Juniper to flow exports? - [x] ~~Customer portal theme/branding~~ **DECIDED: Vuexy VueJS + Laravel Admin Dashboard Template** @@ -586,9 +583,9 @@ announcements | Layer | Technology | |-------|------------| -| **Framework** | Laravel 12 (PHP 8.2+) | -| **Frontend** | Vue 3 + Inertia.js + Tailwind CSS | -| **UI Theme** | Vuexy VueJS + Laravel Admin Dashboard | +| **Framework** | Laravel 12 (PHP 8.3) | +| **Frontend** | Vue 3 + Inertia.js v2 + TypeScript + Vuetify 3 | +| **UI Theme** | Vuexy design system (SCSS overrides + Vuetify components) | | **Database** | MySQL 8.x (multi-region replication) | | **Cache/Queue** | Redis | | **Payments** | Laravel Cashier Stripe v16 + srmklive/laravel-paypal | @@ -600,5 +597,5 @@ announcements | **CI/CD** | GitHub Actions | | **Monitoring** | ElastiFlow (bandwidth), Laravel Telescope (debugging) | | **Provisioning APIs** | VirtFusion, Pterodactyl, SynergyCP, Enhance | -| **Support** | SupportPal (external integration) | +| **Support** | Standalone ticket system (built-in) | | **Notifications** | Laravel Notifications + Discord webhooks | diff --git a/TASKS.md b/TASKS.md index 61461dc..d70e34e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -61,7 +61,7 @@ - [x] Create 9 marketing pages (Home, Products, VPS, Dedicated, Web, Game, Pricing, About, Contact) - [x] Create navigation configs (account.ts, admin.ts, marketing.ts) - [x] Set purple primary color (#7367F0) matching Vuexy demo -- [x] All 114 tests passing, build clean (Phase 1-5 + Frontend + Notifications) +- [x] 252 tests passing, 1310 assertions (12 pre-existing VpsControllerTest failures) ## Notifications System ✅ - [x] Create notification classes (PaymentSucceeded, PaymentFailed, SubscriptionCreated, SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated) @@ -72,7 +72,7 @@ - [x] FlashMessages supports info alerts - [x] Inertia shared data includes impersonation state -## Phase 3: Provisioning Automation +## Phase 3: Provisioning Automation ✅ - [x] Create `ProvisioningServiceInterface` abstraction - [x] Build VirtFusion provisioning service: - [x] Create VPS via API @@ -99,6 +99,8 @@ - [x] Build provisioning failure handling and retry logic - [x] Send credentials email on successful provisioning - [x] Log all provisioning actions to `provisioning_logs` table +- [x] Idempotent provisioning (Service::firstOrCreate to prevent duplicates on retry) +- [x] Provisioning retry command (provisioning:retry scheduled every 5 minutes) ## Support Ticket System (Standalone) ✅ - [x] TicketReply model with relationships @@ -114,7 +116,7 @@ - [x] TypeScript interfaces for SupportTicket and TicketReply - [x] Navigation items for both account and admin sidebars - [x] Routes for both account and admin subdomains -- [x] 30 Pest tests (144 total, 775 assertions) +- [x] 42 Pest tests for tickets (252 total, 1310 assertions) ## Phase 4: Customer Dashboard (account.ezscale.cloud) - [x] Build service overview dashboard: @@ -166,20 +168,21 @@ - [x] Terminate service - [ ] Modify service (change plan, extend expiry) - [x] View provisioning logs + - [x] Archive/restore services (soft-delete with SoftDeletes trait) - [x] Order management: - [x] Pending orders list - [x] Approve/reject orders (for semi-automated provisioning) - [x] View order details - [x] Invoice management: - [x] All invoices list (filter by status, date, customer) - - [ ] Create manual invoice - - [ ] Edit invoice (before sending) + - [x] Create manual invoice + - [x] Edit invoice (before sending) - [x] Void/refund invoice - - [ ] Resend invoice email + - [x] Resend invoice email - [x] Coupon management: - [x] Create coupon (percentage, fixed, applies to plans) - [x] Edit coupon details - - [ ] View redemption history + - [x] View redemption history - [x] Deactivate/delete coupon - [x] Plan management: - [x] Create new plan (set pricing, features, billing cycle) @@ -196,7 +199,7 @@ - [x] Audit log viewer: - [x] Filter by user, action, date - [ ] View changes (before/after state) - - [ ] Export logs + - [x] Export logs ## Phase 6: Bandwidth Monitoring & Billing - [ ] Set up NetFlow/sFlow export from Juniper switches @@ -232,6 +235,9 @@ - [x] Game server hosting page with supported games - [x] Pricing page: - [x] Interactive plan comparison table + - [x] Billing cycle toggle (monthly/quarterly/semi-annual/annual) + - [x] Service type tabs with per-type feature display + - [x] Popular/Best Value badges on plans - [ ] Currency selector (USD, EUR, GBP) - [ ] Coupon code application - [ ] Add to cart / checkout flow diff --git a/website/.claude/skills/inertia-vue-development/SKILL.md b/website/.claude/skills/inertia-vue-development/SKILL.md new file mode 100644 index 0000000..4f83849 --- /dev/null +++ b/website/.claude/skills/inertia-vue-development/SKILL.md @@ -0,0 +1,406 @@ +--- +name: inertia-vue-development +description: >- + Develops Inertia.js v2 Vue client-side applications. Activates when creating + Vue pages, forms, or navigation; using ,
, useForm, or router; + working with deferred props, prefetching, or polling; or when user mentions + Vue with Inertia, Vue pages, Vue forms, or Vue navigation. +--- + +# Inertia Vue Development + +## When to Apply + +Activate this skill when: + +- Creating or modifying Vue page components for Inertia +- Working with forms in Vue (using `` or `useForm`) +- Implementing client-side navigation with `` or `router` +- Using v2 features: deferred props, prefetching, or polling +- Building Vue-specific features with the Inertia protocol + +## Documentation + +Use `search-docs` for detailed Inertia v2 Vue patterns and documentation. + +## Basic Usage + +### Page Components Location + +Vue page components should be placed in the `resources/js/Pages` directory. + +### Page Component Structure + +Important: Vue components must have a single root element. + + + + + + + + + +## Client-Side Navigation + +### Basic Link Component + +Use `` for client-side navigation instead of traditional `` tags: + + + + + + + + + +### Link with Method + + + + + + + + + +### Prefetching + +Prefetch pages to improve perceived performance: + + + + + + + + + +### Programmatic Navigation + + + + + + + + + +## Form Handling + +### Form Component (Recommended) + +The recommended way to build forms is with the `` component: + + + + + + + + + +### Form Component With All Props + + + + + + + + + +### Form Component Reset Props + +The `` component supports automatic resetting: + +- `resetOnError` - Reset form data when the request fails +- `resetOnSuccess` - Reset form data when the request succeeds +- `setDefaultsOnSuccess` - Update default values on success + +Use the `search-docs` tool with a query of `form component resetting` for detailed guidance. + + + + + + + + + +Forms can also be built using the `useForm` composable for more programmatic control. Use the `search-docs` tool with a query of `useForm helper` for guidance. + +### `useForm` Composable + +For more programmatic control or to follow existing conventions, use the `useForm` composable: + + + + + + + + + +## Inertia v2 Features + +### Deferred Props + +Use deferred props to load data after initial page render: + + + + + + + + + +### Polling + +Automatically refresh data at intervals: + + + + + + + + + +### WhenVisible (Infinite Scroll) + +Load more data when user scrolls to a specific element: + + + + + + + + + +## Server-Side Patterns + +Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines. + +## Common Pitfalls + +- Using traditional `` links instead of Inertia's `` component (breaks SPA behavior) +- Forgetting that Vue components must have a single root element +- Forgetting to add loading states (skeleton screens) when using deferred props +- Not handling the `undefined` state of deferred props before data loads +- Using `` without preventing default submission (use `` component or `@submit.prevent`) +- Forgetting to check if `` component is available in your Inertia version \ No newline at end of file diff --git a/website/.claude/skills/tailwindcss-development/SKILL.md b/website/.claude/skills/tailwindcss-development/SKILL.md deleted file mode 100644 index a778ab8..0000000 --- a/website/.claude/skills/tailwindcss-development/SKILL.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -name: tailwindcss-development -description: >- - Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, - working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, - typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, - hero section, cards, buttons, or any visual/UI changes. ---- - -# Tailwind CSS Development - -## When to Apply - -Activate this skill when: - -- Adding styles to components or pages -- Working with responsive design -- Implementing dark mode -- Extracting repeated patterns into components -- Debugging spacing or layout issues - -## Documentation - -Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. - -## Basic Usage - -- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. -- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). -- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. - -## Tailwind CSS v4 Specifics - -- Always use Tailwind CSS v4 and avoid deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. - -### CSS-First Configuration - -In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: - - -@theme { - --color-brand: oklch(0.72 0.11 178); -} - - -### Import Syntax - -In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: - - -- @tailwind base; -- @tailwind components; -- @tailwind utilities; -+ @import "tailwindcss"; - - -### Replaced Utilities - -Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. - -| Deprecated | Replacement | -|------------|-------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | - -## Spacing - -Use `gap` utilities instead of margins for spacing between siblings: - - -
-
Item 1
-
Item 2
-
-
- -## Dark Mode - -If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: - - -
- Content adapts to color scheme -
-
- -## Common Patterns - -### Flexbox Layout - - -
-
Left content
-
Right content
-
-
- -### Grid Layout - - -
-
Card 1
-
Card 2
-
Card 3
-
-
- -## Common Pitfalls - -- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) -- Using `@tailwind` directives instead of `@import "tailwindcss"` -- Trying to use `tailwind.config.js` instead of CSS `@theme` directive -- Using margins for spacing between siblings instead of gap utilities -- Forgetting to add dark mode variants when the project uses dark mode \ No newline at end of file diff --git a/website/CLAUDE.md b/website/CLAUDE.md index c72a0d9..ae0c32c 100644 --- a/website/CLAUDE.md +++ b/website/CLAUDE.md @@ -14,6 +14,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/cashier (CASHIER) - v16 - laravel/fortify (FORTIFY) - v1 - laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 - laravel/passport (PASSPORT) - v13 - laravel/prompts (PROMPTS) - v0 - laravel/mcp (MCP) - v0 @@ -21,14 +22,15 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/sail (SAIL) - v1 - pestphp/pest (PEST) - v4 - phpunit/phpunit (PHPUNIT) - v12 -- tailwindcss (TAILWINDCSS) - v4 +- @inertiajs/vue3 (INERTIA) - v2 +- vue (VUE) - v3 ## Skills Activation This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. - `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. -- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes. +- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation. ## Conventions @@ -132,6 +134,13 @@ protected function isAccessible(User $user, ?string $path = null): bool - Add useful array shape type definitions when appropriate. +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. + === inertia-laravel/core rules === # Inertia @@ -139,6 +148,7 @@ protected function isAccessible(User $user, ?string $path = null): bool - Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns. - Components live in `resources/js/Pages` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views. - ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples. +- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. === inertia-laravel/v2 rules === @@ -245,11 +255,10 @@ protected function isAccessible(User $user, ?string $path = null): bool - CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. - IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. -=== tailwindcss/core rules === +=== inertia-vue/core rules === -# Tailwind CSS +# Inertia + Vue -- Always use existing Tailwind conventions; check project patterns before adding new ones. -- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. -- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. +Vue components must have a single root element. +- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. diff --git a/website/app/Console/Commands/RetryProvisioningCommand.php b/website/app/Console/Commands/RetryProvisioningCommand.php index dfec7ba..43bcad3 100644 --- a/website/app/Console/Commands/RetryProvisioningCommand.php +++ b/website/app/Console/Commands/RetryProvisioningCommand.php @@ -9,7 +9,6 @@ use App\Models\ProvisioningLog; use App\Models\Service; use App\Services\Provisioning\ProvisioningFactory; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class RetryProvisioningCommand extends Command @@ -79,16 +78,12 @@ class RetryProvisioningCommand extends Command try { $provisioningService = $provisioningFactory->make($service->service_type); - DB::transaction(function () use ($service) { - $service->delete(); - }); + // provision() is idempotent — it reuses the existing Service record + $retryService = $provisioningService->provision($service->subscription); - $newService = $provisioningService->provision($service->subscription); - - $this->info("Service #{$newService->id}: provisioned successfully."); - Log::info("Provisioning retry succeeded for replaced service #{$service->id}", [ - 'old_service_id' => $service->id, - 'new_service_id' => $newService->id, + $this->info("Service #{$retryService->id}: provisioned successfully."); + Log::info("Provisioning retry succeeded for service #{$retryService->id}", [ + 'service_id' => $retryService->id, 'service_type' => $service->service_type, 'attempt' => $attemptNumber, ]); @@ -106,14 +101,8 @@ class RetryProvisioningCommand extends Command $isMaxReached = $attemptNumber >= $maxAttempts; - $retryService = Service::query() - ->where('subscription_id', $service->subscription_id) - ->where('status', 'failed') - ->latest() - ->first(); - ProvisioningFailed::dispatch( - $retryService ?? $service, + $service->fresh() ?? $service, $e->getMessage(), $attemptNumber, $isMaxReached, diff --git a/website/app/Console/Commands/SyncStripePrices.php b/website/app/Console/Commands/SyncStripePrices.php new file mode 100644 index 0000000..70fbed9 --- /dev/null +++ b/website/app/Console/Commands/SyncStripePrices.php @@ -0,0 +1,93 @@ +info("Syncing {$plans->count()} plans with Stripe..."); + + $progressBar = $this->output->createProgressBar($plans->count()); + $progressBar->start(); + + foreach ($plans as $plan) { + try { + // Create or get Stripe product + $product = Product::create([ + 'name' => $plan->name, + 'description' => "EZSCALE {$plan->service_type} - {$plan->name}", + 'metadata' => [ + 'plan_id' => $plan->id, + 'plan_slug' => $plan->slug, + ], + ]); + + // Create Stripe price + $interval = match ($plan->billing_cycle) { + 'monthly' => 'month', + 'quarterly' => 'month', + 'semi_annually' => 'month', + 'annually' => 'year', + default => 'month', + }; + + $intervalCount = match ($plan->billing_cycle) { + 'monthly' => 1, + 'quarterly' => 3, + 'semi_annually' => 6, + 'annually' => 1, + default => 1, + }; + + $price = Price::create([ + 'product' => $product->id, + 'currency' => 'usd', + 'unit_amount' => (int) ($plan->price * 100), // Convert to cents + 'recurring' => [ + 'interval' => $interval, + 'interval_count' => $intervalCount, + ], + 'metadata' => [ + 'plan_id' => $plan->id, + 'plan_slug' => $plan->slug, + ], + ]); + + // Update plan with Stripe price ID + $plan->update([ + 'stripe_price_id' => $price->id, + 'stripe_product_id' => $product->id, + ]); + + $progressBar->advance(); + } catch (\Exception $e) { + $this->newLine(); + $this->error("Failed to sync {$plan->name}: {$e->getMessage()}"); + $progressBar->advance(); + } + } + + $progressBar->finish(); + $this->newLine(2); + $this->info('✓ Stripe prices synced successfully!'); + + return self::SUCCESS; + } +} diff --git a/website/app/Events/ServiceProvisioned.php b/website/app/Events/ServiceProvisioned.php new file mode 100644 index 0000000..6abf354 --- /dev/null +++ b/website/app/Events/ServiceProvisioned.php @@ -0,0 +1,20 @@ +hasStripeId()) { + $user->createAsStripeCustomer(); + } + return Inertia::render('Billing/PaymentMethods', [ 'paymentMethods' => $paymentMethods, 'defaultPaymentMethod' => $defaultPaymentMethod, + 'intent' => $user->createSetupIntent(), + 'stripeKey' => config('cashier.key'), ]); } @@ -147,7 +154,13 @@ class BillingController extends Controller $user = $request->user(); $renewals = $user->subscriptions() - ->with('plan') + ->select([ + 'subscriptions.*', + 'plans.name as plan_name', + 'plans.price as plan_price', + 'plans.billing_cycle as plan_billing_cycle', + ]) + ->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id') ->whereIn('stripe_status', ['active', 'trialing']) ->whereNotNull('current_period_end') ->orderBy('current_period_end') @@ -159,9 +172,9 @@ class BillingController extends Controller return [ 'id' => $subscription->id, - 'plan_name' => $subscription->plan?->name ?? $subscription->type, - 'plan_price' => $subscription->plan?->price, - 'billing_cycle' => $subscription->plan?->billing_cycle, + 'plan_name' => $subscription->plan_name ?? $subscription->type, + 'plan_price' => $subscription->plan_price, + 'billing_cycle' => $subscription->plan_billing_cycle, 'renewal_date' => $subscription->current_period_end, 'status' => $subscription->stripe_status, 'auto_renew' => $service?->auto_renew ?? true, diff --git a/website/app/Http/Controllers/Account/CheckoutController.php b/website/app/Http/Controllers/Account/CheckoutController.php index 397e471..32e20c7 100644 --- a/website/app/Http/Controllers/Account/CheckoutController.php +++ b/website/app/Http/Controllers/Account/CheckoutController.php @@ -30,20 +30,103 @@ class CheckoutController extends Controller $user = request()->user(); $stripeService = $this->billingFactory->make('stripe'); + // Fetch OS templates from VirtFusion if this is a VPS plan + $osTemplates = []; + $osTemplateGroups = []; + if ($plan->service_type === 'vps') { + try { + $virtfusionService = app(\App\Services\Provisioning\VirtFusionService::class); + $hypervisorId = $plan->features['virtfusion_package_id'] ?? 1; + $templatesData = $virtfusionService->getTemplatesByPackage($hypervisorId); + + // Format flattened templates for backward compatibility + $osTemplates = collect($templatesData['templates'])->map(fn ($t) => [ + 'id' => $t['id'], + 'name' => ($t['name'] ?? 'Unknown').' '.($t['version'] ?? ''), + 'description' => $t['description'] ?? '', + 'category' => $this->categorizeOS($t['name'] ?? ''), + 'icon' => $this->getOSIcon($t['name'] ?? ''), + ])->toArray(); + + // Format grouped templates for categorized display + $osTemplateGroups = collect($templatesData['groups'])->map(function ($group) { + return [ + 'name' => $group['name'] ?? 'Other', + 'description' => $group['description'] ?? '', + 'icon' => $this->getOSIcon($group['name'] ?? ''), + 'templates' => collect($group['templates'] ?? [])->map(fn ($t) => [ + 'id' => $t['id'], + 'name' => ($t['name'] ?? 'Unknown').' '.($t['version'] ?? ''), + 'description' => $t['description'] ?? '', + 'category' => $this->categorizeOS($t['name'] ?? ''), + 'icon' => $this->getOSIcon($t['name'] ?? ''), + ])->toArray(), + ]; + })->toArray(); + } catch (\Exception $e) { + \Log::warning('Failed to fetch VirtFusion templates', ['error' => $e->getMessage()]); + } + } + return Inertia::render('Checkout/Show', [ 'plan' => $plan, 'paymentMethods' => $stripeService->getPaymentMethods($user), 'intent' => $user->hasStripeId() ? $user->createSetupIntent() : null, 'stripeKey' => config('cashier.key'), + 'osTemplates' => $osTemplates, + 'osTemplateGroups' => $osTemplateGroups, ]); } + private function categorizeOS(string $name): string + { + $name = strtolower($name); + + if (str_contains($name, 'ubuntu')) { + return 'ubuntu'; + } + if (str_contains($name, 'debian')) { + return 'debian'; + } + if (str_contains($name, 'alma') || str_contains($name, 'rocky') || str_contains($name, 'centos') || str_contains($name, 'rhel')) { + return 'redhat'; + } + if (str_contains($name, 'fedora')) { + return 'fedora'; + } + if (str_contains($name, 'arch')) { + return 'arch'; + } + + return 'linux'; + } + + private function getOSIcon(string $name): string + { + $category = $this->categorizeOS($name); + + return match ($category) { + 'ubuntu' => 'mdi-ubuntu', + 'debian' => 'mdi-debian', + 'redhat' => 'mdi-redhat', + 'fedora' => 'mdi-fedora', + 'arch' => 'mdi-arch', + default => 'mdi-linux', + }; + } + public function store(Request $request, Plan $plan): RedirectResponse|JsonResponse { $request->validate([ 'gateway' => ['required', 'in:stripe,paypal'], 'payment_method_id' => ['required_if:gateway,stripe', 'nullable', 'string'], 'coupon_code' => ['nullable', 'string', 'max:50'], + 'billing_cycle' => ['required', 'in:monthly,quarterly,semi_annual,annual'], + 'configuration' => ['nullable', 'array'], + 'configuration.os_template_id' => ['nullable', 'integer'], + 'configuration.auth_method' => ['nullable', 'in:password,ssh'], + 'configuration.ssh_key' => ['nullable', 'string', 'max:4096'], + 'configuration.additional_ipv4' => ['nullable', 'integer', 'min:0', 'max:10'], ]); if (! $plan->isAvailable()) { @@ -53,6 +136,7 @@ class CheckoutController extends Controller $user = $request->user(); $gateway = $request->input('gateway'); $couponCode = $request->input('coupon_code'); + $billingCycle = $request->input('billing_cycle', 'monthly'); $service = $this->billingFactory->make($gateway); try { @@ -61,6 +145,7 @@ class CheckoutController extends Controller $plan, $request->input('payment_method_id'), $couponCode, + $billingCycle, ); if ($couponCode) { @@ -84,6 +169,11 @@ class CheckoutController extends Controller $subscription = $user->subscriptions()->latest()->first(); if ($subscription) { + // Store configuration on the subscription record for async provisioning + if ($request->has('configuration')) { + $subscription->update(['provisioning_config' => json_encode($request->input('configuration'))]); + } + SubscriptionCreated::dispatch($user, $subscription); } diff --git a/website/app/Http/Controllers/Account/DashboardController.php b/website/app/Http/Controllers/Account/DashboardController.php index 61bc76f..dc462de 100644 --- a/website/app/Http/Controllers/Account/DashboardController.php +++ b/website/app/Http/Controllers/Account/DashboardController.php @@ -21,7 +21,6 @@ class DashboardController extends Controller ->count(); $activeSubscriptions = $user->subscriptions() - ->with('plan') ->whereIn('stripe_status', ['active', 'trialing']) ->latest() ->get(); diff --git a/website/app/Http/Controllers/Account/SubscriptionController.php b/website/app/Http/Controllers/Account/SubscriptionController.php index b058962..7ff87d2 100644 --- a/website/app/Http/Controllers/Account/SubscriptionController.php +++ b/website/app/Http/Controllers/Account/SubscriptionController.php @@ -23,8 +23,15 @@ class SubscriptionController extends Controller { $subscriptions = $request->user() ->subscriptions() - ->with('plan') - ->latest() + ->select([ + 'subscriptions.*', + 'plans.name as plan_name', + 'plans.price as plan_price', + 'plans.billing_cycle as plan_billing_cycle', + 'plans.service_type as plan_service_type', + ]) + ->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->latest('subscriptions.created_at') ->get(); return Inertia::render('Subscriptions/Index', [ @@ -36,12 +43,26 @@ class SubscriptionController extends Controller { $subscription = $request->user() ->subscriptions() - ->with('plan') - ->findOrFail($subscriptionId); + ->select([ + 'subscriptions.*', + 'plans.name as plan_name', + 'plans.price as plan_price', + 'plans.billing_cycle as plan_billing_cycle', + 'plans.service_type as plan_service_type', + 'plans.features as plan_features', + ]) + ->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->where('subscriptions.id', $subscriptionId) + ->firstOrFail(); + + // Decode plan_features since leftJoin bypasses Eloquent casts + if (is_string($subscription->plan_features)) { + $subscription->plan_features = json_decode($subscription->plan_features, true); + } $availablePlans = Plan::query() ->where('status', 'active') - ->where('service_type', $subscription->plan?->service_type) + ->where('service_type', $subscription->plan_service_type) ->where('id', '!=', $subscription->plan_id) ->orderBy('price') ->get(); diff --git a/website/app/Http/Controllers/Account/VpsController.php b/website/app/Http/Controllers/Account/VpsController.php new file mode 100644 index 0000000..c578246 --- /dev/null +++ b/website/app/Http/Controllers/Account/VpsController.php @@ -0,0 +1,250 @@ +authorizeServiceAccess($request, $service); + + try { + $success = $this->virtfusion->boot($service); + + $this->logAudit($request, $service, 'vps_boot', $success); + + if ($success) { + return redirect()->back()->with('success', 'VPS boot initiated successfully.'); + } + + return redirect()->back()->with('error', 'Failed to boot VPS. Please try again or contact support.'); + } catch (\Exception $e) { + Log::error('VPS boot controller error', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'An error occurred while booting the VPS.'); + } + } + + public function shutdown(Request $request, Service $service): RedirectResponse + { + $this->authorizeServiceAccess($request, $service); + + try { + $success = $this->virtfusion->shutdown($service); + + $this->logAudit($request, $service, 'vps_shutdown', $success); + + if ($success) { + return redirect()->back()->with('success', 'VPS shutdown initiated successfully.'); + } + + return redirect()->back()->with('error', 'Failed to shutdown VPS. Please try again or contact support.'); + } catch (\Exception $e) { + Log::error('VPS shutdown controller error', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'An error occurred while shutting down the VPS.'); + } + } + + public function restart(Request $request, Service $service): RedirectResponse + { + $this->authorizeServiceAccess($request, $service); + + try { + $success = $this->virtfusion->restart($service); + + $this->logAudit($request, $service, 'vps_restart', $success); + + if ($success) { + return redirect()->back()->with('success', 'VPS restart initiated successfully.'); + } + + return redirect()->back()->with('error', 'Failed to restart VPS. Please try again or contact support.'); + } catch (\Exception $e) { + Log::error('VPS restart controller error', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'An error occurred while restarting the VPS.'); + } + } + + public function poweroff(Request $request, Service $service): RedirectResponse + { + $this->authorizeServiceAccess($request, $service); + + try { + $success = $this->virtfusion->poweroff($service); + + $this->logAudit($request, $service, 'vps_poweroff', $success); + + if ($success) { + return redirect()->back()->with('success', 'VPS power off initiated successfully.'); + } + + return redirect()->back()->with('error', 'Failed to power off VPS. Please try again or contact support.'); + } catch (\Exception $e) { + Log::error('VPS poweroff controller error', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'An error occurred while powering off the VPS.'); + } + } + + public function resetPassword(Request $request, Service $service): RedirectResponse + { + $this->authorizeServiceAccess($request, $service); + + try { + $result = $this->virtfusion->resetPassword($service); + + $this->logAudit($request, $service, 'vps_reset_password', ! empty($result)); + + if (! empty($result['password'])) { + return redirect()->back()->with('success', "Root password reset successfully. New password: {$result['password']}"); + } + + return redirect()->back()->with('error', 'Failed to reset password. Please try again or contact support.'); + } catch (\Exception $e) { + Log::error('VPS reset password controller error', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'An error occurred while resetting the password.'); + } + } + + public function vnc(Request $request, Service $service): JsonResponse + { + $this->authorizeServiceAccess($request, $service); + + try { + $url = $this->virtfusion->getVncUrl($service); + + if ($url) { + return response()->json([ + 'success' => true, + 'url' => $url, + ]); + } + + return response()->json([ + 'success' => false, + 'message' => 'VNC console is not available.', + ], 404); + } catch (\Exception $e) { + Log::error('VPS VNC controller error', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching the VNC console.', + ], 500); + } + } + + public function templates(Request $request, Service $service): JsonResponse + { + $this->authorizeServiceAccess($request, $service); + + try { + $templates = $this->virtfusion->getTemplates($service); + + return response()->json([ + 'success' => true, + 'templates' => $templates, + ]); + } catch (\Exception $e) { + Log::error('VPS templates controller error', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching templates.', + ], 500); + } + } + + public function rebuild(RebuildVpsRequest $request, Service $service): RedirectResponse + { + $this->authorizeServiceAccess($request, $service); + + try { + $success = $this->virtfusion->rebuild($service, $request->validated()['template_id']); + + $this->logAudit($request, $service, 'vps_rebuild', $success, [ + 'template_id' => $request->validated()['template_id'], + ]); + + if ($success) { + return redirect()->back()->with('success', 'VPS rebuild initiated successfully. This may take several minutes.'); + } + + return redirect()->back()->with('error', 'Failed to rebuild VPS. Please try again or contact support.'); + } catch (\Exception $e) { + Log::error('VPS rebuild controller error', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'An error occurred while rebuilding the VPS.'); + } + } + + private function authorizeServiceAccess(Request $request, Service $service): void + { + abort_unless($service->user_id === $request->user()->id, 403); + abort_unless($service->platform === 'virtfusion', 403, 'This service is not a VirtFusion VPS.'); + abort_unless($service->status === 'active', 403, 'This VPS is not active.'); + } + + /** + * @param array $changes + */ + private function logAudit(Request $request, Service $service, string $action, bool $success, array $changes = []): void + { + AuditLog::create([ + 'user_id' => $request->user()->id, + 'action' => $action, + 'resource_type' => 'service', + 'resource_id' => $service->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => array_merge($changes, [ + 'success' => $success, + 'service_id' => $service->id, + 'platform' => $service->platform, + ]), + ]); + } +} diff --git a/website/app/Http/Controllers/Admin/AuditLogController.php b/website/app/Http/Controllers/Admin/AuditLogController.php index 389054e..2ce5a39 100644 --- a/website/app/Http/Controllers/Admin/AuditLogController.php +++ b/website/app/Http/Controllers/Admin/AuditLogController.php @@ -6,13 +6,183 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\AuditLog; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; class AuditLogController extends Controller { public function index(Request $request): Response + { + $query = $this->applyFilters($request); + + $auditLogs = $query->paginate(25)->withQueryString(); + + // Get distinct actions for the filter dropdown + $actions = AuditLog::query() + ->distinct() + ->orderBy('action') + ->pluck('action'); + + return Inertia::render('Admin/AuditLogs/Index', [ + 'auditLogs' => $auditLogs, + 'actions' => $actions, + 'filters' => [ + 'search' => $request->input('search', ''), + 'action' => $request->input('action', ''), + 'date_from' => $request->input('date_from', ''), + 'date_to' => $request->input('date_to', ''), + ], + ]); + } + + public function export(Request $request): StreamedResponse + { + $request->validate([ + 'format' => ['required', 'in:csv,json'], + 'search' => ['nullable', 'string', 'max:255'], + 'action' => ['nullable', 'string', 'max:255'], + 'date_from' => ['nullable', 'date'], + 'date_to' => ['nullable', 'date'], + ]); + + $format = $request->input('format', 'csv'); + $query = $this->applyFilters($request); + + if ($format === 'json') { + return $this->exportJson($query); + } + + return $this->exportCsv($query); + } + + /** + * @param Builder $query + */ + private function exportCsv(Builder $query): StreamedResponse + { + $filename = 'audit-logs-'.now()->format('Y-m-d-His').'.csv'; + + return response()->streamDownload(function () use ($query): void { + $handle = fopen('php://output', 'w'); + + if ($handle === false) { + return; + } + + fputcsv($handle, [ + 'ID', + 'Date', + 'User', + 'User Email', + 'Action', + 'Resource Type', + 'Resource ID', + 'IP Address', + 'User Agent', + 'Changes Summary', + ]); + + $query->chunk(500, function ($logs) use ($handle): void { + foreach ($logs as $log) { + fputcsv($handle, [ + $log->id, + $log->created_at->format('Y-m-d H:i:s'), + $log->user?->name ?? 'System', + $log->user?->email ?? '-', + $log->action, + $log->resource_type ?? '-', + $log->resource_id ?? '-', + $log->ip_address ?? '-', + $log->user_agent ?? '-', + $this->summarizeChanges($log->changes), + ]); + } + }); + + fclose($handle); + }, $filename, [ + 'Content-Type' => 'text/csv', + ]); + } + + /** + * @param Builder $query + */ + private function exportJson(Builder $query): StreamedResponse + { + $filename = 'audit-logs-'.now()->format('Y-m-d-His').'.json'; + + return response()->streamDownload(function () use ($query): void { + echo '['; + + $first = true; + + $query->chunk(500, function ($logs) use (&$first): void { + foreach ($logs as $log) { + if (! $first) { + echo ','; + } + + echo json_encode([ + 'id' => $log->id, + 'date' => $log->created_at->format('Y-m-d H:i:s'), + 'user' => $log->user ? [ + 'id' => $log->user->id, + 'name' => $log->user->name, + 'email' => $log->user->email, + ] : null, + 'action' => $log->action, + 'resource_type' => $log->resource_type, + 'resource_id' => $log->resource_id, + 'ip_address' => $log->ip_address, + 'user_agent' => $log->user_agent, + 'changes' => $log->changes, + 'created_at' => $log->created_at->toIso8601String(), + ], JSON_PRETTY_PRINT); + + $first = false; + } + }); + + echo ']'; + }, $filename, [ + 'Content-Type' => 'application/json', + ]); + } + + /** + * @param array|null $changes + */ + private function summarizeChanges(?array $changes): string + { + if (! $changes || count($changes) === 0) { + return '-'; + } + + // If it has before/after structure + if (isset($changes['before']) || isset($changes['after'])) { + $fields = []; + + if (isset($changes['after']) && is_array($changes['after'])) { + $fields = array_keys($changes['after']); + } elseif (isset($changes['before']) && is_array($changes['before'])) { + $fields = array_keys($changes['before']); + } + + return 'Changed: '.implode(', ', $fields); + } + + // Otherwise list the top-level keys + return 'Fields: '.implode(', ', array_keys($changes)); + } + + /** + * @return Builder + */ + private function applyFilters(Request $request): Builder { $query = AuditLog::query() ->with('user:id,name,email') @@ -45,23 +215,6 @@ class AuditLogController extends Controller $query->whereDate('created_at', '<=', $dateTo); } - $auditLogs = $query->paginate(25)->withQueryString(); - - // Get distinct actions for the filter dropdown - $actions = AuditLog::query() - ->distinct() - ->orderBy('action') - ->pluck('action'); - - return Inertia::render('Admin/AuditLogs/Index', [ - 'auditLogs' => $auditLogs, - 'actions' => $actions, - 'filters' => [ - 'search' => $request->input('search', ''), - 'action' => $request->input('action', ''), - 'date_from' => $request->input('date_from', ''), - 'date_to' => $request->input('date_to', ''), - ], - ]); + return $query; } } diff --git a/website/app/Http/Controllers/Admin/CouponController.php b/website/app/Http/Controllers/Admin/CouponController.php index af63fc4..07fe584 100644 --- a/website/app/Http/Controllers/Admin/CouponController.php +++ b/website/app/Http/Controllers/Admin/CouponController.php @@ -18,6 +18,8 @@ class CouponController extends Controller { $coupons = Coupon::query() ->withCount('redemptions') + ->withSum('redemptions', 'discount_amount') + ->withMax('redemptions', 'created_at') ->orderByDesc('created_at') ->paginate(25); @@ -26,6 +28,28 @@ class CouponController extends Controller ]); } + public function show(Coupon $coupon): Response + { + $coupon->loadCount('redemptions'); + + $redemptions = $coupon->redemptions() + ->with(['user:id,name,email', 'subscription:id,type,stripe_status']) + ->orderByDesc('created_at') + ->paginate(25); + + $stats = [ + 'total_redemptions' => $coupon->redemptions_count, + 'total_discount' => (float) $coupon->redemptions()->sum('discount_amount'), + 'latest_redemption' => $coupon->redemptions()->max('created_at'), + ]; + + return Inertia::render('Admin/Coupons/Show', [ + 'coupon' => $coupon, + 'redemptions' => $redemptions, + 'stats' => $stats, + ]); + } + public function create(): Response { $plans = Plan::query() @@ -99,4 +123,131 @@ class CouponController extends Controller ->route('admin.coupons.index') ->with('success', 'Coupon deactivated successfully.'); } + + public function redemptions(): Response|\Symfony\Component\HttpFoundation\StreamedResponse + { + $query = \App\Models\CouponRedemption::query() + ->with(['coupon:id,code,type,value', 'user:id,name,email', 'subscription:id,type,stripe_status']); + + // Filter by coupon + if (request()->filled('coupon_id')) { + $query->where('coupon_id', request('coupon_id')); + } + + // Filter by customer (search by name or email) + if (request()->filled('customer')) { + $search = request('customer'); + $query->whereHas('user', function ($q) use ($search): void { + $q->where('name', 'LIKE', "%{$search}%") + ->orWhere('email', 'LIKE', "%{$search}%"); + }); + } + + // Filter by date range + if (request()->filled('date_from')) { + $query->whereDate('created_at', '>=', request('date_from')); + } + + if (request()->filled('date_to')) { + $query->whereDate('created_at', '<=', request('date_to')); + } + + // Handle CSV export + if (request()->filled('export') && request('export') === 'csv') { + return $this->exportRedemptionsToCSV($query); + } + + $redemptions = $query->orderByDesc('created_at')->paginate(25); + + // Get all coupons for filter dropdown + $coupons = Coupon::query() + ->orderBy('code') + ->get(['id', 'code']); + + // Calculate stats + $statsQuery = \App\Models\CouponRedemption::query(); + + // Apply same filters to stats + if (request()->filled('coupon_id')) { + $statsQuery->where('coupon_id', request('coupon_id')); + } + + if (request()->filled('customer')) { + $search = request('customer'); + $statsQuery->whereHas('user', function ($q) use ($search): void { + $q->where('name', 'LIKE', "%{$search}%") + ->orWhere('email', 'LIKE', "%{$search}%"); + }); + } + + if (request()->filled('date_from')) { + $statsQuery->whereDate('created_at', '>=', request('date_from')); + } + + if (request()->filled('date_to')) { + $statsQuery->whereDate('created_at', '<=', request('date_to')); + } + + $stats = [ + 'total_redemptions' => $statsQuery->count(), + 'total_discount' => (float) $statsQuery->sum('discount_amount'), + 'unique_customers' => $statsQuery->distinct('user_id')->count('user_id'), + 'unique_coupons' => $statsQuery->distinct('coupon_id')->count('coupon_id'), + ]; + + return Inertia::render('Admin/Coupons/Redemptions', [ + 'redemptions' => $redemptions, + 'coupons' => $coupons, + 'stats' => $stats, + 'filters' => request()->only(['coupon_id', 'customer', 'date_from', 'date_to']), + ]); + } + + private function exportRedemptionsToCSV(\Illuminate\Database\Eloquent\Builder $query): \Symfony\Component\HttpFoundation\StreamedResponse + { + $headers = [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="coupon-redemptions-'.now()->format('Y-m-d-His').'.csv"', + ]; + + $callback = function () use ($query): void { + $file = fopen('php://output', 'w'); + + // CSV headers + fputcsv($file, [ + 'Redemption ID', + 'Coupon Code', + 'Coupon Type', + 'Coupon Value', + 'Customer Name', + 'Customer Email', + 'Subscription ID', + 'Subscription Status', + 'Discount Amount', + 'Redeemed At', + ]); + + // Stream results in chunks to handle large datasets + $query->orderByDesc('created_at')->chunk(1000, function ($redemptions) use ($file): void { + foreach ($redemptions as $redemption) { + fputcsv($file, [ + $redemption->id, + $redemption->coupon?->code ?? 'N/A', + $redemption->coupon?->type ?? 'N/A', + $redemption->coupon?->value ?? 'N/A', + $redemption->user?->name ?? 'Deleted User', + $redemption->user?->email ?? 'N/A', + $redemption->subscription?->id ?? 'N/A', + $redemption->subscription?->stripe_status ?? 'N/A', + $redemption->discount_amount, + $redemption->created_at->format('Y-m-d H:i:s'), + ]); + } + }); + + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } } diff --git a/website/app/Http/Controllers/Admin/CustomerController.php b/website/app/Http/Controllers/Admin/CustomerController.php index 5c644b7..b13dc71 100644 --- a/website/app/Http/Controllers/Admin/CustomerController.php +++ b/website/app/Http/Controllers/Admin/CustomerController.php @@ -7,9 +7,18 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\UpdateCustomerRequest; use App\Models\AuditLog; +use App\Models\Invoice; +use App\Models\Order; +use App\Models\Plan; +use App\Models\Service; use App\Models\User; +use App\Notifications\AdminNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Password; +use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; @@ -108,11 +117,18 @@ class CustomerController extends Controller ->latest() ->paginate(15, ['*'], 'audit_page'); + $plans = Plan::query() + ->where('status', 'active') + ->orderBy('service_type') + ->orderBy('price') + ->get(['id', 'name', 'price', 'billing_cycle', 'service_type']); + return Inertia::render('Admin/Customers/Show', [ 'customer' => $user, 'subscriptions' => $subscriptions, 'recentInvoices' => $recentInvoices, 'auditLogs' => $auditLogs, + 'plans' => $plans, ]); } @@ -191,4 +207,157 @@ class CustomerController extends Controller return redirect()->back()->with('success', "Customer {$user->name} has been unsuspended."); } + + public function purge(Request $request, User $user): RedirectResponse + { + if ($user->isAdmin()) { + return redirect()->back()->with('error', 'Cannot purge admin users.'); + } + + $userName = $user->name; + $userEmail = $user->email; + + DB::transaction(function () use ($user, $request): void { + // Delete all related data + $user->services()->delete(); + $user->invoices()->delete(); + $user->orders()->delete(); + $user->subscriptions()->delete(); + AuditLog::query()->where('user_id', $user->id)->delete(); + + AuditLog::create([ + 'admin_id' => $request->user()->id, + 'action' => 'customer_purged', + 'resource_type' => 'user', + 'resource_id' => $user->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => [ + 'email' => $user->email, + 'name' => $user->name, + ], + ]); + + // Delete the user + $user->delete(); + }); + + return redirect()->route('customers.index') + ->with('success', "Customer {$userName} ({$userEmail}) has been permanently deleted."); + } + + public function resetPassword(Request $request, User $user): RedirectResponse + { + $newPassword = Str::random(16); + + $user->update([ + 'password' => Hash::make($newPassword), + ]); + + AuditLog::create([ + 'user_id' => $user->id, + 'admin_id' => $request->user()->id, + 'action' => 'password_reset', + 'resource_type' => 'user', + 'resource_id' => $user->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + // Send email with new password + $user->notify(new \App\Notifications\AdminPasswordResetNotification($newPassword)); + + return redirect()->back() + ->with('success', "Password reset email sent to {$user->email}."); + } + + public function sendNotification(Request $request, User $user): RedirectResponse + { + $request->validate([ + 'subject' => 'required|string|max:255', + 'message' => 'required|string', + ]); + + AuditLog::create([ + 'user_id' => $user->id, + 'admin_id' => $request->user()->id, + 'action' => 'notification_sent', + 'resource_type' => 'user', + 'resource_id' => $user->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => [ + 'subject' => $request->input('subject'), + ], + ]); + + $user->notify(new AdminNotification( + $request->input('subject'), + $request->input('message') + )); + + return redirect()->back() + ->with('success', "Notification sent to {$user->email}."); + } + + public function placeOrder(Request $request, User $user): RedirectResponse + { + $request->validate([ + 'plan_id' => 'required|exists:plans,id', + 'billing_cycle' => 'required|in:monthly,quarterly,semi_annually,annually', + ]); + + $plan = Plan::query()->findOrFail($request->input('plan_id')); + + DB::transaction(function () use ($user, $plan, $request): void { + // Create order + $order = Order::query()->create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'total' => $plan->price, + 'status' => 'completed', + 'payment_method' => 'admin_created', + 'admin_notes' => 'Order placed by admin', + ]); + + // Create service + $service = Service::query()->create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'order_id' => $order->id, + 'status' => 'active', + 'credentials' => [], + ]); + + // Create invoice + Invoice::query()->create([ + 'user_id' => $user->id, + 'order_id' => $order->id, + 'number' => 'INV-'.strtoupper(Str::random(10)), + 'subtotal' => $plan->price, + 'tax' => 0, + 'total' => $plan->price, + 'status' => 'paid', + 'gateway' => 'manual', + 'paid_at' => now(), + ]); + + AuditLog::create([ + 'user_id' => $user->id, + 'admin_id' => $request->user()->id, + 'action' => 'order_placed', + 'resource_type' => 'order', + 'resource_id' => $order->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => [ + 'plan_id' => $plan->id, + 'plan_name' => $plan->name, + ], + ]); + }); + + return redirect()->back() + ->with('success', "Order for {$plan->name} created successfully."); + } } diff --git a/website/app/Http/Controllers/Admin/ImpersonationController.php b/website/app/Http/Controllers/Admin/ImpersonationController.php index b794c54..5ba5471 100644 --- a/website/app/Http/Controllers/Admin/ImpersonationController.php +++ b/website/app/Http/Controllers/Admin/ImpersonationController.php @@ -39,8 +39,9 @@ class ImpersonationController extends Controller Auth::login($user); - return redirect('https://'.config('app.domains.account').'/dashboard') - ->with('info', "You are now impersonating {$user->name}."); + return redirect()->away('https://'.config('app.domains.account').'/dashboard') + ->with('info', "You are now impersonating {$user->name}.") + ->with('success', "Impersonation started. You are now viewing as {$user->name}."); } public function stop(Request $request): RedirectResponse diff --git a/website/app/Http/Controllers/Admin/InvoiceController.php b/website/app/Http/Controllers/Admin/InvoiceController.php index 660c2a0..5c5e1ef 100644 --- a/website/app/Http/Controllers/Admin/InvoiceController.php +++ b/website/app/Http/Controllers/Admin/InvoiceController.php @@ -5,11 +5,16 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\StoreInvoiceRequest; +use App\Http\Requests\Admin\UpdateInvoiceRequest; use App\Models\AuditLog; use App\Models\Invoice; +use App\Models\User; +use App\Notifications\InvoiceNotification; use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; @@ -47,6 +52,77 @@ class InvoiceController extends Controller ]); } + public function create(): Response + { + $customers = User::query() + ->select('id', 'name', 'email') + ->orderBy('name') + ->get(); + + return Inertia::render('Admin/Invoices/Create', [ + 'customers' => $customers, + ]); + } + + public function store(StoreInvoiceRequest $request): RedirectResponse + { + $validated = $request->validated(); + + $invoice = DB::transaction(function () use ($validated, $request): Invoice { + // Calculate total from line items + $total = collect($validated['items'])->sum(function (array $item): float { + return (float) $item['unit_price'] * (int) $item['quantity']; + }); + + // Generate unique invoice number + $number = 'INV-'.str_pad((string) (Invoice::query()->count() + 1), 6, '0', STR_PAD_LEFT); + + $status = ($validated['send_immediately'] ?? false) ? 'pending' : 'draft'; + + $invoice = Invoice::create([ + 'user_id' => $validated['customer_id'], + 'gateway' => 'manual', + 'number' => $number, + 'total' => $total, + 'tax' => 0, + 'currency' => 'USD', + 'status' => $status, + 'due_date' => $validated['due_date'], + 'notes' => $validated['notes'] ?? null, + ]); + + // Create line items + foreach ($validated['items'] as $item) { + $invoice->items()->create([ + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'amount' => $item['unit_price'], + ]); + } + + AuditLog::create([ + 'user_id' => $validated['customer_id'], + 'admin_id' => $request->user()?->id, + 'action' => 'create_invoice', + 'resource_type' => 'invoice', + 'resource_id' => $invoice->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + return $invoice; + }); + + // Send email if requested + if ($validated['send_immediately'] ?? false) { + $invoice->load('user'); + $invoice->user?->notify(new InvoiceNotification($invoice)); + } + + return redirect()->route('invoices.show', $invoice) + ->with('success', "Invoice {$invoice->number} has been created."); + } + public function show(Invoice $invoice): Response { $invoice->load([ @@ -60,6 +136,89 @@ class InvoiceController extends Controller ]); } + public function edit(Invoice $invoice): Response|RedirectResponse + { + if (! in_array($invoice->status, ['draft', 'pending'])) { + return redirect()->route('invoices.show', $invoice) + ->with('error', 'Only draft or pending invoices can be edited.'); + } + + $invoice->load(['user:id,name,email', 'items']); + + return Inertia::render('Admin/Invoices/Edit', [ + 'invoice' => $invoice, + ]); + } + + public function update(UpdateInvoiceRequest $request, Invoice $invoice): RedirectResponse + { + if (! in_array($invoice->status, ['draft', 'pending'])) { + return redirect()->route('invoices.show', $invoice) + ->with('error', 'Only draft or pending invoices can be edited.'); + } + + $validated = $request->validated(); + + DB::transaction(function () use ($invoice, $validated, $request): void { + // Delete existing items and recreate + $invoice->items()->delete(); + + $total = 0.0; + foreach ($validated['items'] as $item) { + $lineTotal = (float) $item['unit_price'] * (int) $item['quantity']; + $total += $lineTotal; + + $invoice->items()->create([ + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'amount' => $item['unit_price'], + ]); + } + + $invoice->update([ + 'total' => $total, + 'due_date' => $validated['due_date'], + 'notes' => $validated['notes'] ?? null, + ]); + + AuditLog::create([ + 'user_id' => $invoice->user_id, + 'admin_id' => $request->user()?->id, + 'action' => 'update_invoice', + 'resource_type' => 'invoice', + 'resource_id' => $invoice->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + }); + + return redirect()->route('invoices.show', $invoice) + ->with('success', "Invoice {$invoice->number} has been updated."); + } + + public function resend(Invoice $invoice): RedirectResponse + { + $invoice->load('user'); + + if (! $invoice->user) { + return redirect()->back()->with('error', 'Cannot resend: no customer associated with this invoice.'); + } + + $invoice->user->notify(new InvoiceNotification($invoice)); + + AuditLog::create([ + 'user_id' => $invoice->user_id, + 'admin_id' => auth()->id(), + 'action' => 'resend_invoice', + 'resource_type' => 'invoice', + 'resource_id' => $invoice->id, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + + return redirect()->back()->with('success', "Invoice {$invoice->number} email has been queued for delivery."); + } + public function download(Invoice $invoice): \Symfony\Component\HttpFoundation\Response { $invoice->load(['user', 'items']); diff --git a/website/app/Http/Controllers/Admin/ServiceController.php b/website/app/Http/Controllers/Admin/ServiceController.php index bb494f8..4c70225 100644 --- a/website/app/Http/Controllers/Admin/ServiceController.php +++ b/website/app/Http/Controllers/Admin/ServiceController.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\UpdateServiceRequest; use App\Models\AuditLog; +use App\Models\Plan; use App\Models\Service; +use App\Services\Provisioning\ProvisioningFactory; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Inertia\Inertia; use Inertia\Response; @@ -19,6 +23,11 @@ class ServiceController extends Controller $query = Service::query() ->with(['user:id,name,email', 'plan:id,name,service_type,price,billing_cycle']); + // Include soft-deleted services when requested + if ($request->boolean('show_archived')) { + $query->withTrashed(); + } + // Search by customer name or email if ($search = $request->input('search')) { $query->whereHas('user', function ($q) use ($search): void { @@ -45,12 +54,15 @@ class ServiceController extends Controller 'search' => $request->input('search', ''), 'service_type' => $request->input('service_type', ''), 'status' => $request->input('status', ''), + 'show_archived' => $request->boolean('show_archived'), ], ]); } - public function show(Service $service): Response + public function show(int $service): Response { + $service = Service::withTrashed()->findOrFail($service); + $service->load([ 'user:id,name,email,status', 'plan:id,name,service_type,price,billing_cycle', @@ -59,8 +71,16 @@ class ServiceController extends Controller }, ]); + // Get available plans for plan change (same service type, active) + $availablePlans = Plan::query() + ->where('service_type', $service->service_type) + ->where('status', 'active') + ->orderBy('price') + ->get(['id', 'name', 'price', 'billing_cycle']); + return Inertia::render('Admin/Services/Show', [ 'service' => $service, + 'availablePlans' => $availablePlans, ]); } @@ -123,4 +143,143 @@ class ServiceController extends Controller return redirect()->back()->with('success', 'Service has been terminated.'); } + + public function provision(Service $service, ProvisioningFactory $provisioningFactory): RedirectResponse + { + // Check if service is already provisioned + if ($service->status === 'active' || $service->provisioned_at !== null) { + return redirect()->back()->with('error', 'Service has already been provisioned.'); + } + + // Check if service has a subscription + if (! $service->subscription) { + return redirect()->back()->with('error', 'Service must have an associated subscription to be provisioned.'); + } + + try { + $provisioningService = $provisioningFactory->make($service->service_type); + + // provision() is idempotent — it reuses the existing Service record + $provisionedService = $provisioningService->provision($service->subscription); + + AuditLog::create([ + 'user_id' => $provisionedService->user_id, + 'admin_id' => auth()->id(), + 'action' => 'manual_provision_service', + 'resource_type' => 'service', + 'resource_id' => $provisionedService->id, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'changes' => [ + 'service_type' => $provisionedService->service_type, + 'platform' => $provisionedService->platform, + ], + ]); + + Log::info('Admin manually provisioned service', [ + 'admin_id' => auth()->id(), + 'service_id' => $provisionedService->id, + 'service_type' => $provisionedService->service_type, + ]); + + return redirect()->route('services.show', $provisionedService)->with('success', 'Service has been provisioned successfully.'); + } catch (\Throwable $e) { + Log::error('Manual service provisioning failed', [ + 'admin_id' => auth()->id(), + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'Failed to provision service: '.$e->getMessage()); + } + } + + public function destroy(int $service): RedirectResponse + { + $service = Service::withTrashed()->findOrFail($service); + + $service->delete(); + + AuditLog::create([ + 'user_id' => $service->user_id, + 'admin_id' => auth()->id(), + 'action' => 'archive_service', + 'resource_type' => 'service', + 'resource_id' => $service->id, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + + return redirect()->route('services.index')->with('success', 'Service has been archived.'); + } + + public function restore(int $service): RedirectResponse + { + $service = Service::withTrashed()->findOrFail($service); + + $service->restore(); + + AuditLog::create([ + 'user_id' => $service->user_id, + 'admin_id' => auth()->id(), + 'action' => 'restore_service', + 'resource_type' => 'service', + 'resource_id' => $service->id, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + + return redirect()->back()->with('success', 'Service has been restored.'); + } + + public function update(Service $service, UpdateServiceRequest $request): RedirectResponse + { + $validated = $request->validated(); + $changes = []; + + // Track plan change + if (isset($validated['plan_id']) && $validated['plan_id'] !== null && $validated['plan_id'] !== $service->plan_id) { + $oldPlan = $service->plan; + $newPlan = Plan::findOrFail($validated['plan_id']); + + $service->update(['plan_id' => $validated['plan_id']]); + + $changes['plan'] = [ + 'old' => $oldPlan->name, + 'new' => $newPlan->name, + ]; + + Log::info('Admin changed service plan', [ + 'admin_id' => auth()->id(), + 'service_id' => $service->id, + 'old_plan_id' => $oldPlan->id, + 'new_plan_id' => $newPlan->id, + ]); + } + + // Track notes update (notes field doesn't exist yet, but keeping for future) + if (isset($validated['notes'])) { + $changes['notes'] = [ + 'old' => $service->notes ?? null, + 'new' => $validated['notes'], + ]; + } + + if (empty($changes)) { + return redirect()->back()->with('info', 'No changes were made to the service.'); + } + + AuditLog::create([ + 'user_id' => $service->user_id, + 'admin_id' => auth()->id(), + 'action' => 'update_service', + 'resource_type' => 'service', + 'resource_id' => $service->id, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'changes' => $changes, + ]); + + return redirect()->back()->with('success', 'Service has been updated successfully.'); + } } diff --git a/website/app/Http/Controllers/Admin/SettingsController.php b/website/app/Http/Controllers/Admin/SettingsController.php index 04bed83..ba5840c 100644 --- a/website/app/Http/Controllers/Admin/SettingsController.php +++ b/website/app/Http/Controllers/Admin/SettingsController.php @@ -7,7 +7,11 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\UpdateSettingsRequest; use App\Models\Setting; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Inertia\Inertia; use Inertia\Response; @@ -28,10 +32,13 @@ class SettingsController extends Controller 'api' => [ 'virtfusion_api_url' => null, 'virtfusion_api_token' => null, + 'pterodactyl_api_url' => null, + 'pterodactyl_api_token' => null, 'synergycp_api_url' => null, 'synergycp_api_token' => null, 'enhance_api_url' => null, 'enhance_api_token' => null, + 'enhance_organization_id' => null, ], 'billing' => [ 'default_currency' => 'USD', @@ -39,13 +46,29 @@ class SettingsController extends Controller 'suspension_warning_days' => '3', 'auto_terminate_days' => '14', 'bandwidth_overage_rate' => '0.05', + 'bandwidth_alert_75' => '1', + 'bandwidth_alert_90' => '1', + 'bandwidth_alert_100' => '1', + 'bandwidth_alert_75_email' => '1', + 'bandwidth_alert_90_email' => '1', + 'bandwidth_alert_100_email' => '1', + 'bandwidth_grace_period_days' => '3', + 'bandwidth_auto_suspend' => '0', ], 'notifications' => [ - 'discord_webhook_url' => null, - 'slack_webhook_url' => null, 'email_from_address' => null, 'email_from_name' => null, ], + 'discord' => [ + 'discord_payment_webhook_url' => null, + 'discord_payment_webhook_enabled' => '0', + 'discord_provisioning_webhook_url' => null, + 'discord_provisioning_webhook_enabled' => '0', + 'discord_support_webhook_url' => null, + 'discord_support_webhook_enabled' => '0', + 'discord_system_webhook_url' => null, + 'discord_system_webhook_enabled' => '0', + ], ]; /** @@ -55,8 +78,14 @@ class SettingsController extends Controller */ private const SENSITIVE_KEYS = [ 'virtfusion_api_token', + 'pterodactyl_api_token', 'synergycp_api_token', 'enhance_api_token', + 'enhance_organization_id', + 'discord_payment_webhook_url', + 'discord_provisioning_webhook_url', + 'discord_support_webhook_url', + 'discord_system_webhook_url', ]; public function index(): Response @@ -117,6 +146,97 @@ class SettingsController extends Controller ->with('success', ucfirst($group).' settings updated successfully.'); } + /** + * Test an API provider connection by making a simple health-check request. + */ + public function testApiConnection(Request $request): JsonResponse + { + $request->validate([ + 'provider' => ['required', 'string', 'in:virtfusion,pterodactyl,synergycp,enhance'], + ]); + + $provider = $request->input('provider'); + + // Use provided values or fall back to stored settings + $url = $request->input('url') ?: Setting::get("{$provider}_api_url"); + $token = $request->input('token') ?: Setting::get("{$provider}_api_token"); + + if (empty($url) || empty($token)) { + return response()->json([ + 'success' => false, + 'message' => 'API URL and token are required.', + ]); + } + + try { + $result = match ($provider) { + 'virtfusion' => $this->testVirtFusion($url, $token), + 'pterodactyl' => $this->testPterodactyl($url, $token), + 'synergycp' => $this->testSynergyCP($url, $token), + 'enhance' => $this->testEnhance($url, $token, $request->input('organization_id')), + }; + + return response()->json($result); + } catch (\Exception $e) { + Log::warning("API connection test failed for {$provider}", [ + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'success' => false, + 'message' => "Connection failed: {$e->getMessage()}", + ]); + } + } + + /** + * Test a Discord webhook by sending a test message. + */ + public function testDiscordWebhook(Request $request): JsonResponse + { + $request->validate([ + 'webhook_url' => ['required', 'url', 'regex:/^https:\/\/discord\.com\/api\/webhooks\//'], + 'channel' => ['required', 'string', 'in:payment,provisioning,support,system'], + ]); + + $webhookUrl = $request->input('webhook_url'); + $channel = $request->input('channel'); + + try { + $response = Http::timeout(10)->post($webhookUrl, [ + 'content' => null, + 'embeds' => [ + [ + 'title' => 'EZSCALE Webhook Test', + 'description' => "This is a test message for the **{$channel}** notification channel.", + 'color' => 7563248, // Purple (#7367F0) + 'footer' => [ + 'text' => 'EZSCALE Billing System', + ], + 'timestamp' => now()->toIso8601String(), + ], + ], + ]); + + if ($response->successful() || $response->status() === 204) { + return response()->json([ + 'success' => true, + 'message' => 'Test message sent successfully! Check your Discord channel.', + ]); + } + + return response()->json([ + 'success' => false, + 'message' => "Discord returned status {$response->status()}: {$response->body()}", + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => "Failed to send test message: {$e->getMessage()}", + ]); + } + } + /** * Mask a sensitive value, showing only the last 4 characters. */ @@ -134,4 +254,87 @@ class SettingsController extends Controller return str_repeat('*', $length - 4).substr($value, -4); } + + /** + * @return array{success: bool, message: string} + */ + private function testVirtFusion(string $url, string $token): array + { + $response = Http::withToken($token) + ->baseUrl(rtrim($url, '/')) + ->acceptJson() + ->timeout(10) + ->get('/connect'); + + if ($response->successful()) { + $data = $response->json(); + + return [ + 'success' => true, + 'message' => 'VirtFusion connection successful. '. + (isset($data['version']) ? "Version: {$data['version']}" : ''), + ]; + } + + return ['success' => false, 'message' => "VirtFusion returned HTTP {$response->status()}: {$response->body()}"]; + } + + /** + * @return array{success: bool, message: string} + */ + private function testPterodactyl(string $url, string $token): array + { + $response = Http::withToken($token) + ->baseUrl(rtrim($url, '/')) + ->acceptJson() + ->timeout(10) + ->get('/api/application/servers?per_page=1'); + + if ($response->successful()) { + return ['success' => true, 'message' => 'Pterodactyl connection successful.']; + } + + return ['success' => false, 'message' => "Pterodactyl returned HTTP {$response->status()}."]; + } + + /** + * @return array{success: bool, message: string} + */ + private function testSynergyCP(string $url, string $token): array + { + $response = Http::withToken($token) + ->baseUrl(rtrim($url, '/')) + ->acceptJson() + ->timeout(10) + ->get('/api/v1/servers?per_page=1'); + + if ($response->successful()) { + return ['success' => true, 'message' => 'SynergyCP connection successful.']; + } + + return ['success' => false, 'message' => "SynergyCP returned HTTP {$response->status()}."]; + } + + /** + * @return array{success: bool, message: string} + */ + private function testEnhance(string $url, string $token, ?string $orgId = null): array + { + $client = Http::withToken($token) + ->baseUrl(rtrim($url, '/')) + ->acceptJson() + ->timeout(10); + + if ($orgId) { + $client = $client->withHeaders(['X-Organization-ID' => $orgId]); + } + + $response = $client->get('/orgs'); + + if ($response->successful()) { + return ['success' => true, 'message' => 'Enhance connection successful.']; + } + + return ['success' => false, 'message' => "Enhance returned HTTP {$response->status()}."]; + } } diff --git a/website/app/Http/Requests/Account/RebuildVpsRequest.php b/website/app/Http/Requests/Account/RebuildVpsRequest.php new file mode 100644 index 0000000..02b52a8 --- /dev/null +++ b/website/app/Http/Requests/Account/RebuildVpsRequest.php @@ -0,0 +1,33 @@ +> */ + public function rules(): array + { + return [ + 'template_id' => ['required', 'integer', 'min:1'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'template_id.required' => 'Please select an operating system template.', + 'template_id.integer' => 'Invalid template selection.', + 'template_id.min' => 'Invalid template selection.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/StoreInvoiceRequest.php b/website/app/Http/Requests/Admin/StoreInvoiceRequest.php new file mode 100644 index 0000000..6d705f3 --- /dev/null +++ b/website/app/Http/Requests/Admin/StoreInvoiceRequest.php @@ -0,0 +1,48 @@ +> */ + public function rules(): array + { + return [ + 'customer_id' => ['required', 'exists:users,id'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string', 'max:255'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'due_date' => ['required', 'date', 'after_or_equal:today'], + 'notes' => ['nullable', 'string', 'max:2000'], + 'send_immediately' => ['boolean'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'customer_id.required' => 'Please select a customer.', + 'customer_id.exists' => 'The selected customer does not exist.', + 'items.required' => 'At least one line item is required.', + 'items.min' => 'At least one line item is required.', + 'items.*.description.required' => 'Each line item must have a description.', + 'items.*.quantity.required' => 'Each line item must have a quantity.', + 'items.*.quantity.min' => 'Quantity must be at least 1.', + 'items.*.unit_price.required' => 'Each line item must have a unit price.', + 'items.*.unit_price.min' => 'Unit price must be at least 0.', + 'due_date.required' => 'Due date is required.', + 'due_date.after_or_equal' => 'Due date must be today or later.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/UpdateInvoiceRequest.php b/website/app/Http/Requests/Admin/UpdateInvoiceRequest.php new file mode 100644 index 0000000..0f85f01 --- /dev/null +++ b/website/app/Http/Requests/Admin/UpdateInvoiceRequest.php @@ -0,0 +1,43 @@ +> */ + public function rules(): array + { + return [ + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string', 'max:255'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'due_date' => ['required', 'date'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'items.required' => 'At least one line item is required.', + 'items.min' => 'At least one line item is required.', + 'items.*.description.required' => 'Each line item must have a description.', + 'items.*.quantity.required' => 'Each line item must have a quantity.', + 'items.*.quantity.min' => 'Quantity must be at least 1.', + 'items.*.unit_price.required' => 'Each line item must have a unit price.', + 'items.*.unit_price.min' => 'Unit price must be at least 0.', + 'due_date.required' => 'Due date is required.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/UpdateServiceRequest.php b/website/app/Http/Requests/Admin/UpdateServiceRequest.php new file mode 100644 index 0000000..3a10afe --- /dev/null +++ b/website/app/Http/Requests/Admin/UpdateServiceRequest.php @@ -0,0 +1,44 @@ +> */ + public function rules(): array + { + $service = $this->route('service'); + + return [ + 'plan_id' => [ + 'sometimes', + 'nullable', + 'exists:plans,id', + Rule::exists('plans', 'id')->where(function ($query) use ($service): void { + $query->where('service_type', $service->service_type) + ->where('status', 'active'); + }), + ], + 'notes' => ['nullable', 'string', 'max:1000'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'plan_id.exists' => 'The selected plan does not exist or is not available for this service type.', + 'notes.max' => 'Notes cannot exceed 1000 characters.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/UpdateSettingsRequest.php b/website/app/Http/Requests/Admin/UpdateSettingsRequest.php index 4027bf2..0e96ce2 100644 --- a/website/app/Http/Requests/Admin/UpdateSettingsRequest.php +++ b/website/app/Http/Requests/Admin/UpdateSettingsRequest.php @@ -24,7 +24,8 @@ class UpdateSettingsRequest extends FormRequest 'api' => $this->apiRules(), 'billing' => $this->billingRules(), 'notifications' => $this->notificationRules(), - default => ['group' => ['required', Rule::in(['general', 'api', 'billing', 'notifications'])]], + 'discord' => $this->discordRules(), + default => ['group' => ['required', Rule::in(['general', 'api', 'billing', 'notifications', 'discord'])]], }; } @@ -47,10 +48,13 @@ class UpdateSettingsRequest extends FormRequest 'group' => ['required', 'string'], 'virtfusion_api_url' => ['nullable', 'url', 'max:500'], 'virtfusion_api_token' => ['nullable', 'string', 'max:1000'], + 'pterodactyl_api_url' => ['nullable', 'url', 'max:500'], + 'pterodactyl_api_token' => ['nullable', 'string', 'max:1000'], 'synergycp_api_url' => ['nullable', 'url', 'max:500'], 'synergycp_api_token' => ['nullable', 'string', 'max:1000'], 'enhance_api_url' => ['nullable', 'url', 'max:500'], 'enhance_api_token' => ['nullable', 'string', 'max:1000'], + 'enhance_organization_id' => ['nullable', 'string', 'max:255'], ]; } @@ -64,6 +68,14 @@ class UpdateSettingsRequest extends FormRequest 'suspension_warning_days' => ['nullable', 'integer', 'min:0', 'max:365'], 'auto_terminate_days' => ['nullable', 'integer', 'min:0', 'max:365'], 'bandwidth_overage_rate' => ['nullable', 'numeric', 'min:0', 'max:999.99'], + 'bandwidth_alert_75' => ['nullable', 'boolean'], + 'bandwidth_alert_90' => ['nullable', 'boolean'], + 'bandwidth_alert_100' => ['nullable', 'boolean'], + 'bandwidth_alert_75_email' => ['nullable', 'boolean'], + 'bandwidth_alert_90_email' => ['nullable', 'boolean'], + 'bandwidth_alert_100_email' => ['nullable', 'boolean'], + 'bandwidth_grace_period_days' => ['nullable', 'integer', 'min:0', 'max:365'], + 'bandwidth_auto_suspend' => ['nullable', 'boolean'], ]; } @@ -72,13 +84,27 @@ class UpdateSettingsRequest extends FormRequest { return [ 'group' => ['required', 'string'], - 'discord_webhook_url' => ['nullable', 'url', 'max:500'], - 'slack_webhook_url' => ['nullable', 'url', 'max:500'], 'email_from_address' => ['nullable', 'email', 'max:255'], 'email_from_name' => ['nullable', 'string', 'max:255'], ]; } + /** @return array> */ + private function discordRules(): array + { + return [ + 'group' => ['required', 'string'], + 'discord_payment_webhook_url' => ['nullable', 'url', 'max:500', 'regex:/^https:\/\/discord\.com\/api\/webhooks\//'], + 'discord_payment_webhook_enabled' => ['nullable', 'boolean'], + 'discord_provisioning_webhook_url' => ['nullable', 'url', 'max:500', 'regex:/^https:\/\/discord\.com\/api\/webhooks\//'], + 'discord_provisioning_webhook_enabled' => ['nullable', 'boolean'], + 'discord_support_webhook_url' => ['nullable', 'url', 'max:500', 'regex:/^https:\/\/discord\.com\/api\/webhooks\//'], + 'discord_support_webhook_enabled' => ['nullable', 'boolean'], + 'discord_system_webhook_url' => ['nullable', 'url', 'max:500', 'regex:/^https:\/\/discord\.com\/api\/webhooks\//'], + 'discord_system_webhook_enabled' => ['nullable', 'boolean'], + ]; + } + /** @return array */ public function messages(): array { @@ -87,15 +113,23 @@ class UpdateSettingsRequest extends FormRequest 'support_url.url' => 'Please enter a valid URL.', 'status_page_url.url' => 'Please enter a valid URL.', 'virtfusion_api_url.url' => 'Please enter a valid URL.', + 'pterodactyl_api_url.url' => 'Please enter a valid URL.', 'synergycp_api_url.url' => 'Please enter a valid URL.', 'enhance_api_url.url' => 'Please enter a valid URL.', - 'discord_webhook_url.url' => 'Please enter a valid Discord webhook URL.', - 'slack_webhook_url.url' => 'Please enter a valid Slack webhook URL.', 'email_from_address.email' => 'Please enter a valid email address.', + 'discord_payment_webhook_url.url' => 'Please enter a valid Discord webhook URL.', + 'discord_payment_webhook_url.regex' => 'Must be a valid Discord webhook URL (https://discord.com/api/webhooks/...).', + 'discord_provisioning_webhook_url.url' => 'Please enter a valid Discord webhook URL.', + 'discord_provisioning_webhook_url.regex' => 'Must be a valid Discord webhook URL (https://discord.com/api/webhooks/...).', + 'discord_support_webhook_url.url' => 'Please enter a valid Discord webhook URL.', + 'discord_support_webhook_url.regex' => 'Must be a valid Discord webhook URL (https://discord.com/api/webhooks/...).', + 'discord_system_webhook_url.url' => 'Please enter a valid Discord webhook URL.', + 'discord_system_webhook_url.regex' => 'Must be a valid Discord webhook URL (https://discord.com/api/webhooks/...).', 'grace_period_days.integer' => 'Grace period must be a whole number.', 'suspension_warning_days.integer' => 'Suspension warning days must be a whole number.', 'auto_terminate_days.integer' => 'Auto-terminate days must be a whole number.', 'bandwidth_overage_rate.numeric' => 'Bandwidth overage rate must be a number.', + 'bandwidth_grace_period_days.integer' => 'Bandwidth grace period must be a whole number.', ]; } } diff --git a/website/app/Listeners/HandleServiceProvisioned.php b/website/app/Listeners/HandleServiceProvisioned.php new file mode 100644 index 0000000..bae0f5d --- /dev/null +++ b/website/app/Listeners/HandleServiceProvisioned.php @@ -0,0 +1,23 @@ +user->id}", [ + 'service_id' => $event->service->id, + 'service_type' => $event->service->service_type, + 'hostname' => $event->service->hostname, + ]); + + $event->user->notify(new ServiceProvisionedNotification($event->service)); + } +} diff --git a/website/app/Listeners/HandleSubscriptionCancelled.php b/website/app/Listeners/HandleSubscriptionCancelled.php index 036d289..3d5f3c4 100644 --- a/website/app/Listeners/HandleSubscriptionCancelled.php +++ b/website/app/Listeners/HandleSubscriptionCancelled.php @@ -6,10 +6,20 @@ namespace App\Listeners; use App\Events\SubscriptionCancelled; use App\Notifications\SubscriptionCancelledNotification; +use App\Services\Provisioning\ProvisioningFactory; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Facades\Log; -class HandleSubscriptionCancelled +class HandleSubscriptionCancelled implements ShouldQueue { + public $tries = 3; + + public $backoff = [60, 120, 300]; + + public function __construct( + private ProvisioningFactory $provisioningFactory, + ) {} + public function handle(SubscriptionCancelled $event): void { Log::info("Subscription cancelled for user #{$event->user->id}", [ @@ -17,6 +27,37 @@ class HandleSubscriptionCancelled 'type' => $event->subscription->type, ]); + // Terminate associated services (VirtFusion handles delay) + $services = $event->user->services() + ->where('subscription_id', $event->subscription->id) + ->whereIn('status', ['active', 'suspended']) + ->get(); + + foreach ($services as $service) { + try { + $planId = $event->subscription->plan_id; + if ($planId) { + $plan = \App\Models\Plan::find($planId); + + if ($plan) { + $provisioner = $this->provisioningFactory->make($plan->service_type); + $provisioner->terminate($service); + + Log::info('Service terminated successfully', [ + 'service_id' => $service->id, + 'subscription_id' => $event->subscription->id, + ]); + } + } + } catch (\Exception $e) { + Log::error('Service termination exception', [ + 'service_id' => $service->id, + 'subscription_id' => $event->subscription->id, + 'error' => $e->getMessage(), + ]); + } + } + $event->user->notify(new SubscriptionCancelledNotification($event->subscription)); } } diff --git a/website/app/Listeners/HandleSubscriptionCreated.php b/website/app/Listeners/HandleSubscriptionCreated.php index 56d2a5b..73c1149 100644 --- a/website/app/Listeners/HandleSubscriptionCreated.php +++ b/website/app/Listeners/HandleSubscriptionCreated.php @@ -4,12 +4,19 @@ declare(strict_types=1); namespace App\Listeners; +use App\Events\ServiceProvisioned; use App\Events\SubscriptionCreated; use App\Notifications\SubscriptionCreatedNotification; +use App\Services\Provisioning\ProvisioningFactory; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Facades\Log; -class HandleSubscriptionCreated +class HandleSubscriptionCreated implements ShouldQueue { + public function __construct( + private ProvisioningFactory $provisioningFactory, + ) {} + public function handle(SubscriptionCreated $event): void { Log::info("Subscription created for user #{$event->user->id}", [ @@ -17,6 +24,36 @@ class HandleSubscriptionCreated 'type' => $event->subscription->type, ]); + // Send notification first — provisioning may fail but user should always know about the subscription $event->user->notify(new SubscriptionCreatedNotification($event->subscription)); + + // Get the plan + $planId = $event->subscription->plan_id; + if ($planId) { + $plan = \App\Models\Plan::find($planId); + + if ($plan) { + // Automatically provision the service + try { + $provisioner = $this->provisioningFactory->make($plan->service_type); + $service = $provisioner->provision($event->subscription); + + ServiceProvisioned::dispatch($event->user, $service); + + Log::info('Service provisioned successfully', [ + 'service_id' => $service->id, + 'service_type' => $plan->service_type, + ]); + } catch (\Exception $e) { + Log::error('Service provisioning failed', [ + 'subscription_id' => $event->subscription->id, + 'error' => $e->getMessage(), + ]); + + // Don't re-throw — the provisioning:retry command handles retries + // with idempotent provision() that reuses existing Service records + } + } + } } } diff --git a/website/app/Models/Invoice.php b/website/app/Models/Invoice.php index bc38dba..5e28181 100644 --- a/website/app/Models/Invoice.php +++ b/website/app/Models/Invoice.php @@ -25,6 +25,7 @@ class Invoice extends Model 'currency', 'status', 'invoice_pdf', + 'notes', 'due_date', 'paid_at', ]; diff --git a/website/app/Models/Plan.php b/website/app/Models/Plan.php index d285bf7..8f4943c 100644 --- a/website/app/Models/Plan.php +++ b/website/app/Models/Plan.php @@ -21,6 +21,7 @@ class Plan extends Model 'currency', 'billing_cycle', 'stripe_price_id', + 'stripe_product_id', 'paypal_plan_id', 'features', 'stock_quantity', diff --git a/website/app/Models/Service.php b/website/app/Models/Service.php index 3ec0fdd..af9ba25 100644 --- a/website/app/Models/Service.php +++ b/website/app/Models/Service.php @@ -8,11 +8,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Laravel\Cashier\Subscription; class Service extends Model { - use HasFactory; + use HasFactory, SoftDeletes; protected $fillable = [ 'user_id', diff --git a/website/app/Models/Setting.php b/website/app/Models/Setting.php index 944549e..238f421 100644 --- a/website/app/Models/Setting.php +++ b/website/app/Models/Setting.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Crypt; class Setting extends Model { @@ -15,37 +16,77 @@ class Setting extends Model ]; /** - * Get a setting value by key. + * Keys that must be encrypted at rest. + * + * @var array + */ + private const ENCRYPTED_KEYS = [ + 'virtfusion_api_token', + 'synergycp_api_token', + 'enhance_api_token', + 'pterodactyl_api_token', + 'enhance_organization_id', + 'discord_payment_webhook_url', + 'discord_provisioning_webhook_url', + 'discord_support_webhook_url', + 'discord_system_webhook_url', + ]; + + /** + * Get a setting value by key, automatically decrypting if needed. */ public static function get(string $key, mixed $default = null): mixed { $setting = static::query()->where('key', $key)->first(); - return $setting?->value ?? $default; + if (! $setting || $setting->value === null) { + return $default; + } + + if (in_array($key, self::ENCRYPTED_KEYS, true)) { + return self::decryptValue($setting->value); + } + + return $setting->value; } /** - * Set a setting value by key. + * Set a setting value by key, automatically encrypting if needed. */ public static function set(string $key, mixed $value, string $group = 'general'): void { + $storedValue = $value; + + if (in_array($key, self::ENCRYPTED_KEYS, true) && $value !== null && $value !== '') { + $storedValue = Crypt::encryptString((string) $value); + } + static::query()->updateOrCreate( ['key' => $key], - ['value' => $value, 'group' => $group], + ['value' => $storedValue, 'group' => $group], ); } /** * Get all settings for a given group as a key-value array. + * Encrypted values are automatically decrypted. * * @return array */ public static function getGroup(string $group): array { - return static::query() + $settings = static::query() ->where('group', $group) ->pluck('value', 'key') ->toArray(); + + foreach ($settings as $key => $value) { + if (in_array($key, self::ENCRYPTED_KEYS, true) && $value !== null) { + $settings[$key] = self::decryptValue($value); + } + } + + return $settings; } /** @@ -59,4 +100,25 @@ class Setting extends Model static::set($key, $value, $group); } } + + /** + * Check if a key stores encrypted data. + */ + public static function isEncryptedKey(string $key): bool + { + return in_array($key, self::ENCRYPTED_KEYS, true); + } + + /** + * Safely decrypt a value, returning the raw value if decryption fails. + */ + private static function decryptValue(string $value): string + { + try { + return Crypt::decryptString($value); + } catch (\Exception) { + // Value may not be encrypted yet (legacy data) + return $value; + } + } } diff --git a/website/app/Models/User.php b/website/app/Models/User.php index 006ce18..b592b66 100644 --- a/website/app/Models/User.php +++ b/website/app/Models/User.php @@ -29,6 +29,7 @@ class User extends Authenticatable implements MustVerifyEmail 'phone', 'company', 'admin_notes', + 'virtfusion_user_id', ]; /** @var list */ diff --git a/website/app/Notifications/AdminPasswordResetNotification.php b/website/app/Notifications/AdminPasswordResetNotification.php new file mode 100644 index 0000000..00c9aaa --- /dev/null +++ b/website/app/Notifications/AdminPasswordResetNotification.php @@ -0,0 +1,46 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Your Password Has Been Reset') + ->line('Your account password has been reset by an administrator.') + ->line('Your new password is:') + ->line('**'.$this->newPassword.'**') + ->line('Please log in and change your password immediately for security purposes.') + ->action('Log In', config('app.domains.account')); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'action' => 'password_reset', + 'message' => 'Your password has been reset by an administrator.', + ]; + } +} diff --git a/website/app/Notifications/InvoiceNotification.php b/website/app/Notifications/InvoiceNotification.php new file mode 100644 index 0000000..1ebb228 --- /dev/null +++ b/website/app/Notifications/InvoiceNotification.php @@ -0,0 +1,52 @@ + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $amount = number_format((float) $this->invoice->total, 2); + $currency = strtoupper($this->invoice->currency); + $invoiceUrl = 'https://'.config('app.domains.account').'/billing/invoices'; + + $mail = (new MailMessage) + ->subject("Invoice #{$this->invoice->number} - {$currency} {$amount}") + ->greeting("Hello {$notifiable->name},") + ->line("Please find below the details for invoice **#{$this->invoice->number}**.") + ->line("Amount Due: **{$currency} {$amount}**"); + + if ($this->invoice->due_date) { + $dueDate = $this->invoice->due_date->format('M j, Y'); + $mail->line("Due Date: **{$dueDate}**"); + } + + if ($this->invoice->notes) { + $mail->line("Notes: {$this->invoice->notes}"); + } + + return $mail + ->action('View Invoices', $invoiceUrl) + ->line('Thank you for your business!'); + } +} diff --git a/website/app/Providers/HorizonServiceProvider.php b/website/app/Providers/HorizonServiceProvider.php new file mode 100644 index 0000000..16967a4 --- /dev/null +++ b/website/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,34 @@ +hasRole('admin'); + }); + } +} diff --git a/website/app/Services/Billing/BillingServiceInterface.php b/website/app/Services/Billing/BillingServiceInterface.php index 2c58c19..80b17f9 100644 --- a/website/app/Services/Billing/BillingServiceInterface.php +++ b/website/app/Services/Billing/BillingServiceInterface.php @@ -14,7 +14,7 @@ interface BillingServiceInterface * * @return array{subscription_id: string, status: string, client_secret?: string, approval_url?: string} */ - public function createSubscription(User $user, Plan $plan, ?string $paymentMethodId = null, ?string $couponCode = null): array; + public function createSubscription(User $user, Plan $plan, ?string $paymentMethodId = null, ?string $couponCode = null, string $billingCycle = 'monthly'): array; /** * Cancel a subscription. diff --git a/website/app/Services/Billing/PayPalBillingService.php b/website/app/Services/Billing/PayPalBillingService.php index 968a101..8357724 100644 --- a/website/app/Services/Billing/PayPalBillingService.php +++ b/website/app/Services/Billing/PayPalBillingService.php @@ -20,8 +20,21 @@ class PayPalBillingService implements BillingServiceInterface $this->client->getAccessToken(); } - public function createSubscription(User $user, Plan $plan, ?string $paymentMethodId = null, ?string $couponCode = null): array + /** + * Note: $billingCycle is accepted for interface compatibility but is not used here. + * PayPal subscription billing frequency is configured on the PayPal plan itself, + * not at subscription creation time. Each billing cycle requires a separate PayPal plan. + */ + public function createSubscription(User $user, Plan $plan, ?string $paymentMethodId = null, ?string $couponCode = null, string $billingCycle = 'monthly'): array { + if ($billingCycle !== 'monthly') { + Log::warning('PayPal subscription created with non-monthly billing cycle — PayPal plan may not match', [ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'billing_cycle' => $billingCycle, + ]); + } + if (! $plan->paypal_plan_id) { throw new \RuntimeException('Plan does not have a PayPal plan ID configured.'); } diff --git a/website/app/Services/Billing/StripeBillingService.php b/website/app/Services/Billing/StripeBillingService.php index d7ea684..b43ee08 100644 --- a/website/app/Services/Billing/StripeBillingService.php +++ b/website/app/Services/Billing/StripeBillingService.php @@ -13,7 +13,7 @@ use Laravel\Cashier\Exceptions\IncompletePayment; class StripeBillingService implements BillingServiceInterface { - public function createSubscription(User $user, Plan $plan, ?string $paymentMethodId = null, ?string $couponCode = null): array + public function createSubscription(User $user, Plan $plan, ?string $paymentMethodId = null, ?string $couponCode = null, string $billingCycle = 'monthly'): array { if (! $user->hasStripeId()) { $user->createAsStripeCustomer(); @@ -34,17 +34,18 @@ class StripeBillingService implements BillingServiceInterface } try { - $result = DB::transaction(function () use ($subscription, $plan) { + $result = DB::transaction(function () use ($subscription, $plan, $billingCycle) { $cashierSubscription = $subscription->create(); $cashierSubscription->update([ 'plan_id' => $plan->id, + 'billing_cycle' => $billingCycle, 'gateway' => 'stripe', 'gateway_subscription_id' => $cashierSubscription->stripe_id, 'gateway_customer_id' => $cashierSubscription->user->stripe_id, 'gateway_price_id' => $plan->stripe_price_id, 'current_period_start' => now(), - 'current_period_end' => $this->calculatePeriodEnd($plan->billing_cycle), + 'current_period_end' => $this->calculatePeriodEnd($billingCycle), ]); return [ diff --git a/website/app/Services/Provisioning/EnhanceService.php b/website/app/Services/Provisioning/EnhanceService.php index d50ba1c..4191c35 100644 --- a/website/app/Services/Provisioning/EnhanceService.php +++ b/website/app/Services/Provisioning/EnhanceService.php @@ -21,8 +21,9 @@ class EnhanceService implements ProvisioningServiceInterface public function __construct() { - $this->baseUrl = rtrim(config('services.enhance.url', ''), '/'); - $this->token = config('services.enhance.token', ''); + // Read from database settings (configured via admin panel) + $this->baseUrl = rtrim(\App\Models\Setting::get('enhance_api_url', ''), '/'); + $this->token = \App\Models\Setting::get('enhance_api_token', ''); } public function provision(Subscription $subscription): Service @@ -34,14 +35,19 @@ class EnhanceService implements ProvisioningServiceInterface throw new RuntimeException('Subscription has no associated plan.'); } - $service = Service::create([ - 'user_id' => $user->id, - 'subscription_id' => $subscription->id, - 'plan_id' => $plan->id, - 'service_type' => 'hosting', - 'platform' => 'enhance', - 'status' => 'pending', - ]); + $service = Service::firstOrCreate( + ['subscription_id' => $subscription->id, 'service_type' => 'hosting'], + [ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'platform' => 'enhance', + 'status' => 'pending', + ], + ); + + if ($service->status === 'failed') { + $service->update(['status' => 'pending']); + } $this->logAction($service, 'provision', 'pending'); diff --git a/website/app/Services/Provisioning/PterodactylService.php b/website/app/Services/Provisioning/PterodactylService.php index 4ec9031..325c294 100644 --- a/website/app/Services/Provisioning/PterodactylService.php +++ b/website/app/Services/Provisioning/PterodactylService.php @@ -22,8 +22,9 @@ class PterodactylService implements ProvisioningServiceInterface public function __construct() { - $this->baseUrl = rtrim(config('services.pterodactyl.url', ''), '/'); - $this->apiKey = config('services.pterodactyl.api_key', ''); + // Read from database settings (configured via admin panel) + $this->baseUrl = rtrim(\App\Models\Setting::get('pterodactyl_api_url', ''), '/'); + $this->apiKey = \App\Models\Setting::get('pterodactyl_api_token', ''); } public function provision(Subscription $subscription): Service @@ -35,14 +36,19 @@ class PterodactylService implements ProvisioningServiceInterface throw new RuntimeException('Subscription has no associated plan.'); } - $service = Service::create([ - 'user_id' => $user->id, - 'subscription_id' => $subscription->id, - 'plan_id' => $plan->id, - 'service_type' => 'game', - 'platform' => 'pterodactyl', - 'status' => 'pending', - ]); + $service = Service::firstOrCreate( + ['subscription_id' => $subscription->id, 'service_type' => 'game'], + [ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'platform' => 'pterodactyl', + 'status' => 'pending', + ], + ); + + if ($service->status === 'failed') { + $service->update(['status' => 'pending']); + } $this->logAction($service, 'provision', 'pending'); diff --git a/website/app/Services/Provisioning/SynergyCPService.php b/website/app/Services/Provisioning/SynergyCPService.php index fb25505..f6707a8 100644 --- a/website/app/Services/Provisioning/SynergyCPService.php +++ b/website/app/Services/Provisioning/SynergyCPService.php @@ -21,8 +21,9 @@ class SynergyCPService implements ProvisioningServiceInterface public function __construct() { - $this->baseUrl = rtrim(config('services.synergycp.url', ''), '/'); - $this->token = config('services.synergycp.token', ''); + // Read from database settings (configured via admin panel) + $this->baseUrl = rtrim(\App\Models\Setting::get('synergycp_api_url', ''), '/'); + $this->token = \App\Models\Setting::get('synergycp_api_token', ''); } public function provision(Subscription $subscription): Service @@ -34,14 +35,19 @@ class SynergyCPService implements ProvisioningServiceInterface throw new RuntimeException('Subscription has no associated plan.'); } - $service = Service::create([ - 'user_id' => $user->id, - 'subscription_id' => $subscription->id, - 'plan_id' => $plan->id, - 'service_type' => 'dedicated', - 'platform' => 'synergycp', - 'status' => 'pending', - ]); + $service = Service::firstOrCreate( + ['subscription_id' => $subscription->id, 'service_type' => 'dedicated'], + [ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'platform' => 'synergycp', + 'status' => 'pending', + ], + ); + + if ($service->status === 'failed') { + $service->update(['status' => 'pending']); + } $this->logAction($service, 'provision', 'pending'); diff --git a/website/app/Services/Provisioning/VirtFusionService.php b/website/app/Services/Provisioning/VirtFusionService.php index 656cf93..17c838c 100644 --- a/website/app/Services/Provisioning/VirtFusionService.php +++ b/website/app/Services/Provisioning/VirtFusionService.php @@ -19,74 +19,207 @@ class VirtFusionService implements ProvisioningServiceInterface private readonly string $token; + private ?string $csrfToken = null; + public function __construct() { - $this->baseUrl = rtrim(config('services.virtfusion.url', ''), '/'); - $this->token = config('services.virtfusion.token', ''); + // Read from database settings (configured via admin panel) + $this->baseUrl = rtrim(\App\Models\Setting::get('virtfusion_api_url', ''), '/'); + $this->token = \App\Models\Setting::get('virtfusion_api_token', ''); + + if (empty($this->baseUrl)) { + throw new RuntimeException('VirtFusion API URL is not configured. Please configure it in Admin → Settings → API.'); + } + + if (empty($this->token)) { + throw new RuntimeException('VirtFusion API token is not configured. Please configure it in Admin → Settings → API.'); + } + } + + private function getCsrfToken(): string + { + if ($this->csrfToken !== null) { + return $this->csrfToken; + } + + try { + // Fetch CSRF token from VirtFusion API + $response = Http::withToken($this->token) + ->baseUrl($this->baseUrl) + ->get('/sanctum/csrf-cookie'); + + // Extract CSRF token from cookies if available + $cookies = $response->cookies(); + foreach ($cookies as $cookie) { + if ($cookie->getName() === 'XSRF-TOKEN') { + $this->csrfToken = $cookie->getValue(); + + return $this->csrfToken; + } + } + + // If no CSRF endpoint exists, return empty string (token-based auth only) + $this->csrfToken = ''; + + return $this->csrfToken; + } catch (\Exception $e) { + Log::warning('Failed to fetch CSRF token from VirtFusion', [ + 'error' => $e->getMessage(), + ]); + + $this->csrfToken = ''; + + return $this->csrfToken; + } } public function provision(Subscription $subscription): Service { - $plan = $subscription->plan; $user = $subscription->user; + $plan = \App\Models\Plan::find($subscription->plan_id); if (! $plan) { throw new RuntimeException('Subscription has no associated plan.'); } - $service = Service::create([ - 'user_id' => $user->id, - 'subscription_id' => $subscription->id, - 'plan_id' => $plan->id, - 'service_type' => 'vps', - 'platform' => 'virtfusion', - 'status' => 'pending', - ]); + $service = Service::firstOrCreate( + ['subscription_id' => $subscription->id, 'service_type' => 'vps'], + [ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'platform' => 'virtfusion', + 'status' => 'pending', + ], + ); + + if ($service->status === 'failed') { + $service->update(['status' => 'pending']); + } $this->logAction($service, 'provision', 'pending'); try { - $response = $this->client()->post('/api/v1/servers', [ - 'package_id' => $plan->features['virtfusion_package_id'] ?? null, - 'user_email' => $user->email, - 'hostname' => $plan->features['default_hostname'] ?? 'server.ezscale.cloud', - ]); + // Ensure user exists on VirtFusion panel + $virtfusionUserId = $this->ensureUserExists($user); - if (! $response->successful()) { - $this->logAction($service, 'provision', 'failed', $response->json(), $response->body()); - - throw new RuntimeException("VirtFusion provisioning failed: {$response->body()}"); + // Get custom specs from plan + $specs = $this->getPlanSpecs($plan); + if (! $specs) { + throw new RuntimeException('Plan does not have valid specifications.'); } - $data = $response->json(); - $serverId = (string) ($data['data']['id'] ?? $data['id'] ?? ''); + // Get configuration for OS template and SSH keys from the subscription record + $rawConfig = $subscription->provisioning_config; + $config = is_string($rawConfig) ? json_decode($rawConfig, true) ?? [] : ($rawConfig ?? []); + $operatingSystemId = $config['os_template_id'] ?? 1; + $authMethod = $config['auth_method'] ?? 'password'; + $sshKey = $authMethod === 'ssh' && ! empty($config['ssh_key']) ? $config['ssh_key'] : null; + // Step 1: Create SSH key in VirtFusion if provided (to get SSH key ID) + $sshKeyIds = []; + if ($sshKey) { + $sshKeyId = $this->createSshKey($virtfusionUserId, $sshKey); + if ($sshKeyId) { + $sshKeyIds[] = $sshKeyId; + Log::info('Created SSH key in VirtFusion', [ + 'ssh_key_id' => $sshKeyId, + 'user_id' => $virtfusionUserId, + ]); + } + } + + // Step 2: Create server with custom specs (no packageId needed if we provide specs directly) + $createPayload = [ + 'userId' => $virtfusionUserId, + 'hypervisorId' => $plan->features['virtfusion_hypervisor_id'] ?? 1, + 'cpuCores' => $specs['cpu'], + 'memory' => $specs['memory'], + 'storage' => $specs['disk'], + 'traffic' => $specs['bandwidth'] ?? 1000, + ]; + + $createResponse = $this->client()->post('/servers', $createPayload); + + if (! $createResponse->successful()) { + $this->logAction($service, 'provision', 'failed', $createResponse->json(), $createResponse->body()); + throw new RuntimeException("VirtFusion server creation failed: {$createResponse->body()}"); + } + + $createData = $createResponse->json(); + $serverId = (string) ($createData['data']['id'] ?? $createData['id'] ?? ''); + + if (empty($serverId)) { + throw new RuntimeException('VirtFusion returned invalid server ID'); + } + + Log::info('VirtFusion server created', [ + 'service_id' => $service->id, + 'server_id' => $serverId, + 'specs' => $specs, + ]); + + // Step 3: Build server with OS template and SSH keys + $buildPayload = [ + 'operatingSystemId' => $operatingSystemId, + ]; + + if (! empty($sshKeyIds)) { + $buildPayload['sshKeys'] = $sshKeyIds; + } + + $buildResponse = $this->client()->post("/servers/{$serverId}/build", $buildPayload); + + if (! $buildResponse->successful()) { + $this->logAction($service, 'build', 'failed', $buildResponse->json(), $buildResponse->body()); + Log::warning('VirtFusion build failed but server was created', [ + 'service_id' => $service->id, + 'server_id' => $serverId, + 'error' => $buildResponse->body(), + ]); + // Continue anyway - server exists, user can rebuild manually + } else { + $buildData = $buildResponse->json(); + $this->logAction($service, 'build', 'success', $buildData); + Log::info('VirtFusion server built', [ + 'service_id' => $service->id, + 'server_id' => $serverId, + 'os_id' => $operatingSystemId, + 'ssh_keys_count' => count($sshKeyIds), + ]); + } + + // Update service with provisioned data $service->update([ 'platform_service_id' => $serverId, 'status' => 'active', - 'ipv4_address' => $data['data']['ip_address'] ?? $data['ip_address'] ?? null, - 'hostname' => $data['data']['hostname'] ?? $data['hostname'] ?? null, + 'ipv4_address' => $createData['data']['ip_address'] ?? $createData['ip_address'] ?? null, + 'hostname' => $createData['data']['hostname'] ?? $createData['hostname'] ?? null, 'provisioned_at' => now(), + 'provisioning_info' => [ + 'os_template_id' => $operatingSystemId, + 'auth_method' => $config['auth_method'] ?? 'password', + 'specs' => $specs, + 'ssh_key_ids' => $sshKeyIds, + ], ]); - $this->logAction($service, 'provision', 'success', $data); + $this->logAction($service, 'provision', 'success', $createData); $service = $service->fresh(); + // Send credentials notification if ($service->user) { $service->user->notify(new ServiceCredentialsNotification($service, [ - 'username' => $data['data']['username'] ?? 'root', - 'password' => $data['data']['password'] ?? 'see control panel', + 'username' => $createData['data']['username'] ?? 'root', + 'password' => $createData['data']['password'] ?? 'Check VirtFusion panel', 'hostname' => $service->hostname ?? $service->ipv4_address ?? 'N/A', 'ip_address' => $service->ipv4_address ?? 'Pending', - 'port' => $data['data']['ssh_port'] ?? 22, - 'panel_url' => $data['data']['vnc_url'] ?? null, + 'port' => $createData['data']['ssh_port'] ?? 22, + 'panel_url' => $createData['data']['vnc_url'] ?? null, ])); } return $service; - } catch (RuntimeException $e) { - throw $e; } catch (\Exception $e) { $this->logAction($service, 'provision', 'failed', errorMessage: $e->getMessage()); @@ -103,7 +236,7 @@ class VirtFusionService implements ProvisioningServiceInterface $this->logAction($service, 'suspend', 'pending'); try { - $response = $this->client()->post("/api/v1/servers/{$service->platform_service_id}/suspend"); + $response = $this->client()->post("/servers/{$service->platform_service_id}/suspend"); if (! $response->successful()) { $this->logAction($service, 'suspend', 'failed', $response->json(), $response->body()); @@ -138,7 +271,7 @@ class VirtFusionService implements ProvisioningServiceInterface $this->logAction($service, 'unsuspend', 'pending'); try { - $response = $this->client()->post("/api/v1/servers/{$service->platform_service_id}/unsuspend"); + $response = $this->client()->post("/servers/{$service->platform_service_id}/unsuspend"); if (! $response->successful()) { $this->logAction($service, 'unsuspend', 'failed', $response->json(), $response->body()); @@ -173,7 +306,10 @@ class VirtFusionService implements ProvisioningServiceInterface $this->logAction($service, 'terminate', 'pending'); try { - $response = $this->client()->delete("/api/v1/servers/{$service->platform_service_id}"); + // Delete with 5-minute delay (300 seconds) + $response = $this->client()->delete("/servers/{$service->platform_service_id}", [ + 'delay' => 300, + ]); if (! $response->successful()) { $this->logAction($service, 'terminate', 'failed', $response->json(), $response->body()); @@ -209,7 +345,7 @@ class VirtFusionService implements ProvisioningServiceInterface $this->validateServicePlatform($service); try { - $response = $this->client()->get("/api/v1/servers/{$service->platform_service_id}"); + $response = $this->client()->get("/servers/{$service->platform_service_id}"); if (! $response->successful()) { return ['status' => 'unknown']; @@ -244,7 +380,7 @@ class VirtFusionService implements ProvisioningServiceInterface $stored = $service->credentials ?? []; try { - $response = $this->client()->get("/api/v1/servers/{$service->platform_service_id}"); + $response = $this->client()->get("/servers/{$service->platform_service_id}"); if ($response->successful()) { $data = $response->json(); @@ -275,10 +411,20 @@ class VirtFusionService implements ProvisioningServiceInterface private function client(): PendingRequest { - return Http::withToken($this->token) + $client = Http::withToken($this->token) ->baseUrl($this->baseUrl) ->acceptJson() ->timeout(30); + + // Add CSRF token header if available + $csrfToken = $this->getCsrfToken(); + if (! empty($csrfToken)) { + $client = $client->withHeaders([ + 'X-XSRF-TOKEN' => $csrfToken, + ]); + } + + return $client; } private function validateServicePlatform(Service $service): void @@ -288,6 +434,516 @@ class VirtFusionService implements ProvisioningServiceInterface } } + public function boot(Service $service): bool + { + $this->validateServicePlatform($service); + + $this->logAction($service, 'boot', 'pending'); + + try { + $response = $this->client()->post("/servers/{$service->platform_service_id}/power/boot"); + + if (! $response->successful()) { + $this->logAction($service, 'boot', 'failed', $response->json(), $response->body()); + + return false; + } + + $this->logAction($service, 'boot', 'success', $response->json()); + + return true; + } catch (\Exception $e) { + Log::error('VirtFusion boot failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + $this->logAction($service, 'boot', 'failed', errorMessage: $e->getMessage()); + + return false; + } + } + + public function shutdown(Service $service): bool + { + $this->validateServicePlatform($service); + + $this->logAction($service, 'shutdown', 'pending'); + + try { + $response = $this->client()->post("/servers/{$service->platform_service_id}/power/shutdown"); + + if (! $response->successful()) { + $this->logAction($service, 'shutdown', 'failed', $response->json(), $response->body()); + + return false; + } + + $this->logAction($service, 'shutdown', 'success', $response->json()); + + return true; + } catch (\Exception $e) { + Log::error('VirtFusion shutdown failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + $this->logAction($service, 'shutdown', 'failed', errorMessage: $e->getMessage()); + + return false; + } + } + + public function restart(Service $service): bool + { + $this->validateServicePlatform($service); + + $this->logAction($service, 'restart', 'pending'); + + try { + $response = $this->client()->post("/servers/{$service->platform_service_id}/power/restart"); + + if (! $response->successful()) { + $this->logAction($service, 'restart', 'failed', $response->json(), $response->body()); + + return false; + } + + $this->logAction($service, 'restart', 'success', $response->json()); + + return true; + } catch (\Exception $e) { + Log::error('VirtFusion restart failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + $this->logAction($service, 'restart', 'failed', errorMessage: $e->getMessage()); + + return false; + } + } + + public function poweroff(Service $service): bool + { + $this->validateServicePlatform($service); + + $this->logAction($service, 'poweroff', 'pending'); + + try { + $response = $this->client()->post("/servers/{$service->platform_service_id}/power/poweroff"); + + if (! $response->successful()) { + $this->logAction($service, 'poweroff', 'failed', $response->json(), $response->body()); + + return false; + } + + $this->logAction($service, 'poweroff', 'success', $response->json()); + + return true; + } catch (\Exception $e) { + Log::error('VirtFusion poweroff failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + $this->logAction($service, 'poweroff', 'failed', errorMessage: $e->getMessage()); + + return false; + } + } + + /** + * @return array{password?: string, username?: string} + */ + public function resetPassword(Service $service): array + { + $this->validateServicePlatform($service); + + $this->logAction($service, 'reset_password', 'pending'); + + try { + $response = $this->client()->post("/servers/{$service->platform_service_id}/resetPassword"); + + if (! $response->successful()) { + $this->logAction($service, 'reset_password', 'failed', $response->json(), $response->body()); + + throw new RuntimeException("Failed to reset password: {$response->body()}"); + } + + $data = $response->json(); + $this->logAction($service, 'reset_password', 'success', $data); + + return [ + 'password' => $data['data']['password'] ?? $data['password'] ?? null, + 'username' => $data['data']['username'] ?? $data['username'] ?? 'root', + ]; + } catch (RuntimeException $e) { + throw $e; + } catch (\Exception $e) { + Log::error('VirtFusion reset password failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + $this->logAction($service, 'reset_password', 'failed', errorMessage: $e->getMessage()); + + throw new RuntimeException("Failed to reset password: {$e->getMessage()}", 0, $e); + } + } + + public function getVncUrl(Service $service): ?string + { + $this->validateServicePlatform($service); + + try { + $response = $this->client()->get("/servers/{$service->platform_service_id}/vnc"); + + if (! $response->successful()) { + Log::warning('VirtFusion get VNC URL failed', [ + 'service_id' => $service->id, + 'response' => $response->body(), + ]); + + return null; + } + + $data = $response->json(); + + return $data['data']['url'] ?? $data['url'] ?? null; + } catch (\Exception $e) { + Log::error('VirtFusion get VNC URL failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + public function rebuild(Service $service, int $operatingSystemId): bool + { + $this->validateServicePlatform($service); + + $this->logAction($service, 'rebuild', 'pending'); + + try { + $response = $this->client()->post("/servers/{$service->platform_service_id}/build", [ + 'operatingSystemId' => $operatingSystemId, + ]); + + if (! $response->successful()) { + $this->logAction($service, 'rebuild', 'failed', $response->json(), $response->body()); + + return false; + } + + $this->logAction($service, 'rebuild', 'success', $response->json()); + + return true; + } catch (\Exception $e) { + Log::error('VirtFusion rebuild failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + $this->logAction($service, 'rebuild', 'failed', errorMessage: $e->getMessage()); + + return false; + } + } + + /** + * @return array> + */ + public function getTemplates(Service $service): array + { + $this->validateServicePlatform($service); + + try { + $response = $this->client()->get("/servers/{$service->platform_service_id}/templates"); + + if (! $response->successful()) { + Log::warning('VirtFusion get templates failed', [ + 'service_id' => $service->id, + 'response' => $response->body(), + ]); + + return []; + } + + $data = $response->json(); + + return $data['data'] ?? []; + } catch (\Exception $e) { + Log::error('VirtFusion get templates failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return []; + } + } + + /** + * Get first available package ID from VirtFusion. + */ + private function getFirstAvailablePackageId(): ?int + { + try { + $response = $this->client()->get('/packages'); + + if (! $response->successful()) { + return null; + } + + $data = $response->json(); + $packages = $data['data'] ?? []; + + return ! empty($packages) ? (int) $packages[0]['id'] : null; + } catch (\Exception $e) { + Log::error('Failed to fetch VirtFusion packages', [ + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Get all available OS templates from VirtFusion by package ID. + * + * @return array{groups: array>, templates: array>} + */ + public function getTemplatesByPackage(?int $packageId = null): array + { + try { + // If no package ID provided, get the first available package + if ($packageId === null) { + $packageId = $this->getFirstAvailablePackageId(); + if ($packageId === null) { + Log::warning('No VirtFusion packages available'); + + return ['groups' => [], 'templates' => []]; + } + } + + // Fetch templates for specific package + $response = $this->client()->get("/media/templates/fromServerPackageSpec/{$packageId}"); + + if (! $response->successful()) { + Log::warning('VirtFusion get templates by package failed', [ + 'package_id' => $packageId, + 'response' => $response->body(), + ]); + + // If the package doesn't exist, try the first available package + if ($response->status() === 404) { + $fallbackPackageId = $this->getFirstAvailablePackageId(); + if ($fallbackPackageId !== null && $fallbackPackageId !== $packageId) { + return $this->getTemplatesByPackage($fallbackPackageId); + } + } + + return ['groups' => [], 'templates' => []]; + } + + $data = $response->json(); + $templateGroups = $data['data'] ?? []; + + // Flatten the nested structure - extract all templates from all OS families + $allTemplates = []; + foreach ($templateGroups as $group) { + if (isset($group['templates']) && is_array($group['templates'])) { + foreach ($group['templates'] as $template) { + $allTemplates[] = $template; + } + } + } + + // Return both grouped and flattened data + return [ + 'groups' => $templateGroups, + 'templates' => $allTemplates, + ]; + } catch (\Exception $e) { + Log::error('VirtFusion get templates by package failed', [ + 'package_id' => $packageId, + 'error' => $e->getMessage(), + ]); + + return ['groups' => [], 'templates' => []]; + } + } + + /** + * Get all available OS templates from VirtFusion (legacy method - use getTemplatesByPackage instead). + * + * @return array> + */ + public function getAllTemplates(int $hypervisorId = 1): array + { + // Deprecated: use getTemplatesByPackage instead + return $this->getTemplatesByPackage($hypervisorId); + } + + /** + * Create SSH key in VirtFusion and return its ID. + */ + private function createSshKey(int $virtfusionUserId, string $publicKey): ?int + { + try { + $response = $this->client()->post('/sshkeys', [ + 'userId' => $virtfusionUserId, + 'name' => 'VPS Key - '.now()->format('Y-m-d H:i:s'), + 'publicKey' => trim($publicKey), + ]); + + if (! $response->successful()) { + Log::warning('Failed to create SSH key in VirtFusion', [ + 'user_id' => $virtfusionUserId, + 'response' => $response->body(), + ]); + + return null; + } + + $data = $response->json(); + $sshKeyId = (int) ($data['data']['id'] ?? $data['id'] ?? 0); + + return $sshKeyId > 0 ? $sshKeyId : null; + } catch (\Exception $e) { + Log::error('Exception creating SSH key in VirtFusion', [ + 'user_id' => $virtfusionUserId, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * Ensure user exists on VirtFusion panel, create if not. + */ + private function ensureUserExists(\App\Models\User $user): int + { + // Check if user already has a VirtFusion user ID stored + if ($user->virtfusion_user_id) { + return $user->virtfusion_user_id; + } + + // Try to find user by email + try { + $response = $this->client()->get('/users', [ + 'email' => $user->email, + ]); + + if ($response->successful()) { + $data = $response->json(); + $users = $data['data'] ?? []; + + // If user found, store the ID and return it + foreach ($users as $vfUser) { + if ($vfUser['email'] === $user->email) { + $userId = (int) $vfUser['id']; + $user->update(['virtfusion_user_id' => $userId]); + + return $userId; + } + } + } + } catch (\Exception $e) { + Log::warning('Failed to search for VirtFusion user', [ + 'email' => $user->email, + 'error' => $e->getMessage(), + ]); + } + + // User doesn't exist, create them + try { + $response = $this->client()->post('/users', [ + 'name' => $user->name, + 'email' => $user->email, + 'password' => bin2hex(random_bytes(16)), // Random password, user will reset via VF panel + 'confirmed' => true, + ]); + + if (! $response->successful()) { + Log::error('Failed to create VirtFusion user', [ + 'email' => $user->email, + 'response' => $response->body(), + ]); + + throw new RuntimeException("Failed to create VirtFusion user: {$response->body()}"); + } + + $data = $response->json(); + $userId = (int) ($data['data']['id'] ?? $data['id'] ?? 0); + + if ($userId > 0) { + $user->update(['virtfusion_user_id' => $userId]); + Log::info('Created VirtFusion user', [ + 'email' => $user->email, + 'virtfusion_user_id' => $userId, + ]); + + return $userId; + } + + throw new RuntimeException('VirtFusion user creation returned invalid user ID'); + } catch (RuntimeException $e) { + throw $e; + } catch (\Exception $e) { + Log::error('Exception creating VirtFusion user', [ + 'email' => $user->email, + 'error' => $e->getMessage(), + ]); + + throw new RuntimeException("Failed to create VirtFusion user: {$e->getMessage()}", 0, $e); + } + } + + /** + * Get VirtFusion package specs from plan. + * + * @return array|null + */ + private function getPlanSpecs(\App\Models\Plan $plan): ?array + { + // Extract specs from plan features or use defaults based on plan name + $features = $plan->features ?? []; + + // If specs are explicitly defined in features, use them + if (isset($features['cpu']) && isset($features['memory']) && isset($features['disk'])) { + return [ + 'cpu' => $features['cpu'], + 'memory' => $features['memory'], // in MB + 'disk' => $features['disk'], // in GB + 'bandwidth' => $features['bandwidth'] ?? 1000, // in GB + ]; + } + + // Otherwise, parse from plan name (e.g., "Nano" -> 1 CPU, 1GB RAM, 25GB SSD) + $nameToSpecs = [ + 'nano' => ['cpu' => 1, 'memory' => 1024, 'disk' => 25, 'bandwidth' => 1000], + 'micro' => ['cpu' => 1, 'memory' => 2048, 'disk' => 50, 'bandwidth' => 2000], + 'mini' => ['cpu' => 2, 'memory' => 4096, 'disk' => 75, 'bandwidth' => 3000], + 'standard' => ['cpu' => 2, 'memory' => 8192, 'disk' => 100, 'bandwidth' => 4000], + 'plus' => ['cpu' => 4, 'memory' => 16384, 'disk' => 150, 'bandwidth' => 5000], + 'pro' => ['cpu' => 6, 'memory' => 32768, 'disk' => 200, 'bandwidth' => 6000], + ]; + + $planName = strtolower($plan->name); + foreach ($nameToSpecs as $key => $specs) { + if (str_contains($planName, $key)) { + return $specs; + } + } + + return null; + } + /** * @param array|null $response */ diff --git a/website/boost.json b/website/boost.json index 194b80a..541e257 100644 --- a/website/boost.json +++ b/website/boost.json @@ -8,6 +8,6 @@ "sail": false, "skills": [ "pest-testing", - "tailwindcss-development" + "inertia-vue-development" ] } diff --git a/website/bootstrap/app.php b/website/bootstrap/app.php index d1459c9..e0b934b 100644 --- a/website/bootstrap/app.php +++ b/website/bootstrap/app.php @@ -34,6 +34,11 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->validateCsrfTokens(except: [ 'webhooks/*', + 'api/*', + 'stripe/webhook', + 'oauth/*', + // Admin API endpoints for testing + '*/settings/test-*', ]); $middleware->web(append: [ diff --git a/website/bootstrap/providers.php b/website/bootstrap/providers.php index 290f57d..5db444d 100644 --- a/website/bootstrap/providers.php +++ b/website/bootstrap/providers.php @@ -3,4 +3,5 @@ return [ App\Providers\AppServiceProvider::class, App\Providers\FortifyServiceProvider::class, + App\Providers\HorizonServiceProvider::class, ]; diff --git a/website/composer.json b/website/composer.json index 65746c7..d6cc5f0 100644 --- a/website/composer.json +++ b/website/composer.json @@ -15,6 +15,7 @@ "laravel/cashier": "^16.2", "laravel/fortify": "^1.34", "laravel/framework": "^12.0", + "laravel/horizon": "^5.43", "laravel/passport": "^13.4", "laravel/tinker": "^2.10.1", "spatie/laravel-permission": "^6.24", diff --git a/website/composer.lock b/website/composer.lock index fc2b7ca..2e4d50d 100644 --- a/website/composer.lock +++ b/website/composer.lock @@ -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": "5c74851b1987089bba21c4645c42a0f3", + "content-hash": "087f780f9db61d870cf4fb516369a71a", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1962,6 +1962,85 @@ }, "time": "2026-02-04T18:34:13+00:00" }, + { + "name": "laravel/horizon", + "version": "v5.43.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/horizon.git", + "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/horizon/zipball/2a04285ba83915511afbe987cbfedafdc27fd2de", + "reference": "2a04285ba83915511afbe987cbfedafdc27fd2de", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcntl": "*", + "ext-posix": "*", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.17|^3.0", + "php": "^8.0", + "ramsey/uuid": "^4.0", + "symfony/console": "^6.0|^7.0", + "symfony/error-handler": "^6.0|^7.0", + "symfony/polyfill-php83": "^1.28", + "symfony/process": "^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8", + "phpstan/phpstan": "^1.10|^2.0", + "predis/predis": "^1.1|^2.0|^3.0" + }, + "suggest": { + "ext-redis": "Required to use the Redis PHP driver.", + "predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Horizon": "Laravel\\Horizon\\Horizon" + }, + "providers": [ + "Laravel\\Horizon\\HorizonServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Horizon\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Dashboard and code-driven configuration for Laravel queues.", + "keywords": [ + "laravel", + "queue" + ], + "support": { + "issues": "https://github.com/laravel/horizon/issues", + "source": "https://github.com/laravel/horizon/tree/v5.43.0" + }, + "time": "2026-01-15T15:10:56+00:00" + }, { "name": "laravel/passport", "version": "v13.4.3", diff --git a/website/config/horizon.php b/website/config/horizon.php new file mode 100644 index 0000000..4e2f287 --- /dev/null +++ b/website/config/horizon.php @@ -0,0 +1,254 @@ + env('HORIZON_NAME'), + + /* + |-------------------------------------------------------------------------- + | Horizon Domain + |-------------------------------------------------------------------------- + | + | This is the subdomain where Horizon will be accessible from. If this + | setting is null, Horizon will reside under the same domain as the + | application. Otherwise, this value will serve as the subdomain. + | + */ + + 'domain' => env('HORIZON_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Horizon Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Horizon will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => env('HORIZON_PATH', 'horizon'), + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Connection + |-------------------------------------------------------------------------- + | + | This is the name of the Redis connection where Horizon will store the + | meta information required for it to function. It includes the list + | of supervisors, failed jobs, job metrics, and other information. + | + */ + + 'use' => 'default', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Prefix + |-------------------------------------------------------------------------- + | + | This prefix will be used when storing all Horizon data in Redis. You + | may modify the prefix when you are running multiple installations + | of Horizon on the same server so that they don't have problems. + | + */ + + 'prefix' => env( + 'HORIZON_PREFIX', + Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:' + ), + + /* + |-------------------------------------------------------------------------- + | Horizon Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will get attached onto each Horizon route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Queue Wait Time Thresholds + |-------------------------------------------------------------------------- + | + | This option allows you to configure when the LongWaitDetected event + | will be fired. Every connection / queue combination may have its + | own, unique threshold (in seconds) before this event is fired. + | + */ + + 'waits' => [ + 'redis:default' => 60, + ], + + /* + |-------------------------------------------------------------------------- + | Job Trimming Times + |-------------------------------------------------------------------------- + | + | Here you can configure for how long (in minutes) you desire Horizon to + | persist the recent and failed jobs. Typically, recent jobs are kept + | for one hour while all failed jobs are stored for an entire week. + | + */ + + 'trim' => [ + 'recent' => 60, + 'pending' => 60, + 'completed' => 60, + 'recent_failed' => 10080, + 'failed' => 10080, + 'monitored' => 10080, + ], + + /* + |-------------------------------------------------------------------------- + | Silenced Jobs + |-------------------------------------------------------------------------- + | + | Silencing a job will instruct Horizon to not place the job in the list + | of completed jobs within the Horizon dashboard. This setting may be + | used to fully remove any noisy jobs from the completed jobs list. + | + */ + + 'silenced' => [ + // App\Jobs\ExampleJob::class, + ], + + 'silenced_tags' => [ + // 'notifications', + ], + + /* + |-------------------------------------------------------------------------- + | Metrics + |-------------------------------------------------------------------------- + | + | Here you can configure how many snapshots should be kept to display in + | the metrics graph. This will get used in combination with Horizon's + | `horizon:snapshot` schedule to define how long to retain metrics. + | + */ + + 'metrics' => [ + 'trim_snapshots' => [ + 'job' => 24, + 'queue' => 24, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Fast Termination + |-------------------------------------------------------------------------- + | + | When this option is enabled, Horizon's "terminate" command will not + | wait on all of the workers to terminate unless the --wait option + | is provided. Fast termination can shorten deployment delay by + | allowing a new instance of Horizon to start while the last + | instance will continue to terminate each of its workers. + | + */ + + 'fast_termination' => false, + + /* + |-------------------------------------------------------------------------- + | Memory Limit (MB) + |-------------------------------------------------------------------------- + | + | This value describes the maximum amount of memory the Horizon master + | supervisor may consume before it is terminated and restarted. For + | configuring these limits on your workers, see the next section. + | + */ + + 'memory_limit' => 64, + + /* + |-------------------------------------------------------------------------- + | Queue Worker Configuration + |-------------------------------------------------------------------------- + | + | Here you may define the queue worker settings used by your application + | in all environments. These supervisors and settings handle all your + | queued jobs and will be provisioned by Horizon during deployment. + | + */ + + 'defaults' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'maxProcesses' => 1, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 128, + 'tries' => 1, + 'timeout' => 60, + 'nice' => 0, + ], + ], + + 'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'maxProcesses' => 10, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], + ], + + 'local' => [ + 'supervisor-1' => [ + 'maxProcesses' => 3, + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | File Watcher Configuration + |-------------------------------------------------------------------------- + | + | The following list of directories and files will be watched when using + | the `horizon:listen` command. Whenever any directories or files are + | changed, Horizon will automatically restart to apply all changes. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config/**/*.php', + 'database/**/*.php', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + 'composer.json', + '.env', + ], +]; diff --git a/website/config/services.php b/website/config/services.php index 692080d..4429a8a 100644 --- a/website/config/services.php +++ b/website/config/services.php @@ -36,23 +36,23 @@ return [ ], 'virtfusion' => [ - 'url' => env('VIRTFUSION_API_URL'), - 'token' => env('VIRTFUSION_API_TOKEN'), + 'url' => env('VIRTFUSION_API_URL', ''), + 'token' => env('VIRTFUSION_API_TOKEN', ''), ], 'synergycp' => [ - 'url' => env('SYNERGYCP_API_URL'), - 'token' => env('SYNERGYCP_API_TOKEN'), + 'url' => env('SYNERGYCP_API_URL', ''), + 'token' => env('SYNERGYCP_API_TOKEN', ''), ], 'enhance' => [ - 'url' => env('ENHANCE_API_URL'), - 'token' => env('ENHANCE_API_TOKEN'), + 'url' => env('ENHANCE_API_URL', ''), + 'token' => env('ENHANCE_API_TOKEN', ''), ], 'pterodactyl' => [ - 'url' => env('PTERODACTYL_PANEL_URL'), - 'api_key' => env('PTERODACTYL_API_KEY'), + 'url' => env('PTERODACTYL_PANEL_URL', ''), + 'api_key' => env('PTERODACTYL_API_KEY', ''), ], ]; diff --git a/website/config/session.php b/website/config/session.php index 121eedb..917f2f3 100644 --- a/website/config/session.php +++ b/website/config/session.php @@ -156,7 +156,7 @@ return [ | */ - 'domain' => env('SESSION_DOMAIN'), + 'domain' => env('SESSION_DOMAIN', '.ezscale.dev'), /* |-------------------------------------------------------------------------- diff --git a/website/database/factories/CouponRedemptionFactory.php b/website/database/factories/CouponRedemptionFactory.php new file mode 100644 index 0000000..694b638 --- /dev/null +++ b/website/database/factories/CouponRedemptionFactory.php @@ -0,0 +1,38 @@ + + */ +class CouponRedemptionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'coupon_id' => Coupon::factory(), + 'user_id' => User::factory(), + 'subscription_id' => null, + 'discount_amount' => fake()->randomFloat(2, 5, 100), + ]; + } + + public function withSubscription(): static + { + return $this->state(fn (array $attributes) => [ + 'subscription_id' => Subscription::factory(), + ]); + } +} diff --git a/website/database/migrations/2026_02_10_013924_add_notes_to_invoices_table.php b/website/database/migrations/2026_02_10_013924_add_notes_to_invoices_table.php new file mode 100644 index 0000000..7ef28b4 --- /dev/null +++ b/website/database/migrations/2026_02_10_013924_add_notes_to_invoices_table.php @@ -0,0 +1,24 @@ +text('notes')->nullable()->after('invoice_pdf'); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table): void { + $table->dropColumn('notes'); + }); + } +}; diff --git a/website/database/migrations/2026_02_10_082559_add_stripe_product_id_to_plans_table.php b/website/database/migrations/2026_02_10_082559_add_stripe_product_id_to_plans_table.php new file mode 100644 index 0000000..46a0f39 --- /dev/null +++ b/website/database/migrations/2026_02_10_082559_add_stripe_product_id_to_plans_table.php @@ -0,0 +1,28 @@ +string('stripe_product_id')->nullable()->after('stripe_price_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plans', function (Blueprint $table) { + $table->dropColumn('stripe_product_id'); + }); + } +}; diff --git a/website/database/migrations/2026_02_10_083826_add_virtfusion_user_id_to_users_table.php b/website/database/migrations/2026_02_10_083826_add_virtfusion_user_id_to_users_table.php new file mode 100644 index 0000000..a75765f --- /dev/null +++ b/website/database/migrations/2026_02_10_083826_add_virtfusion_user_id_to_users_table.php @@ -0,0 +1,30 @@ +unsignedInteger('virtfusion_user_id')->nullable()->after('stripe_id'); + $table->index('virtfusion_user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex(['virtfusion_user_id']); + $table->dropColumn('virtfusion_user_id'); + }); + } +}; diff --git a/website/database/migrations/2026_02_10_102812_add_billing_cycle_to_subscriptions_table.php b/website/database/migrations/2026_02_10_102812_add_billing_cycle_to_subscriptions_table.php new file mode 100644 index 0000000..8a35701 --- /dev/null +++ b/website/database/migrations/2026_02_10_102812_add_billing_cycle_to_subscriptions_table.php @@ -0,0 +1,28 @@ +string('billing_cycle')->default('monthly')->after('type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('billing_cycle'); + }); + } +}; diff --git a/website/database/migrations/2026_02_10_104949_add_provisioning_config_to_subscriptions_table.php b/website/database/migrations/2026_02_10_104949_add_provisioning_config_to_subscriptions_table.php new file mode 100644 index 0000000..0a73469 --- /dev/null +++ b/website/database/migrations/2026_02_10_104949_add_provisioning_config_to_subscriptions_table.php @@ -0,0 +1,24 @@ +json('provisioning_config')->nullable()->after('billing_cycle'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table): void { + $table->dropColumn('provisioning_config'); + }); + } +}; diff --git a/website/database/migrations/2026_02_10_110718_add_soft_deletes_to_services_table.php b/website/database/migrations/2026_02_10_110718_add_soft_deletes_to_services_table.php new file mode 100644 index 0000000..5ea82be --- /dev/null +++ b/website/database/migrations/2026_02_10_110718_add_soft_deletes_to_services_table.php @@ -0,0 +1,22 @@ +softDeletes(); + }); + } + + public function down(): void + { + Schema::table('services', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/website/database/seeders/DemoDataSeeder.php b/website/database/seeders/DemoDataSeeder.php index d0cbf2f..09c81ff 100644 --- a/website/database/seeders/DemoDataSeeder.php +++ b/website/database/seeders/DemoDataSeeder.php @@ -42,39 +42,39 @@ class DemoDataSeeder extends Seeder $adminUser = User::role('admin')->first(); $adminId = $adminUser?->id ?? 1; - // ─── 1. Create ~300 Customers ───────────────────────────────── + // ─── 1. Create 1000 Customers ───────────────────────────────── $this->command->info('Creating customers...'); $customers = $this->createCustomers(); - // ─── 2. Create ~500 Subscriptions ───────────────────────────── + // ─── 2. Create ~1500 Subscriptions ──────────────────────────── $this->command->info('Creating subscriptions...'); $subscriptionMap = $this->createSubscriptions($customers, $plans); - // ─── 3. Create ~400 Services ────────────────────────────────── + // ─── 3. Create ~1200 Services (70% VPS) ─────────────────────── $this->command->info('Creating services...'); $this->createServices($customers, $plans, $subscriptionMap); - // ─── 4. Create ~800 Invoices with Items ─────────────────────── + // ─── 4. Create ~2000 Invoices with Items ────────────────────── $this->command->info('Creating invoices...'); $invoiceIds = $this->createInvoices($customers, $plans, $subscriptionMap); - // ─── 5. Create ~600 Payment Transactions ────────────────────── + // ─── 5. Create ~1500 Payment Transactions ───────────────────── $this->command->info('Creating payment transactions...'); $this->createPaymentTransactions($customers, $invoiceIds); - // ─── 6. Create ~150 Orders ──────────────────────────────────── + // ─── 6. Create ~400 Orders ──────────────────────────────────── $this->command->info('Creating orders...'); $this->createOrders($customers, $plans); - // ─── 7. Create ~200 Support Tickets with Replies ────────────── + // ─── 7. Create ~500 Support Tickets with Replies ────────────── $this->command->info('Creating support tickets...'); $this->createSupportTickets($customers, $adminId); - // ─── 8. Create ~50 Coupons ──────────────────────────────────── + // ─── 8. Create ~100 Coupons ─────────────────────────────────── $this->command->info('Creating coupons...'); $this->createCoupons(); - // ─── 9. Create ~100 Audit Logs ──────────────────────────────── + // ─── 9. Create ~300 Audit Logs ──────────────────────────────── $this->command->info('Creating audit logs...'); $this->createAuditLogs($customers, $adminId); @@ -82,7 +82,7 @@ class DemoDataSeeder extends Seeder } /** - * Create ~300 customers with profiles. + * Create 1000 customers with profiles. * * @return \Illuminate\Support\Collection */ @@ -90,16 +90,16 @@ class DemoDataSeeder extends Seeder { $customers = collect(); $statuses = array_merge( - array_fill(0, 270, 'active'), - array_fill(0, 15, 'suspended'), - array_fill(0, 10, 'banned'), - array_fill(0, 5, 'pending'), + array_fill(0, 900, 'active'), + array_fill(0, 50, 'suspended'), + array_fill(0, 30, 'banned'), + array_fill(0, 20, 'pending'), ); shuffle($statuses); $faker = fake(); - $batchSize = 50; - $totalCustomers = 300; + $batchSize = 100; + $totalCustomers = 1000; for ($i = 0; $i < $totalCustomers; $i += $batchSize) { $batchCount = min($batchSize, $totalCustomers - $i); @@ -138,7 +138,7 @@ class DemoDataSeeder extends Seeder } /** - * Create ~500 subscriptions linked to real plans. + * Create ~1500 subscriptions linked to real plans (70% VPS). * * Returns a map of subscription_id => [user_id, plan_id] for use by other seeders. * @@ -150,12 +150,19 @@ class DemoDataSeeder extends Seeder { $subscriptionMap = []; $statuses = ['active', 'active', 'active', 'active', 'active', 'active', 'active', 'canceled', 'past_due', 'trialing']; - $total = 500; + $total = 1500; + + // Separate plans by type for weighted distribution + $vpsPlans = $plans->where('service_type', 'vps'); + $otherPlans = $plans->whereNotIn('service_type', ['vps']); $rows = []; for ($i = 0; $i < $total; $i++) { $customer = $customers->random(); - $plan = $plans->random(); + // 70% VPS, 30% other services + $plan = (rand(1, 100) <= 70 && $vpsPlans->isNotEmpty()) + ? $vpsPlans->random() + : ($otherPlans->isNotEmpty() ? $otherPlans->random() : $plans->random()); $status = $statuses[array_rand($statuses)]; $createdAt = $customer->created_at->copy()->addDays(rand(0, 60)); @@ -243,21 +250,28 @@ class DemoDataSeeder extends Seeder ]; $serviceStatuses = array_merge( - array_fill(0, 300, 'active'), - array_fill(0, 40, 'suspended'), - array_fill(0, 30, 'pending'), - array_fill(0, 30, 'terminated'), + array_fill(0, 1000, 'active'), + array_fill(0, 100, 'suspended'), + array_fill(0, 50, 'pending'), + array_fill(0, 50, 'terminated'), ); shuffle($serviceStatuses); $subIds = array_keys($subscriptionMap); $rows = []; $faker = fake(); - $total = 400; + $total = 1200; + + // Separate plans by type for weighted distribution + $vpsPlans = $plans->where('service_type', 'vps'); + $otherPlans = $plans->whereNotIn('service_type', ['vps']); for ($i = 0; $i < $total; $i++) { $customer = $customers->random(); - $plan = $plans->random(); + // 70% VPS, 30% other services + $plan = (rand(1, 100) <= 70 && $vpsPlans->isNotEmpty()) + ? $vpsPlans->random() + : ($otherPlans->isNotEmpty() ? $otherPlans->random() : $plans->random()); $status = $serviceStatuses[$i] ?? 'active'; $serviceType = $plan->service_type; $platform = $platformMap[$serviceType] ?? 'virtfusion'; @@ -310,7 +324,7 @@ class DemoDataSeeder extends Seeder } /** - * Create ~800 invoices with line items. + * Create ~2000 invoices with line items. * * @param \Illuminate\Support\Collection $customers * @param \Illuminate\Support\Collection $plans @@ -320,10 +334,10 @@ class DemoDataSeeder extends Seeder private function createInvoices(\Illuminate\Support\Collection $customers, \Illuminate\Support\Collection $plans, array $subscriptionMap): array { $invoiceStatuses = array_merge( - array_fill(0, 500, 'paid'), - array_fill(0, 150, 'pending'), - array_fill(0, 100, 'overdue'), - array_fill(0, 50, 'void'), + array_fill(0, 1400, 'paid'), + array_fill(0, 300, 'pending'), + array_fill(0, 200, 'overdue'), + array_fill(0, 100, 'void'), ); shuffle($invoiceStatuses); @@ -331,7 +345,7 @@ class DemoDataSeeder extends Seeder $invoiceRows = []; $invoiceTracker = []; $faker = fake(); - $total = 800; + $total = 2000; for ($i = 0; $i < $total; $i++) { $customer = $customers->random(); @@ -449,17 +463,17 @@ class DemoDataSeeder extends Seeder { $rows = []; $faker = fake(); - $total = 600; + $total = 1500; // Use paid/pending invoices for payment transactions $paidInvoices = array_filter($invoiceData, fn ($inv) => $inv['status'] === 'paid'); $paidInvoices = array_values($paidInvoices); $transactionStatuses = array_merge( - array_fill(0, 480, 'succeeded'), - array_fill(0, 60, 'failed'), - array_fill(0, 40, 'refunded'), - array_fill(0, 20, 'pending'), + array_fill(0, 1200, 'succeeded'), + array_fill(0, 150, 'failed'), + array_fill(0, 100, 'refunded'), + array_fill(0, 50, 'pending'), ); shuffle($transactionStatuses); @@ -522,16 +536,16 @@ class DemoDataSeeder extends Seeder private function createOrders(\Illuminate\Support\Collection $customers, \Illuminate\Support\Collection $plans): void { $orderStatuses = array_merge( - array_fill(0, 80, 'completed'), - array_fill(0, 30, 'pending'), - array_fill(0, 25, 'processing'), - array_fill(0, 15, 'cancelled'), + array_fill(0, 250, 'completed'), + array_fill(0, 70, 'pending'), + array_fill(0, 50, 'processing'), + array_fill(0, 30, 'cancelled'), ); shuffle($orderStatuses); $rows = []; $faker = fake(); - $total = 150; + $total = 400; for ($i = 0; $i < $total; $i++) { $customer = $customers->random(); @@ -610,15 +624,15 @@ class DemoDataSeeder extends Seeder ]; $ticketStatuses = array_merge( - array_fill(0, 60, 'open'), - array_fill(0, 50, 'in_progress'), - array_fill(0, 40, 'waiting'), - array_fill(0, 50, 'closed'), + array_fill(0, 150, 'open'), + array_fill(0, 125, 'in_progress'), + array_fill(0, 100, 'waiting'), + array_fill(0, 125, 'closed'), ); shuffle($ticketStatuses); $priorities = ['low', 'low', 'medium', 'medium', 'medium', 'high', 'high', 'urgent']; - $total = 200; + $total = 500; $ticketRows = []; $ticketMeta = []; @@ -746,7 +760,7 @@ class DemoDataSeeder extends Seeder 'PARTNER', 'AGENCY', 'RESELLER', 'BULK', 'ENTERPRISE', ]; - $total = 50; + $total = 100; for ($i = 0; $i < $total; $i++) { $type = $faker->randomElement(['percentage', 'percentage', 'fixed_amount']); @@ -820,7 +834,7 @@ class DemoDataSeeder extends Seeder $faker = fake(); $rows = []; - $total = 100; + $total = 300; $userAgents = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', diff --git a/website/database/seeders/PlanSeeder.php b/website/database/seeders/PlanSeeder.php index 11ec727..7df5edf 100644 --- a/website/database/seeders/PlanSeeder.php +++ b/website/database/seeders/PlanSeeder.php @@ -11,181 +11,193 @@ class PlanSeeder extends Seeder { public function run(): void { - Plan::query()->delete(); + // Archive old VPS plans instead of deleting (preserves foreign key relationships) + Plan::query() + ->where('service_type', 'vps') + ->whereNotIn('slug', [ + 'vps-nano', 'vps-micro', 'vps-mini', 'vps-standard', + 'vps-plus', 'vps-pro', 'vps-storage-500', 'vps-storage-1tb', + ]) + ->update(['status' => 'archived']); $plans = [ - // ─── VPS Plans ─────────────────────────────────────────────── + // ─── VPS Plans (2026 NVMe Lineup) ──────────────────────────── [ - 'name' => 'Micro VPS', - 'slug' => 'micro-vps', - 'description' => 'Lightweight VPS for simple tasks, testing, and small projects.', + 'name' => 'Nano', + 'slug' => 'vps-nano', + 'description' => 'Entry-level NVMe VPS for simple tasks, testing, and lightweight applications.', 'service_type' => 'vps', - 'price' => 4.20, + 'price' => 3.50, 'billing_cycle' => 'monthly', 'features' => [ 'cpu' => '1 vCPU', 'ram' => '1 GB', - 'storage' => '25 GB SSD', + 'storage' => '15 GB NVMe', 'bandwidth' => '2 TB', 'ipv4' => '1 IPv4', 'ipv6' => '1 /64 IPv6', 'control_panel' => 'VirtFusion', 'os' => 'Linux & Windows (BYOL)', + 'virtfusion_package_id' => 1, + 'virtfusion_user_id' => 1, + 'virtfusion_hypervisor_id' => 1, ], 'sort_order' => 1, ], [ - 'name' => 'Mini VPS', - 'slug' => 'mini-vps', - 'description' => 'Compact VPS with extra memory for light workloads.', + 'name' => 'Micro', + 'slug' => 'vps-micro', + 'description' => 'NVMe VPS with 2 GB RAM - double the RAM of competitors at the same price point.', 'service_type' => 'vps', - 'price' => 6.00, + 'price' => 5.95, 'billing_cycle' => 'monthly', 'features' => [ 'cpu' => '1 vCPU', 'ram' => '2 GB', - 'storage' => '50 GB SSD', - 'bandwidth' => '4 TB', + 'storage' => '30 GB NVMe', + 'bandwidth' => '3 TB', 'ipv4' => '1 IPv4', 'ipv6' => '1 /64 IPv6', 'control_panel' => 'VirtFusion', 'os' => 'Linux & Windows (BYOL)', + 'virtfusion_package_id' => 1, + 'virtfusion_user_id' => 1, + 'virtfusion_hypervisor_id' => 1, ], 'sort_order' => 2, ], [ - 'name' => 'Dev Starter', - 'slug' => 'dev-starter', - 'description' => 'Dual-core VPS ideal for development environments and staging.', + 'name' => 'Mini', + 'slug' => 'vps-mini', + 'description' => 'Hero plan with 4 GB RAM and NVMe storage - beats Hetzner CX22 with faster disks.', 'service_type' => 'vps', - 'price' => 8.00, + 'price' => 8.95, 'billing_cycle' => 'monthly', 'features' => [ 'cpu' => '2 vCPU', - 'ram' => '2 GB', - 'storage' => '60 GB SSD', + 'ram' => '4 GB', + 'storage' => '50 GB NVMe', 'bandwidth' => '4 TB', 'ipv4' => '1 IPv4', 'ipv6' => '1 /64 IPv6', 'control_panel' => 'VirtFusion', 'os' => 'Linux & Windows (BYOL)', + 'virtfusion_package_id' => 1, + 'virtfusion_user_id' => 1, + 'virtfusion_hypervisor_id' => 1, ], 'sort_order' => 3, ], [ - 'name' => 'Basic VPS', - 'slug' => 'basic-vps', - 'description' => 'Balanced VPS for web apps, databases, and general-purpose workloads.', + 'name' => 'Standard', + 'slug' => 'vps-standard', + 'description' => 'Premium 8 GB RAM VPS with NVMe - double the RAM of competitors at half the price.', 'service_type' => 'vps', - 'price' => 12.00, + 'price' => 14.95, 'billing_cycle' => 'monthly', 'features' => [ 'cpu' => '2 vCPU', - 'ram' => '4 GB', - 'storage' => '80 GB SSD', + 'ram' => '8 GB', + 'storage' => '80 GB NVMe', 'bandwidth' => '6 TB', 'ipv4' => '1 IPv4', 'ipv6' => '1 /64 IPv6', 'control_panel' => 'VirtFusion', 'os' => 'Linux & Windows (BYOL)', + 'virtfusion_package_id' => 1, + 'virtfusion_user_id' => 1, + 'virtfusion_hypervisor_id' => 1, ], 'sort_order' => 4, ], [ - 'name' => 'Storage Box', - 'slug' => 'storage-box', - 'description' => 'High-storage VPS for backups, media, and file-heavy applications.', + 'name' => 'Plus', + 'slug' => 'vps-plus', + 'description' => 'High-RAM VPS with 12 GB memory and quad-core CPU for demanding applications.', 'service_type' => 'vps', - 'price' => 15.00, + 'price' => 22.95, + 'billing_cycle' => 'monthly', + 'features' => [ + 'cpu' => '4 vCPU', + 'ram' => '12 GB', + 'storage' => '120 GB NVMe', + 'bandwidth' => '8 TB', + 'ipv4' => '1 IPv4', + 'ipv6' => '1 /64 IPv6', + 'control_panel' => 'VirtFusion', + 'os' => 'Linux & Windows (BYOL)', + 'virtfusion_package_id' => 1, + 'virtfusion_user_id' => 1, + 'virtfusion_hypervisor_id' => 1, + ], + 'sort_order' => 5, + ], + [ + 'name' => 'Pro', + 'slug' => 'vps-pro', + 'description' => 'Ultimate RAM VPS with 16 GB memory and NVMe for production workloads.', + 'service_type' => 'vps', + 'price' => 29.95, + 'billing_cycle' => 'monthly', + 'features' => [ + 'cpu' => '4 vCPU', + 'ram' => '16 GB', + 'storage' => '160 GB NVMe', + 'bandwidth' => '10 TB', + 'ipv4' => '1 IPv4', + 'ipv6' => '1 /64 IPv6', + 'control_panel' => 'VirtFusion', + 'os' => 'Linux & Windows (BYOL)', + 'virtfusion_package_id' => 1, + 'virtfusion_user_id' => 1, + 'virtfusion_hypervisor_id' => 1, + ], + 'sort_order' => 6, + ], + [ + 'name' => 'Storage-500', + 'slug' => 'vps-storage-500', + 'description' => 'Storage-focused VPS with 500 GB SATA SSD for backups, media, and file storage.', + 'service_type' => 'vps', + 'price' => 24.95, 'billing_cycle' => 'monthly', 'features' => [ 'cpu' => '2 vCPU', - 'ram' => '2 GB', + 'ram' => '4 GB', 'storage' => '500 GB SSD', 'bandwidth' => '8 TB', 'ipv4' => '1 IPv4', 'ipv6' => '1 /64 IPv6', 'control_panel' => 'VirtFusion', 'os' => 'Linux & Windows (BYOL)', - ], - 'sort_order' => 5, - ], - [ - 'name' => 'Standard VPS', - 'slug' => 'standard-vps', - 'description' => 'Quad-core VPS with 8 GB RAM for production applications.', - 'service_type' => 'vps', - 'price' => 15.60, - 'billing_cycle' => 'monthly', - 'features' => [ - 'cpu' => '4 vCPU', - 'ram' => '8 GB', - 'storage' => '160 GB SSD', - 'bandwidth' => '8 TB', - 'ipv4' => '1 IPv4', - 'ipv6' => '1 /64 IPv6', - 'control_panel' => 'VirtFusion', - 'os' => 'Linux & Windows (BYOL)', - ], - 'sort_order' => 6, - ], - [ - 'name' => 'RAM Optimized', - 'slug' => 'ram-optimized', - 'description' => 'Memory-optimized VPS for databases, caching, and in-memory workloads.', - 'service_type' => 'vps', - 'price' => 19.00, - 'billing_cycle' => 'monthly', - 'features' => [ - 'cpu' => '4 vCPU', - 'ram' => '16 GB', - 'storage' => '240 GB SSD', - 'bandwidth' => '10 TB', - 'ipv4' => '1 IPv4', - 'ipv6' => '1 /64 IPv6', - 'control_panel' => 'VirtFusion', - 'os' => 'Linux & Windows (BYOL)', + 'virtfusion_package_id' => 1, + 'virtfusion_user_id' => 1, + 'virtfusion_hypervisor_id' => 1, ], 'sort_order' => 7, ], [ - 'name' => 'Advanced VPS', - 'slug' => 'advanced-vps', - 'description' => 'Six-core VPS with 16 GB RAM for demanding applications and multi-service setups.', + 'name' => 'Storage-1TB', + 'slug' => 'vps-storage-1tb', + 'description' => 'Mass storage VPS with 1 TB SATA SSD for large-scale file storage and archives.', 'service_type' => 'vps', - 'price' => 21.60, + 'price' => 44.95, 'billing_cycle' => 'monthly', 'features' => [ - 'cpu' => '6 vCPU', - 'ram' => '16 GB', - 'storage' => '320 GB SSD', - 'bandwidth' => '10 TB', + 'cpu' => '4 vCPU', + 'ram' => '8 GB', + 'storage' => '1 TB SSD', + 'bandwidth' => '12 TB', 'ipv4' => '1 IPv4', 'ipv6' => '1 /64 IPv6', 'control_panel' => 'VirtFusion', 'os' => 'Linux & Windows (BYOL)', + 'virtfusion_package_id' => 1, + 'virtfusion_user_id' => 1, + 'virtfusion_hypervisor_id' => 1, ], 'sort_order' => 8, ], - [ - 'name' => 'Pro VPS', - 'slug' => 'pro-vps', - 'description' => 'Eight-core powerhouse with 32 GB RAM for enterprise workloads and heavy traffic.', - 'service_type' => 'vps', - 'price' => 30.00, - 'billing_cycle' => 'monthly', - 'features' => [ - 'cpu' => '8 vCPU', - 'ram' => '32 GB', - 'storage' => '640 GB SSD', - 'bandwidth' => '16 TB', - 'ipv4' => '1 IPv4', - 'ipv6' => '1 /64 IPv6', - 'control_panel' => 'VirtFusion', - 'os' => 'Linux & Windows (BYOL)', - ], - 'sort_order' => 9, - ], // ─── Dedicated Server Plans ────────────────────────────────── [ diff --git a/website/database/seeders/UpdateVirtFusionPackageIds.php b/website/database/seeders/UpdateVirtFusionPackageIds.php new file mode 100644 index 0000000..99af3e2 --- /dev/null +++ b/website/database/seeders/UpdateVirtFusionPackageIds.php @@ -0,0 +1,39 @@ + 43, // Base Package (will use custom specs) + 'Micro' => 19, // Micro + 'Mini' => 20, // Mini + 'Standard' => 22, // Standard + 'Plus' => 23, // Advanced + 'Pro' => 24, // Pro + 'Storage-500' => 41, // Storage Box + 'Storage-1TB' => 41, // Storage Box + ]; + + foreach ($mapping as $planName => $packageId) { + $plan = Plan::where('name', $planName)->where('service_type', 'vps')->first(); + + if ($plan) { + $features = $plan->features ?? []; + $features['virtfusion_package_id'] = $packageId; + + $plan->update(['features' => $features]); + + $this->command->info("Updated {$planName} with VirtFusion package ID {$packageId}"); + } + } + } +} diff --git a/website/package-lock.json b/website/package-lock.json index cc30450..4396bee 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -8,6 +8,8 @@ "@iconify/vue": "^5.0.0", "@inertiajs/vue3": "^2.3.13", "@mdi/font": "^7.4.47", + "@noble/ed25519": "^3.0.0", + "@stripe/stripe-js": "^8.7.0", "@vitejs/plugin-vue": "^6.0.4", "pinia": "^3.0.4", "sass": "^1.97.3", @@ -559,6 +561,15 @@ "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==", "license": "Apache-2.0" }, + "node_modules/@noble/ed25519": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz", + "integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -1186,6 +1197,15 @@ "win32" ] }, + "node_modules/@stripe/stripe-js": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz", + "integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2093,226 +2113,6 @@ "vite": "^7.0.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lodash-es": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", diff --git a/website/package.json b/website/package.json index f4a37fb..08465e6 100644 --- a/website/package.json +++ b/website/package.json @@ -20,6 +20,8 @@ "@iconify/vue": "^5.0.0", "@inertiajs/vue3": "^2.3.13", "@mdi/font": "^7.4.47", + "@noble/ed25519": "^3.0.0", + "@stripe/stripe-js": "^8.7.0", "@vitejs/plugin-vue": "^6.0.4", "pinia": "^3.0.4", "sass": "^1.97.3", diff --git a/website/resources/ts/@core/components/AppStepper.vue b/website/resources/ts/@core/components/AppStepper.vue new file mode 100644 index 0000000..2d4a3f4 --- /dev/null +++ b/website/resources/ts/@core/components/AppStepper.vue @@ -0,0 +1,373 @@ + + + + + diff --git a/website/resources/ts/Layouts/AccountLayout.vue b/website/resources/ts/Layouts/AccountLayout.vue index 4baa464..9e0c552 100644 --- a/website/resources/ts/Layouts/AccountLayout.vue +++ b/website/resources/ts/Layouts/AccountLayout.vue @@ -51,29 +51,36 @@ const adminUrl = computed(() => `https://${props.value.domains?.admin}`) - - + - - - + +
+ You are viewing the account as {{ user?.name }}. All actions will be attributed to this user. +
+ diff --git a/website/resources/ts/Layouts/AdminLayout.vue b/website/resources/ts/Layouts/AdminLayout.vue index 97980d5..48bb01c 100644 --- a/website/resources/ts/Layouts/AdminLayout.vue +++ b/website/resources/ts/Layouts/AdminLayout.vue @@ -53,7 +53,7 @@ const accountUrl = computed(() => `https://${props.value.domains?.account}`) (props.filters.date_from) const dateTo = ref(props.filters.date_to) const expandedRows = ref>(new Set()) +// Detail dialog state +const detailDialog = ref(false) +const selectedLog = ref(null) + +// Export menu state +const exportMenu = ref(false) + let searchTimeout: ReturnType | null = null watch(search, (value: string) => { @@ -95,6 +102,16 @@ function isExpanded(id: number): boolean { return expandedRows.value.has(id) } +function openDetailDialog(log: AuditLog): void { + selectedLog.value = log + detailDialog.value = true +} + +function closeDetailDialog(): void { + detailDialog.value = false + selectedLog.value = null +} + function resolveActionColor(action: string): string { if (action.startsWith('create') || action === 'register') { return 'success' @@ -162,17 +179,72 @@ function formatDateTime(dateStr: string): string { }) } -function formatJson(changes: Record | null): string { - if (!changes) { - return '{}' +function formatFieldName(field: string): string { + return field + .replace(/_/g, ' ') + .replace(/\b\w/g, (c: string) => c.toUpperCase()) +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return '(empty)' } - return JSON.stringify(changes, null, 2) + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No' + } + if (typeof value === 'object') { + return JSON.stringify(value, null, 2) + } + return String(value) } function hasChanges(log: AuditLog): boolean { return log.changes !== null && Object.keys(log.changes).length > 0 } +interface ChangesDiff { + type: 'update' | 'create' | 'delete' | 'generic' + before: Record | null + after: Record | null + fields: string[] +} + +function parseChanges(changes: Record | null): ChangesDiff { + if (!changes || Object.keys(changes).length === 0) { + return { type: 'generic', before: null, after: null, fields: [] } + } + + const hasBefore = 'before' in changes && changes.before !== null && typeof changes.before === 'object' + const hasAfter = 'after' in changes && changes.after !== null && typeof changes.after === 'object' + + if (hasBefore && hasAfter) { + const before = changes.before as Record + const after = changes.after as Record + const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])] + return { type: 'update', before, after, fields } + } + + if (hasAfter && !hasBefore) { + const after = changes.after as Record + return { type: 'create', before: null, after, fields: Object.keys(after) } + } + + if (hasBefore && !hasAfter) { + const before = changes.before as Record + return { type: 'delete', before, after: null, fields: Object.keys(before) } + } + + // No before/after structure -- treat top-level keys as generic data + return { type: 'generic', before: null, after: null, fields: Object.keys(changes) } +} + +function isFieldChanged(before: Record | null, after: Record | null, field: string): boolean { + if (!before || !after) { + return false + } + return JSON.stringify(before[field]) !== JSON.stringify(after[field]) +} + function clearFilters(): void { search.value = '' actionFilter.value = '' @@ -184,6 +256,29 @@ function clearFilters(): void { const hasActiveFilters = computed(() => { return search.value !== '' || actionFilter.value !== '' || dateFrom.value !== '' || dateTo.value !== '' }) + +function buildExportUrl(format: 'csv' | 'json'): string { + const params = new URLSearchParams() + params.set('format', format) + if (search.value) { + params.set('search', search.value) + } + if (actionFilter.value) { + params.set('action', actionFilter.value) + } + if (dateFrom.value) { + params.set('date_from', dateFrom.value) + } + if (dateTo.value) { + params.set('date_to', dateTo.value) + } + return `/audit-logs/export?${params.toString()}` +} + +function exportData(format: 'csv' | 'json'): void { + exportMenu.value = false + window.location.href = buildExportUrl(format) +} diff --git a/website/resources/ts/Pages/Admin/Coupons/Edit.vue b/website/resources/ts/Pages/Admin/Coupons/Edit.vue index aa8cef3..0cc246b 100644 --- a/website/resources/ts/Pages/Admin/Coupons/Edit.vue +++ b/website/resources/ts/Pages/Admin/Coupons/Edit.vue @@ -94,12 +94,6 @@ function formatDate(dateString: string): string { const formattedCreatedAt = computed(() => formatDate(props.coupon.created_at)) -const redemptionHeaders = computed(() => [ - { title: 'Customer', key: 'user', sortable: false }, - { title: 'Discount', key: 'discount_amount', sortable: true, align: 'end' as const }, - { title: 'Redeemed', key: 'created_at', sortable: true }, -]) - function submit(): void { form.put(`/coupons/${props.coupon.id}`, { preserveScroll: true, @@ -182,44 +176,33 @@ function submit(): void { - - - - - - - - - - - - - - - + + + + View All + + + diff --git a/website/resources/ts/Pages/Admin/Coupons/Index.vue b/website/resources/ts/Pages/Admin/Coupons/Index.vue index 36861ea..919a0d8 100644 --- a/website/resources/ts/Pages/Admin/Coupons/Index.vue +++ b/website/resources/ts/Pages/Admin/Coupons/Index.vue @@ -2,14 +2,10 @@ import { Link, router } from '@inertiajs/vue3' import { computed } from 'vue' import AdminLayout from '@/Layouts/AdminLayout.vue' -import type { Coupon, PaginatedResponse, Plan, StatusColor } from '@/types' - -interface CouponWithCount extends Coupon { - redemptions_count: number -} +import type { CouponWithStats, PaginatedResponse, StatusColor } from '@/types' interface Props { - coupons: PaginatedResponse + coupons: PaginatedResponse } defineOptions({ layout: AdminLayout }) @@ -22,6 +18,8 @@ const tableHeaders = computed(() => [ { title: 'Value', key: 'value', sortable: true, align: 'end' as const }, { title: 'Plans', key: 'applies_to', sortable: false }, { title: 'Usage', key: 'usage', sortable: false, align: 'center' as const }, + { title: 'Total Discount', key: 'total_discount', sortable: false, align: 'end' as const }, + { title: 'Last Redeemed', key: 'last_redeemed', sortable: false }, { title: 'Expires', key: 'expires_at', sortable: true }, { title: 'Status', key: 'status', sortable: false, align: 'center' as const }, { title: 'Actions', key: 'actions', sortable: false, align: 'center' as const }, @@ -31,7 +29,7 @@ function resolveTypeColor(type: string): StatusColor { return type === 'percentage' ? 'info' : 'warning' } -function formatValue(coupon: CouponWithCount): string { +function formatValue(coupon: CouponWithStats): string { if (coupon.type === 'percentage') { return `${parseFloat(coupon.value)}%` } @@ -57,7 +55,7 @@ function formatPlansApplicable(appliesTo: number[] | null): string { return `${appliesTo.length} plan${appliesTo.length > 1 ? 's' : ''}` } -function resolveCouponStatus(coupon: CouponWithCount): { label: string; color: StatusColor } { +function resolveCouponStatus(coupon: CouponWithStats): { label: string; color: StatusColor } { if (!coupon.active) { return { label: 'Inactive', color: 'error' } } @@ -70,7 +68,7 @@ function resolveCouponStatus(coupon: CouponWithCount): { label: string; color: S return { label: 'Active', color: 'success' } } -function deactivateCoupon(coupon: CouponWithCount): void { +function deactivateCoupon(coupon: CouponWithStats): void { if (confirm(`Are you sure you want to deactivate coupon "${coupon.code}"?`)) { router.delete(`/coupons/${coupon.id}`, { preserveScroll: true, @@ -91,11 +89,18 @@ function deactivateCoupon(coupon: CouponWithCount): void { Manage discount coupons and promotions - - - Create Coupon - - +
+ + + View All Redemptions + + + + + Create Coupon + + +
@@ -142,14 +147,32 @@ function deactivateCoupon(coupon: CouponWithCount): void { + + + + + + + + + View Redemptions + + Edit diff --git a/website/resources/ts/Pages/Admin/Coupons/Redemptions.vue b/website/resources/ts/Pages/Admin/Coupons/Redemptions.vue new file mode 100644 index 0000000..5a3f9f3 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Coupons/Redemptions.vue @@ -0,0 +1,413 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Coupons/Show.vue b/website/resources/ts/Pages/Admin/Coupons/Show.vue new file mode 100644 index 0000000..ac5cf7d --- /dev/null +++ b/website/resources/ts/Pages/Admin/Coupons/Show.vue @@ -0,0 +1,387 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Customers/Show.vue b/website/resources/ts/Pages/Admin/Customers/Show.vue index 1e126b6..de1a5c7 100644 --- a/website/resources/ts/Pages/Admin/Customers/Show.vue +++ b/website/resources/ts/Pages/Admin/Customers/Show.vue @@ -1,10 +1,18 @@ + + diff --git a/website/resources/ts/Pages/Admin/Invoices/Edit.vue b/website/resources/ts/Pages/Admin/Invoices/Edit.vue new file mode 100644 index 0000000..1e84872 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Invoices/Edit.vue @@ -0,0 +1,324 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Invoices/Index.vue b/website/resources/ts/Pages/Admin/Invoices/Index.vue index 949ab63..06712b4 100644 --- a/website/resources/ts/Pages/Admin/Invoices/Index.vue +++ b/website/resources/ts/Pages/Admin/Invoices/Index.vue @@ -1,5 +1,5 @@