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 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-10 06:30:57 -05:00
parent bf4f5f97c0
commit 45d25d61ba
101 changed files with 13225 additions and 1888 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 |

View File

@@ -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

View File

@@ -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 <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.
---
# Inertia Vue Development
## When to Apply
Activate this skill when:
- Creating or modifying Vue page components for Inertia
- Working with forms in Vue (using `<Form>` or `useForm`)
- Implementing client-side navigation with `<Link>` 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.
<code-snippet name="Basic Vue Page Component" lang="vue">
<script setup>
defineProps({
users: Array
})
</script>
<template>
<div>
<h1>Users</h1>
<ul>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
</code-snippet>
## Client-Side Navigation
### Basic Link Component
Use `<Link>` for client-side navigation instead of traditional `<a>` tags:
<code-snippet name="Inertia Vue Navigation" lang="vue">
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<div>
<Link href="/">Home</Link>
<Link href="/users">Users</Link>
<Link :href="`/users/${user.id}`">View User</Link>
</div>
</template>
</code-snippet>
### Link with Method
<code-snippet name="Link with POST Method" lang="vue">
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<Link href="/logout" method="post" as="button">
Logout
</Link>
</template>
</code-snippet>
### Prefetching
Prefetch pages to improve perceived performance:
<code-snippet name="Prefetch on Hover" lang="vue">
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<Link href="/users" prefetch>
Users
</Link>
</template>
</code-snippet>
### Programmatic Navigation
<code-snippet name="Router Visit" lang="vue">
<script setup>
import { router } from '@inertiajs/vue3'
function handleClick() {
router.visit('/users')
}
// Or with options
function createUser() {
router.visit('/users', {
method: 'post',
data: { name: 'John' },
onSuccess: () => console.log('Done'),
})
}
</script>
<template>
<Link href="/users">Users</Link>
<Link href="/logout" method="post" as="button">Logout</Link>
</template>
</code-snippet>
## Form Handling
### Form Component (Recommended)
The recommended way to build forms is with the `<Form>` component:
<code-snippet name="Form Component Example" lang="vue">
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form action="/users" method="post" #default="{ errors, processing, wasSuccessful }">
<input type="text" name="name" />
<div v-if="errors.name">{{ errors.name }}</div>
<input type="email" name="email" />
<div v-if="errors.email">{{ errors.email }}</div>
<button type="submit" :disabled="processing">
{{ processing ? 'Creating...' : 'Create User' }}
</button>
<div v-if="wasSuccessful">User created!</div>
</Form>
</template>
</code-snippet>
### Form Component With All Props
<code-snippet name="Form Component Full Example" lang="vue">
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form
action="/users"
method="post"
#default="{
errors,
hasErrors,
processing,
progress,
wasSuccessful,
recentlySuccessful,
setError,
clearErrors,
resetAndClearErrors,
defaults,
isDirty,
reset,
submit
}"
>
<input type="text" name="name" :value="defaults.name" />
<div v-if="errors.name">{{ errors.name }}</div>
<button type="submit" :disabled="processing">
{{ processing ? 'Saving...' : 'Save' }}
</button>
<progress v-if="progress" :value="progress.percentage" max="100">
{{ progress.percentage }}%
</progress>
<div v-if="wasSuccessful">Saved!</div>
</Form>
</template>
</code-snippet>
### Form Component Reset Props
The `<Form>` 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.
<code-snippet name="Form with Reset Props" lang="vue">
<script setup>
import { Form } from '@inertiajs/vue3'
</script>
<template>
<Form
action="/users"
method="post"
reset-on-success
set-defaults-on-success
#default="{ errors, processing, wasSuccessful }"
>
<input type="text" name="name" />
<div v-if="errors.name">{{ errors.name }}</div>
<button type="submit" :disabled="processing">
Submit
</button>
</Form>
</template>
</code-snippet>
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:
<code-snippet name="useForm Composable Example" lang="vue">
<script setup>
import { useForm } from '@inertiajs/vue3'
const form = useForm({
name: '',
email: '',
password: '',
})
function submit() {
form.post('/users', {
onSuccess: () => form.reset('password'),
})
}
</script>
<template>
<form @submit.prevent="submit">
<input type="text" v-model="form.name" />
<div v-if="form.errors.name">{{ form.errors.name }}</div>
<input type="email" v-model="form.email" />
<div v-if="form.errors.email">{{ form.errors.email }}</div>
<input type="password" v-model="form.password" />
<div v-if="form.errors.password">{{ form.errors.password }}</div>
<button type="submit" :disabled="form.processing">
Create User
</button>
</form>
</template>
</code-snippet>
## Inertia v2 Features
### Deferred Props
Use deferred props to load data after initial page render:
<code-snippet name="Deferred Props with Empty State" lang="vue">
<script setup>
defineProps({
users: Array
})
</script>
<template>
<div>
<h1>Users</h1>
<div v-if="!users" class="animate-pulse">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
</code-snippet>
### Polling
Automatically refresh data at intervals:
<code-snippet name="Polling Example" lang="vue">
<script setup>
import { router } from '@inertiajs/vue3'
import { onMounted, onUnmounted } from 'vue'
defineProps({
stats: Object
})
let interval
onMounted(() => {
interval = setInterval(() => {
router.reload({ only: ['stats'] })
}, 5000) // Poll every 5 seconds
})
onUnmounted(() => {
clearInterval(interval)
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<div>Active Users: {{ stats.activeUsers }}</div>
</div>
</template>
</code-snippet>
### WhenVisible (Infinite Scroll)
Load more data when user scrolls to a specific element:
<code-snippet name="Infinite Scroll with WhenVisible" lang="vue">
<script setup>
import { WhenVisible } from '@inertiajs/vue3'
defineProps({
users: Object
})
</script>
<template>
<div>
<div v-for="user in users.data" :key="user.id">
{{ user.name }}
</div>
<WhenVisible
v-if="users.next_page_url"
data="users"
:params="{ page: users.current_page + 1 }"
>
<template #fallback>
<div>Loading more...</div>
</template>
</WhenVisible>
</div>
</template>
</code-snippet>
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
## Common Pitfalls
- Using traditional `<a>` links instead of Inertia's `<Link>` 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 `<form>` without preventing default submission (use `<Form>` component or `@submit.prevent`)
- Forgetting to check if `<Form>` component is available in your Inertia version

View File

@@ -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:
<code-snippet name="CSS-First Config" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<code-snippet name="v4 Import Syntax" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### 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:
<code-snippet name="Gap Utilities" lang="html">
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
</code-snippet>
## 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:
<code-snippet name="Dark Mode" lang="html">
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
</code-snippet>
## Common Patterns
### Flexbox Layout
<code-snippet name="Flexbox Layout" lang="html">
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
</code-snippet>
### Grid Layout
<code-snippet name="Grid Layout" lang="html">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
</code-snippet>
## 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

View File

@@ -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 &lt;Link&gt;, &lt;Form&gt;, 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.
</laravel-boost-guidelines>

View File

@@ -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,

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Plan;
use Illuminate\Console\Command;
use Stripe\Price;
use Stripe\Product;
use Stripe\Stripe;
class SyncStripePrices extends Command
{
protected $signature = 'stripe:sync-prices {--force : Force recreate all prices}';
protected $description = 'Sync plans with Stripe prices';
public function handle(): int
{
Stripe::setApiKey(config('cashier.secret'));
$plans = Plan::all();
$this->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;
}
}

View File

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

View File

@@ -99,9 +99,16 @@ class BillingController extends Controller
}
}
// Create setup intent for adding new cards
if (! $user->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,

View File

@@ -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);
}

View File

@@ -21,7 +21,6 @@ class DashboardController extends Controller
->count();
$activeSubscriptions = $user->subscriptions()
->with('plan')
->whereIn('stripe_status', ['active', 'trialing'])
->latest()
->get();

View File

@@ -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();

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Http\Requests\Account\RebuildVpsRequest;
use App\Models\AuditLog;
use App\Models\Service;
use App\Services\Provisioning\VirtFusionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class VpsController extends Controller
{
public function __construct(
private readonly VirtFusionService $virtfusion,
) {}
public function boot(Request $request, Service $service): RedirectResponse
{
$this->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<string, mixed> $changes
*/
private function logAudit(Request $request, Service $service, string $action, bool $success, array $changes = []): void
{
AuditLog::create([
'user_id' => $request->user()->id,
'action' => $action,
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => array_merge($changes, [
'success' => $success,
'service_id' => $service->id,
'platform' => $service->platform,
]),
]);
}
}

View File

@@ -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<AuditLog> $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<AuditLog> $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<string, mixed>|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<AuditLog>
*/
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;
}
}

View File

@@ -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);
}
}

View File

@@ -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.");
}
}

View File

@@ -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

View File

@@ -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']);

View File

@@ -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.');
}
}

View File

@@ -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()}."];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Account;
use Illuminate\Foundation\Http\FormRequest;
class RebuildVpsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'template_id' => ['required', 'integer', 'min:1'],
];
}
/** @return array<string, string> */
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.',
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class StoreInvoiceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
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<string, string> */
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.',
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class UpdateInvoiceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
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<string, string> */
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.',
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateServiceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
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<string, string> */
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.',
];
}
}

View File

@@ -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<string, array<int, mixed>> */
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<string, string> */
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.',
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\ServiceProvisioned;
use App\Notifications\ServiceProvisionedNotification;
use Illuminate\Support\Facades\Log;
class HandleServiceProvisioned
{
public function handle(ServiceProvisioned $event): void
{
Log::info("Service provisioned for user #{$event->user->id}", [
'service_id' => $event->service->id,
'service_type' => $event->service->service_type,
'hostname' => $event->service->hostname,
]);
$event->user->notify(new ServiceProvisionedNotification($event->service));
}
}

View File

@@ -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));
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -25,6 +25,7 @@ class Invoice extends Model
'currency',
'status',
'invoice_pdf',
'notes',
'due_date',
'paid_at',
];

View File

@@ -21,6 +21,7 @@ class Plan extends Model
'currency',
'billing_cycle',
'stripe_price_id',
'stripe_product_id',
'paypal_plan_id',
'features',
'stock_quantity',

View File

@@ -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',

View File

@@ -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<int, string>
*/
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<string, string|null>
*/
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;
}
}
}

View File

@@ -29,6 +29,7 @@ class User extends Authenticatable implements MustVerifyEmail
'phone',
'company',
'admin_notes',
'virtfusion_user_id',
];
/** @var list<string> */

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AdminPasswordResetNotification extends Notification
{
use Queueable;
public function __construct(public string $newPassword) {}
/**
* @return array<int, string>
*/
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<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'action' => 'password_reset',
'message' => 'Your password has been reset by an administrator.',
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class InvoiceNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Invoice $invoice,
) {}
/** @return array<int, string> */
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!');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return $user && $user->hasRole('admin');
});
}
}

View File

@@ -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.

View File

@@ -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.');
}

View File

@@ -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 [

View File

@@ -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');

View File

@@ -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');

View File

@@ -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');

View File

@@ -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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>, templates: array<int, array<string, mixed>>}
*/
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<int, array<string, mixed>>
*/
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<string, mixed>|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<string, mixed>|null $response
*/

View File

@@ -8,6 +8,6 @@
"sail": false,
"skills": [
"pest-testing",
"tailwindcss-development"
"inertia-vue-development"
]
}

View File

@@ -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: [

View File

@@ -3,4 +3,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
];

View File

@@ -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",

81
website/composer.lock generated
View File

@@ -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",

254
website/config/horizon.php Normal file
View File

@@ -0,0 +1,254 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Name
|--------------------------------------------------------------------------
|
| This name appears in notifications and in the Horizon UI. Unique names
| can be useful while running multiple instances of Horizon within an
| application, allowing you to identify the Horizon you're viewing.
|
*/
'name' => 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',
],
];

View File

@@ -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', ''),
],
];

View File

@@ -156,7 +156,7 @@ return [
|
*/
'domain' => env('SESSION_DOMAIN'),
'domain' => env('SESSION_DOMAIN', '.ezscale.dev'),
/*
|--------------------------------------------------------------------------

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Coupon;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Cashier\Subscription;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\CouponRedemption>
*/
class CouponRedemptionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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(),
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table): void {
$table->text('notes')->nullable()->after('invoice_pdf');
});
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table): void {
$table->dropColumn('notes');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('plans', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->string('billing_cycle')->default('monthly')->after('type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropColumn('billing_cycle');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->json('provisioning_config')->nullable()->after('billing_cycle');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->dropColumn('provisioning_config');
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('services', function (Blueprint $table) {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('services', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View File

@@ -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<int, User>
*/
@@ -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<int, User> $customers
* @param \Illuminate\Support\Collection<int, Plan> $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',

View File

@@ -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 ──────────────────────────────────
[

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Plan;
use Illuminate\Database\Seeder;
class UpdateVirtFusionPackageIds extends Seeder
{
public function run(): void
{
// Map Laravel plans to VirtFusion package IDs
$mapping = [
'Nano' => 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}");
}
}
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,373 @@
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
interface Item {
title: string
icon?: string | object
size?: string
subtitle?: string
}
type Direction = 'vertical' | 'horizontal'
interface Props {
items: Item[]
currentStep?: number
direction?: Direction
iconSize?: string | number
isActiveStepValid?: boolean
align?: 'start' | 'center' | 'end' | 'default'
}
interface Emit {
(e: 'update:currentStep', value: number): void
}
const props = withDefaults(defineProps<Props>(), {
currentStep: 0,
direction: 'horizontal',
iconSize: 60,
isActiveStepValid: undefined,
align: 'default',
})
const emit = defineEmits<Emit>()
const currentStep = ref(props.currentStep || 0)
// check if step is completed or active and return class name accordingly
const activeOrCompletedStepsClasses = computed(() => (index: number) => (
index < currentStep.value
? 'stepper-steps-completed'
: index === currentStep.value ? 'stepper-steps-active' : ''
))
// check if step is horizontal and not last step
const isHorizontalAndNotLastStep = computed(() => (index: number) => (
props.direction === 'horizontal'
&& props.items.length - 1 !== index
))
// check if validation is enabled
const isValidationEnabled = computed(() => {
return props.isActiveStepValid !== undefined
})
watchEffect(() => {
// we need to check undefined because if we pass 0 as currentStep it will be falsy
if (
props.currentStep !== undefined
&& props.currentStep < props.items.length
&& props.currentStep >= 0
)
currentStep.value = props.currentStep
emit('update:currentStep', currentStep.value)
})
</script>
<template>
<VSlideGroup
v-model="currentStep"
class="app-stepper"
show-arrows
:direction="props.direction"
:class="`app-stepper-${props.align} ${props.items[0].icon ? 'app-stepper-icons' : ''}`"
>
<VSlideGroupItem
v-for="(item, index) in props.items"
:key="item.title"
:value="index"
>
<div
class="cursor-pointer app-stepper-step pa-1"
:class="[
(!props.isActiveStepValid && (isValidationEnabled)) && 'stepper-steps-invalid',
activeOrCompletedStepsClasses(index),
]"
@click="!isValidationEnabled && emit('update:currentStep', index)"
>
<!-- SECTION stepper step with icon -->
<template v-if="item.icon">
<div class="stepper-icon-step text-high-emphasis d-flex align-center ">
<!-- 👉 icon and title -->
<div
class="d-flex align-center gap-x-3 step-wrapper"
:class="[props.direction === 'horizontal' && 'flex-column']"
>
<div class="stepper-icon">
<template v-if="typeof item.icon === 'object'">
<Component :is="item.icon" />
</template>
<VIcon
v-else
:icon="item.icon"
:size="item.size || props.iconSize"
/>
</div>
<div>
<p class="stepper-title font-weight-medium mb-0">
{{ item.title }}
</p>
<p
v-if="item.subtitle"
class="stepper-subtitle mb-0"
>
{{ item.subtitle }}
</p>
</div>
</div>
<!-- 👉 append chevron -->
<VIcon
v-if="isHorizontalAndNotLastStep(index)"
class="flip-in-rtl stepper-chevron-indicator mx-6"
size="20"
icon="tabler-chevron-right"
/>
</div>
</template>
<!-- !SECTION -->
<!-- SECTION stepper step without icon -->
<template v-else>
<div class="d-flex align-center gap-x-3">
<div>
<!-- 👉 custom circle icon -->
<template v-if="index >= currentStep">
<VAvatar
v-if="(!isValidationEnabled || props.isActiveStepValid || index !== currentStep)"
size="38"
rounded
:variant="index === currentStep ? 'elevated' : 'tonal'"
:color="index === currentStep ? 'primary' : 'default'"
>
<h5
class="text-h5"
:style="index === currentStep ? { color: '#fff' } : ''"
>
{{ index + 1 }}
</h5>
</VAvatar>
<VAvatar
v-else
color="error"
size="38"
rounded
>
<VIcon
icon="tabler-alert-circle"
size="22"
/>
</VAvatar>
</template>
<!-- 👉 step completed icon -->
<VAvatar
v-else
class="stepper-icon"
variant="tonal"
color="primary"
size="38"
rounded
>
<h5
class="text-h5"
style="color: rgb(var(--v-theme-primary));"
>
{{ index + 1 }}
</h5>
</VAvatar>
</div>
<!-- 👉 title and subtitle -->
<div class="d-flex flex-column justify-center">
<div class="stepper-title font-weight-medium">
{{ item.title }}
</div>
<div
v-if="item.subtitle"
class="stepper-subtitle text-sm text-disabled"
>
{{ item.subtitle }}
</div>
</div>
<!-- 👉 stepper step icon -->
<div
v-if="isHorizontalAndNotLastStep(index)"
class="stepper-step-line stepper-chevron-indicator mx-6"
>
<VIcon
icon="tabler-chevron-right"
size="20"
/>
</div>
</div>
</template>
<!-- !SECTION -->
</div>
</VSlideGroupItem>
</VSlideGroup>
</template>
<style lang="scss">
@use "@core-scss/template/mixins" as templateMixins;
.app-stepper {
// 👉 stepper step with bg color
&.stepper-icon-step-bg {
.stepper-icon-step {
.step-wrapper {
flex-direction: row !important;
}
.stepper-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
background-color: rgba(var(--v-theme-on-surface), var(--v-selected-opacity));
block-size: 2.375rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
inline-size: 2.375rem;
}
}
.stepper-steps-active {
.stepper-icon-step {
.stepper-icon {
@include templateMixins.custom-elevation(var(--v-theme-primary), "sm");
background-color: rgb(var(--v-theme-primary));
color: rgba(var(--v-theme-on-primary));
}
}
}
.stepper-steps-completed {
.stepper-icon-step {
.stepper-icon {
background: rgba(var(--v-theme-primary), var(--v-activated-opacity));
color: rgba(var(--v-theme-primary));
}
}
}
}
&.app-stepper-icons:not(.stepper-icon-step-bg) {
/* stylelint-disable-next-line no-descending-specificity */
.stepper-icon {
line-height: 0;
}
.step-wrapper {
padding: 1.25rem;
gap: 0.5rem;
min-inline-size: 9.375rem;
}
.stepper-chevron-indicator {
margin-inline: 1rem !important;
}
.stepper-steps-completed,
.stepper-steps-active {
.stepper-icon-step,
.stepper-step-icon,
.stepper-title,
.stepper-subtitle {
color: rgb(var(--v-theme-primary)) !important;
}
}
}
// 👉 stepper step with icon and default
.v-slide-group__content {
row-gap: 1rem;
/* stylelint-disable-next-line no-descending-specificity */
.stepper-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.9375rem;
font-weight: 500 !important;
}
/* stylelint-disable-next-line no-descending-specificity */
.stepper-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
line-height: 1.25rem;
}
/* stylelint-disable-next-line no-descending-specificity */
.stepper-chevron-indicator {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
/* stylelint-disable-next-line no-descending-specificity */
.stepper-steps-completed {
/* stylelint-disable-next-line no-descending-specificity */
.stepper-title,
.stepper-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
}
.stepper-chevron-indicator {
color: rgb(var(--v-theme-primary));
}
}
/* stylelint-disable-next-line no-descending-specificity */
.stepper-steps-active {
.v-avatar.bg-primary {
@include templateMixins.custom-elevation(var(--v-theme-primary), "sm");
}
.v-avatar.bg-error {
@include templateMixins.custom-elevation(var(--v-theme-error), "sm");
}
}
.stepper-steps-invalid.stepper-steps-active {
.stepper-icon-step,
.step-number,
.stepper-title,
.stepper-subtitle {
color: rgb(var(--v-theme-error)) !important;
}
}
.app-stepper-step {
&:not(.stepper-steps-active,.stepper-steps-completed) .v-avatar--variant-tonal {
--v-activated-opacity: 0.06;
}
}
}
// 👉 stepper alignment
&.app-stepper-center {
.v-slide-group__content {
justify-content: center;
}
}
&.app-stepper-start {
.v-slide-group__content {
justify-content: start;
}
}
&.app-stepper-end {
.v-slide-group__content {
justify-content: end;
}
}
}
</style>

View File

@@ -51,29 +51,36 @@ const adminUrl = computed(() => `https://${props.value.domains?.admin}`)
</Link>
</template>
<!-- Impersonation Banner -->
<VBanner
<!-- Impersonation Alert (Sticky) -->
<VAlert
v-if="isImpersonating"
color="warning"
icon="tabler-user-shield"
class="mb-4"
type="warning"
variant="tonal"
prominent
class="mb-6"
style="position: sticky; top: 0; z-index: 1000;"
>
<template #text>
You are impersonating <strong>{{ user?.name }}</strong>. Actions will be attributed to this user.
</template>
<template #actions>
<VAlertTitle class="d-flex align-center justify-space-between flex-wrap gap-4">
<div class="d-flex align-center gap-2">
<VIcon icon="tabler-user-shield" />
<span class="font-weight-bold">Impersonation Active</span>
</div>
<Link
:href="adminUrl + '/impersonate/stop'"
method="post"
as="button"
class="text-decoration-none"
>
<VBtn color="warning" variant="tonal" size="small">
<VBtn color="warning" variant="flat" size="small">
<VIcon icon="tabler-logout" start />
Stop Impersonating
</VBtn>
</Link>
</template>
</VBanner>
</VAlertTitle>
<div class="mt-2">
You are viewing the account as <strong>{{ user?.name }}</strong>. All actions will be attributed to this user.
</div>
</VAlert>
<FlashMessages />
<slot />

View File

@@ -53,7 +53,7 @@ const accountUrl = computed(() => `https://${props.value.domains?.account}`)
<Link
v-if="user"
:href="accountUrl + '/logout'"
href="/logout"
method="post"
as="button"
class="text-decoration-none ms-2"

View File

@@ -47,6 +47,13 @@ const dateFrom = ref<string>(props.filters.date_from)
const dateTo = ref<string>(props.filters.date_to)
const expandedRows = ref<Set<number>>(new Set())
// Detail dialog state
const detailDialog = ref<boolean>(false)
const selectedLog = ref<AuditLog | null>(null)
// Export menu state
const exportMenu = ref<boolean>(false)
let searchTimeout: ReturnType<typeof setTimeout> | 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<string, unknown> | 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<string, unknown> | null
after: Record<string, unknown> | null
fields: string[]
}
function parseChanges(changes: Record<string, unknown> | 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<string, unknown>
const after = changes.after as Record<string, unknown>
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<string, unknown>
return { type: 'create', before: null, after, fields: Object.keys(after) }
}
if (hasBefore && !hasAfter) {
const before = changes.before as Record<string, unknown>
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<string, unknown> | null, after: Record<string, unknown> | 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<boolean>(() => {
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)
}
</script>
<template>
@@ -198,9 +293,41 @@ const hasActiveFilters = computed<boolean>(() => {
Track all system activity and administrative actions
</div>
</div>
<VChip color="primary" variant="tonal" size="small">
{{ auditLogs.total }} entries
</VChip>
<div class="d-flex align-center gap-3">
<!-- Export Dropdown -->
<VMenu v-model="exportMenu" location="bottom end">
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
variant="outlined"
color="primary"
size="small"
prepend-icon="tabler-download"
>
Export
<VIcon icon="tabler-chevron-down" end size="14" />
</VBtn>
</template>
<VList density="compact" min-width="160">
<VListItem @click="exportData('csv')">
<template #prepend>
<VIcon icon="tabler-file-spreadsheet" size="18" />
</template>
<VListItemTitle>Export as CSV</VListItemTitle>
</VListItem>
<VListItem @click="exportData('json')">
<template #prepend>
<VIcon icon="tabler-file-code" size="18" />
</template>
<VListItemTitle>Export as JSON</VListItemTitle>
</VListItem>
</VList>
</VMenu>
<VChip color="primary" variant="tonal" size="small">
{{ auditLogs.total }} entries
</VChip>
</div>
</div>
<!-- Filters -->
@@ -282,6 +409,7 @@ const hasActiveFilters = computed<boolean>(() => {
<th>Resource Type</th>
<th>Resource ID</th>
<th>IP Address</th>
<th style="width: 40px;" />
</tr>
</thead>
<tbody>
@@ -341,15 +469,138 @@ const hasActiveFilters = computed<boolean>(() => {
<td class="text-body-2 text-medium-emphasis">
{{ log.ip_address ?? '-' }}
</td>
<td>
<VBtn
v-if="hasChanges(log)"
variant="text"
size="x-small"
icon="tabler-eye"
color="primary"
@click.stop="openDetailDialog(log)"
/>
</td>
</tr>
<!-- Expanded row: changes JSON -->
<!-- Expanded row: inline diff preview -->
<tr v-if="isExpanded(log.id) && hasChanges(log)">
<td colspan="7" class="pa-0">
<td colspan="8" class="pa-0">
<div class="pa-4 bg-surface-variant">
<div class="text-caption font-weight-semibold mb-2">
Changes
<div class="d-flex align-center justify-space-between mb-3">
<div class="text-caption font-weight-semibold">
Changes
</div>
<VBtn
variant="text"
size="x-small"
color="primary"
@click.stop="openDetailDialog(log)"
>
<VIcon icon="tabler-arrows-maximize" start size="14" />
View Full Diff
</VBtn>
</div>
<pre class="text-caption" style="white-space: pre-wrap; word-break: break-all;">{{ formatJson(log.changes) }}</pre>
<!-- Inline diff for before/after -->
<template v-if="parseChanges(log.changes).type === 'update'">
<VTable density="compact" class="rounded border">
<thead>
<tr>
<th class="text-caption" style="width: 25%;">
Field
</th>
<th class="text-caption" style="width: 37.5%;">
Before
</th>
<th class="text-caption" style="width: 37.5%;">
After
</th>
</tr>
</thead>
<tbody>
<tr
v-for="field in parseChanges(log.changes).fields"
:key="field"
:class="{ 'bg-warning-lighten-5': isFieldChanged(parseChanges(log.changes).before, parseChanges(log.changes).after, field) }"
>
<td class="text-caption font-weight-medium">
{{ formatFieldName(field) }}
</td>
<td class="text-caption">
<span :class="{ 'text-error text-decoration-line-through': isFieldChanged(parseChanges(log.changes).before, parseChanges(log.changes).after, field) }">
{{ formatValue(parseChanges(log.changes).before?.[field]) }}
</span>
</td>
<td class="text-caption">
<span :class="{ 'text-success font-weight-medium': isFieldChanged(parseChanges(log.changes).before, parseChanges(log.changes).after, field) }">
{{ formatValue(parseChanges(log.changes).after?.[field]) }}
</span>
</td>
</tr>
</tbody>
</VTable>
</template>
<!-- Create: show new values only -->
<template v-else-if="parseChanges(log.changes).type === 'create'">
<VChip size="x-small" color="success" variant="tonal" class="mb-2">
New Values
</VChip>
<VTable density="compact" class="rounded border">
<thead>
<tr>
<th class="text-caption" style="width: 30%;">
Field
</th>
<th class="text-caption">
Value
</th>
</tr>
</thead>
<tbody>
<tr v-for="field in parseChanges(log.changes).fields" :key="field">
<td class="text-caption font-weight-medium">
{{ formatFieldName(field) }}
</td>
<td class="text-caption text-success">
{{ formatValue(parseChanges(log.changes).after?.[field]) }}
</td>
</tr>
</tbody>
</VTable>
</template>
<!-- Delete: show deleted values only -->
<template v-else-if="parseChanges(log.changes).type === 'delete'">
<VChip size="x-small" color="error" variant="tonal" class="mb-2">
Deleted Values
</VChip>
<VTable density="compact" class="rounded border">
<thead>
<tr>
<th class="text-caption" style="width: 30%;">
Field
</th>
<th class="text-caption">
Value
</th>
</tr>
</thead>
<tbody>
<tr v-for="field in parseChanges(log.changes).fields" :key="field">
<td class="text-caption font-weight-medium">
{{ formatFieldName(field) }}
</td>
<td class="text-caption text-error text-decoration-line-through">
{{ formatValue(parseChanges(log.changes).before?.[field]) }}
</td>
</tr>
</tbody>
</VTable>
</template>
<!-- Generic: raw JSON -->
<template v-else>
<pre class="text-caption pa-3 rounded bg-surface" style="white-space: pre-wrap; word-break: break-all;">{{ JSON.stringify(log.changes, null, 2) }}</pre>
</template>
</div>
</td>
</tr>
@@ -368,5 +619,284 @@ const hasActiveFilters = computed<boolean>(() => {
/>
</VCardText>
</VCard>
<!-- Detail Dialog -->
<VDialog
v-model="detailDialog"
max-width="800"
scrollable
>
<VCard v-if="selectedLog">
<VCardTitle class="d-flex align-center justify-space-between pa-5">
<div class="d-flex align-center gap-3">
<VAvatar :color="resolveActionColor(selectedLog.action)" variant="tonal" size="40">
<VIcon :icon="resolveActionIcon(selectedLog.action)" size="20" />
</VAvatar>
<div>
<div class="text-h6">
{{ formatAction(selectedLog.action) }}
</div>
<div class="text-caption text-medium-emphasis">
{{ formatDateTime(selectedLog.created_at) }}
</div>
</div>
</div>
<VBtn icon="tabler-x" variant="text" size="small" @click="closeDetailDialog" />
</VCardTitle>
<VDivider />
<VCardText class="pa-5">
<!-- Log Metadata -->
<VRow class="mb-5">
<VCol cols="12" sm="6">
<div class="text-caption text-medium-emphasis mb-1">
User
</div>
<div v-if="selectedLog.user" class="d-flex align-center gap-2">
<VAvatar color="primary" variant="tonal" size="28">
<span class="text-caption font-weight-medium">
{{ selectedLog.user.name.charAt(0).toUpperCase() }}
</span>
</VAvatar>
<div>
<div class="text-body-2 font-weight-medium">
{{ selectedLog.user.name }}
</div>
<div class="text-caption text-medium-emphasis">
{{ selectedLog.user.email }}
</div>
</div>
</div>
<VChip v-else size="small" variant="tonal" color="secondary">
System
</VChip>
</VCol>
<VCol cols="6" sm="3">
<div class="text-caption text-medium-emphasis mb-1">
Resource Type
</div>
<div class="text-body-2">
{{ formatResourceType(selectedLog.resource_type) }}
</div>
</VCol>
<VCol cols="6" sm="3">
<div class="text-caption text-medium-emphasis mb-1">
Resource ID
</div>
<div class="text-body-2">
{{ selectedLog.resource_id ?? '-' }}
</div>
</VCol>
</VRow>
<VRow class="mb-5">
<VCol cols="12" sm="6">
<div class="text-caption text-medium-emphasis mb-1">
IP Address
</div>
<div class="text-body-2">
{{ selectedLog.ip_address ?? '-' }}
</div>
</VCol>
<VCol cols="12" sm="6">
<div class="text-caption text-medium-emphasis mb-1">
User Agent
</div>
<div class="text-caption" style="word-break: break-all;">
{{ selectedLog.user_agent ?? '-' }}
</div>
</VCol>
</VRow>
<VDivider class="mb-5" />
<!-- Changes Diff View -->
<div v-if="hasChanges(selectedLog)">
<div class="text-subtitle-1 font-weight-semibold mb-4">
State Changes
</div>
<!-- Update: before/after diff -->
<template v-if="parseChanges(selectedLog.changes).type === 'update'">
<VRow>
<!-- Before Column -->
<VCol cols="12" md="6">
<VCard variant="outlined" color="error">
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
<VIcon icon="tabler-minus" size="16" color="error" />
Before
</VCardTitle>
<VDivider />
<VCardText class="pa-0">
<VTable density="compact">
<tbody>
<tr
v-for="field in parseChanges(selectedLog.changes).fields"
:key="field"
>
<td class="text-caption font-weight-medium" style="width: 40%;">
{{ formatFieldName(field) }}
</td>
<td class="text-caption">
<span
:class="{
'text-error text-decoration-line-through': isFieldChanged(parseChanges(selectedLog.changes).before, parseChanges(selectedLog.changes).after, field),
}"
>
{{ formatValue(parseChanges(selectedLog.changes).before?.[field]) }}
</span>
</td>
</tr>
</tbody>
</VTable>
</VCardText>
</VCard>
</VCol>
<!-- After Column -->
<VCol cols="12" md="6">
<VCard variant="outlined" color="success">
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
<VIcon icon="tabler-plus" size="16" color="success" />
After
</VCardTitle>
<VDivider />
<VCardText class="pa-0">
<VTable density="compact">
<tbody>
<tr
v-for="field in parseChanges(selectedLog.changes).fields"
:key="field"
>
<td class="text-caption font-weight-medium" style="width: 40%;">
{{ formatFieldName(field) }}
</td>
<td class="text-caption">
<span
:class="{
'text-success font-weight-medium': isFieldChanged(parseChanges(selectedLog.changes).before, parseChanges(selectedLog.changes).after, field),
}"
>
{{ formatValue(parseChanges(selectedLog.changes).after?.[field]) }}
</span>
</td>
</tr>
</tbody>
</VTable>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Changed fields summary -->
<VAlert type="info" variant="tonal" density="compact" class="mt-4">
<div class="text-caption">
<strong>Changed fields:</strong>
{{ parseChanges(selectedLog.changes).fields.filter(f => isFieldChanged(parseChanges(selectedLog.changes).before, parseChanges(selectedLog.changes).after, f)).join(', ') || 'None' }}
</div>
</VAlert>
</template>
<!-- Create: new values -->
<template v-else-if="parseChanges(selectedLog.changes).type === 'create'">
<VAlert type="success" variant="tonal" density="compact" class="mb-4">
<div class="text-caption">
New record created with the following values:
</div>
</VAlert>
<VCard variant="outlined" color="success">
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
<VIcon icon="tabler-plus" size="16" color="success" />
New Values
</VCardTitle>
<VDivider />
<VCardText class="pa-0">
<VTable density="compact">
<tbody>
<tr
v-for="field in parseChanges(selectedLog.changes).fields"
:key="field"
>
<td class="text-caption font-weight-medium" style="width: 40%;">
{{ formatFieldName(field) }}
</td>
<td class="text-caption text-success">
{{ formatValue(parseChanges(selectedLog.changes).after?.[field]) }}
</td>
</tr>
</tbody>
</VTable>
</VCardText>
</VCard>
</template>
<!-- Delete: deleted values -->
<template v-else-if="parseChanges(selectedLog.changes).type === 'delete'">
<VAlert type="error" variant="tonal" density="compact" class="mb-4">
<div class="text-caption">
Record deleted. Previous values:
</div>
</VAlert>
<VCard variant="outlined" color="error">
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
<VIcon icon="tabler-minus" size="16" color="error" />
Deleted Values
</VCardTitle>
<VDivider />
<VCardText class="pa-0">
<VTable density="compact">
<tbody>
<tr
v-for="field in parseChanges(selectedLog.changes).fields"
:key="field"
>
<td class="text-caption font-weight-medium" style="width: 40%;">
{{ formatFieldName(field) }}
</td>
<td class="text-caption text-error text-decoration-line-through">
{{ formatValue(parseChanges(selectedLog.changes).before?.[field]) }}
</td>
</tr>
</tbody>
</VTable>
</VCardText>
</VCard>
</template>
<!-- Generic: raw JSON -->
<template v-else>
<VCard variant="outlined">
<VCardTitle class="text-body-1 pa-3 d-flex align-center gap-2">
<VIcon icon="tabler-code" size="16" />
Raw Changes Data
</VCardTitle>
<VDivider />
<VCardText>
<pre class="text-caption rounded pa-3 bg-surface-variant" style="white-space: pre-wrap; word-break: break-all; max-height: 400px; overflow-y: auto;">{{ JSON.stringify(selectedLog.changes, null, 2) }}</pre>
</VCardText>
</VCard>
</template>
</div>
<!-- No changes -->
<div v-else class="text-center py-6">
<VIcon icon="tabler-file-off" size="36" color="disabled" class="mb-2" />
<div class="text-medium-emphasis text-body-2">
No change data recorded for this action.
</div>
</div>
</VCardText>
<VDivider />
<VCardActions class="pa-4">
<VSpacer />
<VBtn variant="tonal" color="secondary" @click="closeDetailDialog">
Close
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -94,12 +94,6 @@ function formatDate(dateString: string): string {
const formattedCreatedAt = computed<string>(() => 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 {
</VCardText>
</VCard>
<!-- Redemption History -->
<VCard title="Redemption History" class="mb-6">
<VDataTable
:headers="redemptionHeaders"
:items="redemptions"
:items-per-page="10"
hover
class="text-no-wrap"
>
<!-- Customer -->
<template #item.user="{ item }">
<div v-if="item.user" class="d-flex flex-column py-2">
<span class="text-body-2 font-weight-medium">{{ item.user.name }}</span>
<span class="text-caption text-medium-emphasis">{{ item.user.email }}</span>
</div>
<span v-else class="text-medium-emphasis">Unknown</span>
</template>
<!-- Discount Amount -->
<template #item.discount_amount="{ item }">
<span class="font-weight-medium">${{ parseFloat(item.discount_amount).toFixed(2) }}</span>
</template>
<!-- Created At -->
<template #item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<!-- No data -->
<template #no-data>
<div class="text-center py-8">
<VIcon icon="tabler-receipt-off" size="40" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No redemptions yet.
<!-- Redemption History Link -->
<VCard class="mb-6">
<VCardText class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-3">
<VAvatar color="primary" variant="tonal" size="40" rounded>
<VIcon icon="tabler-receipt" size="20" />
</VAvatar>
<div>
<div class="text-body-1 font-weight-medium">
Redemption History
</div>
<div class="text-body-2 text-medium-emphasis">
{{ redemptions.length }} redemption{{ redemptions.length !== 1 ? 's' : '' }} recorded
</div>
</div>
</template>
</VDataTable>
</div>
<Link :href="`/coupons/${coupon.id}`" class="text-decoration-none">
<VBtn
color="primary"
variant="tonal"
prepend-icon="tabler-eye"
size="small"
>
View All
</VBtn>
</Link>
</VCardText>
</VCard>
</VCol>

View File

@@ -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<CouponWithCount>
coupons: PaginatedResponse<CouponWithStats>
}
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
</div>
</div>
<Link href="/coupons/create">
<VBtn color="primary" prepend-icon="tabler-plus">
Create Coupon
</VBtn>
</Link>
<div class="d-flex gap-2">
<Link href="/coupons/redemptions">
<VBtn variant="outlined" prepend-icon="tabler-receipt">
View All Redemptions
</VBtn>
</Link>
<Link href="/coupons/create">
<VBtn color="primary" prepend-icon="tabler-plus">
Create Coupon
</VBtn>
</Link>
</div>
</div>
<!-- Coupons Table -->
@@ -142,14 +147,32 @@ function deactivateCoupon(coupon: CouponWithCount): void {
<!-- Usage -->
<template #item.usage="{ item }">
<span class="font-weight-medium">
{{ item.redemptions_count }}
</span>
<Link :href="`/coupons/${item.id}`" class="text-decoration-none">
<span class="font-weight-medium text-primary">
{{ item.redemptions_count }}
</span>
</Link>
<span class="text-medium-emphasis">
/ {{ item.max_uses ?? '&infin;' }}
</span>
</template>
<!-- Total Discount -->
<template #item.total_discount="{ item }">
<span v-if="item.redemptions_sum_discount_amount" class="font-weight-medium text-success">
${{ parseFloat(item.redemptions_sum_discount_amount).toFixed(2) }}
</span>
<span v-else class="text-medium-emphasis">$0.00</span>
</template>
<!-- Last Redeemed -->
<template #item.last_redeemed="{ item }">
<span v-if="item.redemptions_max_created_at">
{{ formatDate(item.redemptions_max_created_at) }}
</span>
<span v-else class="text-medium-emphasis">Never</span>
</template>
<!-- Expires -->
<template #item.expires_at="{ item }">
<span :class="{ 'text-error': item.expires_at && new Date(item.expires_at) < new Date() }">
@@ -179,6 +202,11 @@ function deactivateCoupon(coupon: CouponWithCount): void {
/>
</template>
<VList density="compact">
<Link :href="`/coupons/${item.id}`" class="text-decoration-none">
<VListItem prepend-icon="tabler-eye">
<VListItemTitle>View Redemptions</VListItemTitle>
</VListItem>
</Link>
<Link :href="`/coupons/${item.id}/edit`" class="text-decoration-none">
<VListItem prepend-icon="tabler-edit">
<VListItemTitle>Edit</VListItemTitle>

View File

@@ -0,0 +1,413 @@
<script lang="ts" setup>
import { Link, router } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import type { Coupon, CouponRedemption, PaginatedResponse, StatusColor } from '@/types'
interface Props {
redemptions: PaginatedResponse<CouponRedemption>
coupons: Coupon[]
stats: {
total_redemptions: number
total_discount: number
unique_customers: number
unique_coupons: number
}
filters: {
coupon_id?: number | string
customer?: string
date_from?: string
date_to?: string
}
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
// Filter form state
const filterForm = ref({
coupon_id: props.filters.coupon_id ?? '',
customer: props.filters.customer ?? '',
date_from: props.filters.date_from ?? '',
date_to: props.filters.date_to ?? '',
})
const tableHeaders = computed(() => [
{ title: 'Coupon', key: 'coupon', sortable: false },
{ title: 'Customer', key: 'user', sortable: false },
{ title: 'Subscription', key: 'subscription', sortable: false },
{ title: 'Discount Amount', key: 'discount_amount', sortable: true, align: 'end' as const },
{ title: 'Redeemed At', key: 'created_at', sortable: true },
])
function applyFilters(): void {
router.get('/coupons/redemptions', filterForm.value, {
preserveState: true,
preserveScroll: true,
})
}
function clearFilters(): void {
filterForm.value = {
coupon_id: '',
customer: '',
date_from: '',
date_to: '',
}
router.get('/coupons/redemptions', {}, {
preserveState: true,
preserveScroll: true,
})
}
function exportToCSV(): void {
// Build query string with current filters
const params = new URLSearchParams()
if (filterForm.value.coupon_id) {
params.append('coupon_id', String(filterForm.value.coupon_id))
}
if (filterForm.value.customer) {
params.append('customer', filterForm.value.customer)
}
if (filterForm.value.date_from) {
params.append('date_from', filterForm.value.date_from)
}
if (filterForm.value.date_to) {
params.append('date_to', filterForm.value.date_to)
}
params.append('export', 'csv')
window.location.href = `/coupons/redemptions?${params.toString()}`
}
function formatDate(dateString: string | null): string {
if (!dateString) {
return 'N/A'
}
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
function formatCouponValue(coupon: { type: string; value: string }): string {
if (coupon.type === 'percentage') {
return `${parseFloat(coupon.value)}%`
}
return `$${parseFloat(coupon.value).toFixed(2)}`
}
function resolveSubscriptionStatusColor(status: string): StatusColor {
const map: Record<string, StatusColor> = {
active: 'success',
canceled: 'error',
past_due: 'warning',
trialing: 'info',
incomplete: 'secondary',
}
return map[status] ?? 'secondary'
}
function resolveCouponTypeColor(type: string): StatusColor {
return type === 'percentage' ? 'info' : 'warning'
}
const hasActiveFilters = computed(() => {
return !!(filterForm.value.coupon_id || filterForm.value.customer || filterForm.value.date_from || filterForm.value.date_to)
})
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="d-flex align-center gap-2 mb-1">
<Link href="/coupons" class="text-decoration-none">
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
</Link>
<span class="text-h4 font-weight-bold">Coupon Redemption History</span>
</div>
<div class="text-body-2 text-medium-emphasis ms-10">
View all coupon redemptions across all coupons
</div>
</div>
<VBtn
color="success"
prepend-icon="tabler-download"
@click="exportToCSV"
>
Export CSV
</VBtn>
</div>
<!-- Stats Cards -->
<VRow class="mb-6">
<VCol cols="12" sm="6" lg="3">
<VCard>
<VCardText class="d-flex align-center gap-4">
<VAvatar color="primary" variant="tonal" size="48" rounded>
<VIcon icon="tabler-receipt" size="24" />
</VAvatar>
<div>
<div class="text-h5 font-weight-bold">
{{ stats.total_redemptions }}
</div>
<div class="text-body-2 text-medium-emphasis">
Total Redemptions
</div>
</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" sm="6" lg="3">
<VCard>
<VCardText class="d-flex align-center gap-4">
<VAvatar color="success" variant="tonal" size="48" rounded>
<VIcon icon="tabler-currency-dollar" size="24" />
</VAvatar>
<div>
<div class="text-h5 font-weight-bold">
${{ stats.total_discount.toFixed(2) }}
</div>
<div class="text-body-2 text-medium-emphasis">
Total Discount Given
</div>
</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" sm="6" lg="3">
<VCard>
<VCardText class="d-flex align-center gap-4">
<VAvatar color="info" variant="tonal" size="48" rounded>
<VIcon icon="tabler-users" size="24" />
</VAvatar>
<div>
<div class="text-h5 font-weight-bold">
{{ stats.unique_customers }}
</div>
<div class="text-body-2 text-medium-emphasis">
Unique Customers
</div>
</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" sm="6" lg="3">
<VCard>
<VCardText class="d-flex align-center gap-4">
<VAvatar color="warning" variant="tonal" size="48" rounded>
<VIcon icon="tabler-discount-2" size="24" />
</VAvatar>
<div>
<div class="text-h5 font-weight-bold">
{{ stats.unique_coupons }}
</div>
<div class="text-body-2 text-medium-emphasis">
Coupons Used
</div>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Filters -->
<VCard class="mb-6">
<VCardText>
<div class="text-subtitle-1 font-weight-medium mb-4">
Filters
</div>
<VRow>
<VCol cols="12" md="3">
<VSelect
v-model="filterForm.coupon_id"
:items="coupons"
item-title="code"
item-value="id"
label="Coupon"
clearable
density="comfortable"
/>
</VCol>
<VCol cols="12" md="3">
<VTextField
v-model="filterForm.customer"
label="Customer"
placeholder="Name or email"
clearable
density="comfortable"
/>
</VCol>
<VCol cols="12" md="2">
<VTextField
v-model="filterForm.date_from"
label="From Date"
type="date"
clearable
density="comfortable"
/>
</VCol>
<VCol cols="12" md="2">
<VTextField
v-model="filterForm.date_to"
label="To Date"
type="date"
clearable
density="comfortable"
/>
</VCol>
<VCol cols="12" md="2" class="d-flex align-center gap-2">
<VBtn
color="primary"
block
@click="applyFilters"
>
Apply
</VBtn>
<VBtn
v-if="hasActiveFilters"
variant="outlined"
color="secondary"
block
@click="clearFilters"
>
Clear
</VBtn>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Redemptions Table -->
<VCard>
<VDataTable
:headers="tableHeaders"
:items="redemptions.data"
:items-per-page="25"
hover
class="text-no-wrap"
>
<!-- Coupon -->
<template #item.coupon="{ item }">
<div v-if="item.coupon" class="d-flex flex-column py-2">
<Link :href="`/coupons/${item.coupon.id}`" class="text-decoration-none">
<span class="text-body-2 font-weight-medium font-monospace text-primary">{{ item.coupon.code }}</span>
</Link>
<div class="d-flex align-center gap-2 mt-1">
<VChip
:color="resolveCouponTypeColor(item.coupon.type)"
size="x-small"
variant="tonal"
class="text-capitalize"
>
{{ item.coupon.type }}
</VChip>
<span class="text-caption text-medium-emphasis">
{{ formatCouponValue(item.coupon) }}
</span>
</div>
</div>
<span v-else class="text-medium-emphasis">
Deleted Coupon
</span>
</template>
<!-- Customer -->
<template #item.user="{ item }">
<div v-if="item.user" class="d-flex flex-column py-2">
<Link :href="`/customers/${item.user.id}`" class="text-decoration-none">
<span class="text-body-2 font-weight-medium text-primary">{{ item.user.name }}</span>
</Link>
<span class="text-caption text-medium-emphasis">{{ item.user.email }}</span>
</div>
<span v-else class="text-medium-emphasis">
Deleted User
</span>
</template>
<!-- Subscription -->
<template #item.subscription="{ item }">
<template v-if="item.subscription">
<span class="font-weight-medium">#{{ item.subscription.id }}</span>
<VChip
:color="resolveSubscriptionStatusColor(item.subscription.stripe_status)"
size="x-small"
variant="tonal"
class="ms-2 text-capitalize"
>
{{ item.subscription.stripe_status }}
</VChip>
</template>
<span v-else class="text-medium-emphasis">N/A</span>
</template>
<!-- Discount Amount -->
<template #item.discount_amount="{ item }">
<span class="font-weight-medium text-success">
-${{ parseFloat(item.discount_amount).toFixed(2) }}
</span>
</template>
<!-- Redeemed At -->
<template #item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<!-- No data -->
<template #no-data>
<div class="text-center py-8">
<VIcon icon="tabler-receipt-off" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
{{ hasActiveFilters ? 'No redemptions found with the selected filters.' : 'No coupon redemptions yet.' }}
</div>
</div>
</template>
<!-- Bottom pagination -->
<template #bottom>
<VDivider />
<div class="d-flex align-center justify-space-between pa-4">
<div class="text-body-2 text-medium-emphasis">
Showing {{ redemptions.from ?? 0 }} to {{ redemptions.to ?? 0 }} of {{ redemptions.total }} redemptions
</div>
<div v-if="redemptions.last_page > 1" class="d-flex gap-2">
<template v-for="link in redemptions.links" :key="link.label">
<Link
v-if="link.url"
:href="link.url"
class="text-decoration-none"
preserve-scroll
>
<VBtn
:variant="link.active ? 'flat' : 'text'"
:color="link.active ? 'primary' : undefined"
size="small"
min-width="36"
>
<span v-html="link.label" />
</VBtn>
</Link>
<VBtn
v-else
variant="text"
size="small"
min-width="36"
disabled
>
<span v-html="link.label" />
</VBtn>
</template>
</div>
</div>
</template>
</VDataTable>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,387 @@
<script lang="ts" setup>
import { Link } from '@inertiajs/vue3'
import { computed } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import type { Coupon, CouponRedemption, CouponRedemptionStats, PaginatedResponse, StatusColor } from '@/types'
interface Props {
coupon: Coupon
redemptions: PaginatedResponse<CouponRedemption>
stats: CouponRedemptionStats
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
function resolveCouponStatus(): { label: string; color: StatusColor } {
if (!props.coupon.active) {
return { label: 'Inactive', color: 'error' }
}
if (props.coupon.expires_at && new Date(props.coupon.expires_at) < new Date()) {
return { label: 'Expired', color: 'secondary' }
}
if (props.coupon.max_uses !== null && props.coupon.times_used >= props.coupon.max_uses) {
return { label: 'Exhausted', color: 'warning' }
}
return { label: 'Active', color: 'success' }
}
function formatValue(coupon: Coupon): string {
if (coupon.type === 'percentage') {
return `${parseFloat(coupon.value)}%`
}
return `$${parseFloat(coupon.value).toFixed(2)}`
}
function formatDate(dateString: string | null): string {
if (!dateString) {
return 'N/A'
}
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function formatDateTime(dateString: string | null): string {
if (!dateString) {
return 'N/A'
}
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
const couponStatus = computed(() => resolveCouponStatus())
const redemptionHeaders = computed(() => [
{ title: 'Customer', key: 'user', sortable: false },
{ title: 'Subscription', key: 'subscription', sortable: false },
{ title: 'Discount Applied', key: 'discount_amount', sortable: true, align: 'end' as const },
{ title: 'Redeemed At', key: 'created_at', sortable: true },
])
function resolveSubscriptionLabel(redemption: CouponRedemption): string {
if (!redemption.subscription) {
return 'N/A'
}
return `#${redemption.subscription.id} (${redemption.subscription.type})`
}
function resolveSubscriptionStatusColor(status: string): StatusColor {
const map: Record<string, StatusColor> = {
active: 'success',
canceled: 'error',
past_due: 'warning',
trialing: 'info',
incomplete: 'secondary',
}
return map[status] ?? 'secondary'
}
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="d-flex align-center gap-2 mb-1">
<Link href="/coupons" class="text-decoration-none">
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
</Link>
<span class="text-h4 font-weight-bold">Coupon Details</span>
<VChip
:color="couponStatus.color"
size="small"
class="ms-2"
>
{{ couponStatus.label }}
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis ms-10">
Viewing coupon "{{ coupon.code }}"
</div>
</div>
<Link :href="`/coupons/${coupon.id}/edit`">
<VBtn color="primary" prepend-icon="tabler-edit">
Edit Coupon
</VBtn>
</Link>
</div>
<VRow>
<!-- Main Content -->
<VCol cols="12" lg="8">
<!-- Redemption Stats -->
<VRow class="mb-6">
<VCol cols="12" sm="4">
<VCard>
<VCardText class="d-flex align-center gap-4">
<VAvatar color="primary" variant="tonal" size="48" rounded>
<VIcon icon="tabler-receipt" size="24" />
</VAvatar>
<div>
<div class="text-h5 font-weight-bold">
{{ stats.total_redemptions }}
</div>
<div class="text-body-2 text-medium-emphasis">
Total Redemptions
</div>
</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" sm="4">
<VCard>
<VCardText class="d-flex align-center gap-4">
<VAvatar color="success" variant="tonal" size="48" rounded>
<VIcon icon="tabler-currency-dollar" size="24" />
</VAvatar>
<div>
<div class="text-h5 font-weight-bold">
${{ stats.total_discount.toFixed(2) }}
</div>
<div class="text-body-2 text-medium-emphasis">
Total Discount Given
</div>
</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" sm="4">
<VCard>
<VCardText class="d-flex align-center gap-4">
<VAvatar color="info" variant="tonal" size="48" rounded>
<VIcon icon="tabler-clock" size="24" />
</VAvatar>
<div>
<div class="text-h5 font-weight-bold">
{{ formatDate(stats.latest_redemption) }}
</div>
<div class="text-body-2 text-medium-emphasis">
Last Redemption
</div>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Redemption History Table -->
<VCard title="Redemption History">
<VDataTable
:headers="redemptionHeaders"
:items="redemptions.data"
:items-per-page="25"
hover
class="text-no-wrap"
>
<!-- Customer -->
<template #item.user="{ item }">
<div v-if="item.user" class="d-flex flex-column py-2">
<Link :href="`/customers/${item.user.id}`" class="text-decoration-none">
<span class="text-body-2 font-weight-medium text-primary">{{ item.user.name }}</span>
</Link>
<span class="text-caption text-medium-emphasis">{{ item.user.email }}</span>
</div>
<span v-else class="text-medium-emphasis">
Deleted User
</span>
</template>
<!-- Subscription -->
<template #item.subscription="{ item }">
<template v-if="item.subscription">
<span class="font-weight-medium">{{ resolveSubscriptionLabel(item) }}</span>
<VChip
:color="resolveSubscriptionStatusColor(item.subscription.stripe_status)"
size="x-small"
variant="tonal"
class="ms-2 text-capitalize"
>
{{ item.subscription.stripe_status }}
</VChip>
</template>
<span v-else class="text-medium-emphasis">N/A</span>
</template>
<!-- Discount Amount -->
<template #item.discount_amount="{ item }">
<span class="font-weight-medium text-success">
-${{ parseFloat(item.discount_amount).toFixed(2) }}
</span>
</template>
<!-- Redeemed At -->
<template #item.created_at="{ item }">
{{ formatDateTime(item.created_at) }}
</template>
<!-- No data -->
<template #no-data>
<div class="text-center py-8">
<VIcon icon="tabler-receipt-off" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No redemptions for this coupon yet.
</div>
</div>
</template>
<!-- Bottom pagination info -->
<template #bottom>
<VDivider />
<div class="d-flex align-center justify-space-between pa-4">
<div class="text-body-2 text-medium-emphasis">
Showing {{ redemptions.from ?? 0 }} to {{ redemptions.to ?? 0 }} of {{ redemptions.total }} redemptions
</div>
<div v-if="redemptions.last_page > 1" class="d-flex gap-2">
<template v-for="link in redemptions.links" :key="link.label">
<Link
v-if="link.url"
:href="link.url"
class="text-decoration-none"
preserve-scroll
>
<VBtn
:variant="link.active ? 'flat' : 'text'"
:color="link.active ? 'primary' : undefined"
size="small"
min-width="36"
>
<span v-html="link.label" />
</VBtn>
</Link>
<VBtn
v-else
variant="text"
size="small"
min-width="36"
disabled
>
<span v-html="link.label" />
</VBtn>
</template>
</div>
</div>
</template>
</VDataTable>
</VCard>
</VCol>
<!-- Sidebar -->
<VCol cols="12" lg="4">
<!-- Coupon Info -->
<VCard title="Coupon Info" class="mb-6">
<VCardText>
<div class="d-flex justify-space-between align-center mb-4">
<span class="text-body-2 text-medium-emphasis">Code</span>
<span class="font-weight-medium font-monospace">{{ coupon.code }}</span>
</div>
<VDivider class="mb-4" />
<div class="d-flex justify-space-between align-center mb-4">
<span class="text-body-2 text-medium-emphasis">Status</span>
<VChip
:color="couponStatus.color"
size="small"
>
{{ couponStatus.label }}
</VChip>
</div>
<VDivider class="mb-4" />
<div class="d-flex justify-space-between align-center mb-4">
<span class="text-body-2 text-medium-emphasis">Type</span>
<VChip
:color="coupon.type === 'percentage' ? 'info' : 'warning'"
size="small"
variant="tonal"
class="text-capitalize"
>
{{ coupon.type }}
</VChip>
</div>
<VDivider class="mb-4" />
<div class="d-flex justify-space-between align-center mb-4">
<span class="text-body-2 text-medium-emphasis">Value</span>
<span class="text-body-2 font-weight-medium">{{ formatValue(coupon) }}</span>
</div>
<VDivider class="mb-4" />
<div class="d-flex justify-space-between align-center mb-4">
<span class="text-body-2 text-medium-emphasis">Usage</span>
<span class="text-body-2 font-weight-medium">
{{ coupon.times_used }} / {{ coupon.max_uses ?? '&infin;' }}
</span>
</div>
<VDivider class="mb-4" />
<div class="d-flex justify-space-between align-center mb-4">
<span class="text-body-2 text-medium-emphasis">Expires</span>
<span
class="text-body-2"
:class="{ 'text-error': coupon.expires_at && new Date(coupon.expires_at) < new Date() }"
>
{{ coupon.expires_at ? formatDate(coupon.expires_at) : 'Never' }}
</span>
</div>
<VDivider class="mb-4" />
<div class="d-flex justify-space-between align-center">
<span class="text-body-2 text-medium-emphasis">Created</span>
<span class="text-body-2">{{ formatDate(coupon.created_at) }}</span>
</div>
</VCardText>
</VCard>
<!-- Plan Restrictions -->
<VCard title="Plan Restrictions" class="mb-6">
<VCardText>
<div v-if="!coupon.applies_to || coupon.applies_to.length === 0" class="text-body-2 text-medium-emphasis">
Applies to all plans (no restrictions).
</div>
<div v-else>
<VChip
v-for="planId in coupon.applies_to"
:key="planId"
size="small"
variant="tonal"
color="secondary"
class="me-2 mb-2"
>
Plan #{{ planId }}
</VChip>
</div>
</VCardText>
</VCard>
<!-- Actions -->
<VCard>
<VCardText>
<Link :href="`/coupons/${coupon.id}/edit`" class="text-decoration-none">
<VBtn
color="primary"
block
prepend-icon="tabler-edit"
class="mb-3"
>
Edit Coupon
</VBtn>
</Link>
<Link href="/coupons" class="text-decoration-none">
<VBtn
variant="outlined"
block
>
Back to List
</VBtn>
</Link>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</template>

View File

@@ -1,10 +1,18 @@
<script lang="ts" setup>
import { Link, router, useForm } from '@inertiajs/vue3'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
import type { AuditLog, PaginatedResponse } from '@/types'
interface Plan {
id: number
name: string
price: string
billing_cycle: string
service_type: string
}
interface CustomerProfile {
billing_address_line1: string | null
billing_address_line2: string | null
@@ -77,6 +85,7 @@ interface Props {
subscriptions: CustomerSubscription[]
recentInvoices: CustomerInvoice[]
auditLogs: PaginatedResponse<AuditLog>
plans: Plan[]
}
defineOptions({ layout: AdminLayout })
@@ -89,6 +98,32 @@ const suspendForm = useForm({})
const unsuspendForm = useForm({})
const expandedRows = ref<Set<number>>(new Set())
// Admin action dialogs
const showPlaceOrderDialog = ref(false)
const showSendNotificationDialog = ref(false)
const showPurgeConfirmDialog = ref(false)
const showResetPasswordConfirmDialog = ref(false)
// Place order form
const placeOrderForm = useForm({
plan_id: null as number | null,
billing_cycle: 'monthly',
})
// Send notification form
const sendNotificationForm = useForm({
subject: '',
message: '',
})
// Purge confirmation
const purgeConfirmEmail = ref('')
// Filter plans by their availability
const availablePlans = computed(() => {
return props.plans.filter(plan => plan.service_type !== 'addon')
})
function handleSuspend(): void {
suspendForm.post(`/customers/${props.customer.id}/suspend`, {
preserveScroll: true,
@@ -101,6 +136,44 @@ function handleUnsuspend(): void {
})
}
function handlePlaceOrder(): void {
placeOrderForm.post(`/customers/${props.customer.id}/place-order`, {
preserveScroll: true,
onSuccess: () => {
showPlaceOrderDialog.value = false
placeOrderForm.reset()
},
})
}
function handleSendNotification(): void {
sendNotificationForm.post(`/customers/${props.customer.id}/send-notification`, {
preserveScroll: true,
onSuccess: () => {
showSendNotificationDialog.value = false
sendNotificationForm.reset()
},
})
}
function handleResetPassword(): void {
router.post(`/customers/${props.customer.id}/reset-password`, {}, {
preserveScroll: true,
onSuccess: () => {
showResetPasswordConfirmDialog.value = false
},
})
}
function handlePurge(): void {
router.delete(`/customers/${props.customer.id}/purge`, {
preserveScroll: false,
onSuccess: () => {
showPurgeConfirmDialog.value = false
},
})
}
function resolveUserStatusColor(status: string): string {
const map: Record<string, string> = {
active: 'success',
@@ -381,6 +454,47 @@ function goToAuditPage(page: number): void {
<VIcon icon="tabler-circle-check" start />
Unsuspend
</VBtn>
<VMenu>
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
color="secondary"
variant="tonal"
size="small"
icon="tabler-dots-vertical"
/>
</template>
<VList>
<VListItem @click="showPlaceOrderDialog = true">
<template #prepend>
<VIcon icon="tabler-shopping-cart" />
</template>
<VListItemTitle>Place Order</VListItemTitle>
</VListItem>
<VListItem @click="showSendNotificationDialog = true">
<template #prepend>
<VIcon icon="tabler-mail" />
</template>
<VListItemTitle>Send Notification</VListItemTitle>
</VListItem>
<VListItem @click="showResetPasswordConfirmDialog = true">
<template #prepend>
<VIcon icon="tabler-lock-open" />
</template>
<VListItemTitle>Reset Password</VListItemTitle>
</VListItem>
<VDivider />
<VListItem @click="showPurgeConfirmDialog = true">
<template #prepend>
<VIcon icon="tabler-trash" color="error" />
</template>
<VListItemTitle class="text-error">
Purge Customer
</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
</div>
</VCardText>
@@ -994,5 +1108,189 @@ function goToAuditPage(page: number): void {
</VCard>
</VWindowItem>
</VWindow>
<!-- Place Order Dialog -->
<VDialog v-model="showPlaceOrderDialog" max-width="600">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-shopping-cart" />
Place Order for {{ customer.name }}
</VCardTitle>
<VCardText>
<VRow>
<VCol cols="12">
<VSelect
v-model="placeOrderForm.plan_id"
:items="availablePlans"
item-title="name"
item-value="id"
label="Plan"
placeholder="Select a plan"
:error-messages="placeOrderForm.errors.plan_id"
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps">
<template #prepend>
<VIcon :icon="item.raw.service_type === 'vps' ? 'tabler-server' : item.raw.service_type === 'dedicated' ? 'tabler-server-2' : item.raw.service_type === 'hosting' ? 'tabler-world' : 'tabler-device-gamepad-2'" />
</template>
<VListItemTitle>{{ item.raw.name }}</VListItemTitle>
<VListItemSubtitle>
${{ item.raw.price }}/{{ item.raw.billing_cycle }}
</VListItemSubtitle>
</VListItem>
</template>
</VSelect>
</VCol>
<VCol cols="12">
<VSelect
v-model="placeOrderForm.billing_cycle"
:items="[
{ title: 'Monthly', value: 'monthly' },
{ title: 'Quarterly', value: 'quarterly' },
{ title: 'Semi-Annually', value: 'semi_annually' },
{ title: 'Annually', value: 'annually' },
]"
label="Billing Cycle"
:error-messages="placeOrderForm.errors.billing_cycle"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="justify-end">
<VBtn @click="showPlaceOrderDialog = false">
Cancel
</VBtn>
<VBtn
color="primary"
:loading="placeOrderForm.processing"
@click="handlePlaceOrder"
>
Place Order
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Send Notification Dialog -->
<VDialog v-model="showSendNotificationDialog" max-width="600">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-mail" />
Send Notification to {{ customer.name }}
</VCardTitle>
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-model="sendNotificationForm.subject"
label="Subject"
placeholder="Enter email subject"
:error-messages="sendNotificationForm.errors.subject"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="sendNotificationForm.message"
label="Message"
placeholder="Enter email message"
rows="6"
:error-messages="sendNotificationForm.errors.message"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="justify-end">
<VBtn @click="showSendNotificationDialog = false">
Cancel
</VBtn>
<VBtn
color="primary"
:loading="sendNotificationForm.processing"
@click="handleSendNotification"
>
Send
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Reset Password Confirmation Dialog -->
<VDialog v-model="showResetPasswordConfirmDialog" max-width="500">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-lock-open" color="warning" />
Reset Password
</VCardTitle>
<VCardText>
<VAlert type="warning" variant="tonal" class="mb-4">
<div class="text-body-2">
This will generate a new random password and email it to <strong>{{ customer.email }}</strong>.
</div>
</VAlert>
<div class="text-body-2">
Are you sure you want to reset the password for <strong>{{ customer.name }}</strong>?
</div>
</VCardText>
<VCardActions class="justify-end">
<VBtn @click="showResetPasswordConfirmDialog = false">
Cancel
</VBtn>
<VBtn
color="warning"
@click="handleResetPassword"
>
Reset Password
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Purge Customer Confirmation Dialog -->
<VDialog v-model="showPurgeConfirmDialog" max-width="500">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-alert-triangle" color="error" />
Purge Customer
</VCardTitle>
<VCardText>
<VAlert type="error" variant="tonal" class="mb-4">
<div class="text-body-2 font-weight-bold mb-2">
WARNING: This action is IRREVERSIBLE!
</div>
<div class="text-body-2">
This will permanently delete:
</div>
<ul class="mt-2">
<li>Customer account and profile</li>
<li>All services ({{ customer.services.length }})</li>
<li>All subscriptions ({{ subscriptions.length }})</li>
<li>All invoices ({{ recentInvoices.length }})</li>
<li>All orders and audit logs</li>
</ul>
</VAlert>
<div class="text-body-2">
Type <strong>{{ customer.email }}</strong> to confirm:
</div>
<VTextField
v-model="purgeConfirmEmail"
class="mt-2"
placeholder="Enter email to confirm"
density="compact"
/>
</VCardText>
<VCardActions class="justify-end">
<VBtn @click="showPurgeConfirmDialog = false">
Cancel
</VBtn>
<VBtn
color="error"
:disabled="purgeConfirmEmail !== customer.email"
@click="handlePurge"
>
Purge Customer
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -0,0 +1,296 @@
<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { computed } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
import { formatPrice } from '@/utils/resolvers'
interface CustomerOption {
id: number
name: string
email: string
}
interface LineItem {
description: string
quantity: number
unit_price: string
}
interface Props {
customers: CustomerOption[]
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const customerOptions = computed(() => {
return props.customers.map((c: CustomerOption) => ({
title: `${c.name} (${c.email})`,
value: c.id,
}))
})
const form = useForm({
customer_id: null as number | null,
items: [{ description: '', quantity: 1, unit_price: '' }] as LineItem[],
due_date: '',
notes: '',
send_immediately: false,
})
function addLineItem(): void {
form.items.push({ description: '', quantity: 1, unit_price: '' })
}
function removeLineItem(index: number): void {
if (form.items.length > 1) {
form.items.splice(index, 1)
}
}
function lineTotal(item: LineItem): number {
return (parseFloat(item.unit_price) || 0) * (item.quantity || 0)
}
const subtotal = computed<number>(() => {
return form.items.reduce((sum: number, item: LineItem) => sum + lineTotal(item), 0)
})
function submitDraft(): void {
form.send_immediately = false
form.post('/invoices', { preserveScroll: true })
}
function submitAndSend(): void {
form.send_immediately = true
form.post('/invoices', { preserveScroll: true })
}
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="d-flex align-center gap-2 mb-1">
<Link href="/invoices" class="text-decoration-none">
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
</Link>
<span class="text-h4 font-weight-bold">Create Invoice</span>
</div>
<div class="text-body-2 text-medium-emphasis ms-10">
Create a new manual invoice for a customer
</div>
</div>
</div>
<form @submit.prevent="submitDraft">
<VRow>
<!-- Main Content -->
<VCol cols="12" lg="8">
<!-- Customer Selection -->
<VCard title="Customer" class="mb-6">
<VCardText>
<VAutocomplete
v-model="form.customer_id"
:items="customerOptions"
label="Select Customer"
placeholder="Search by name or email..."
:error-messages="form.errors.customer_id"
clearable
no-data-text="No customers found"
/>
</VCardText>
</VCard>
<!-- Line Items -->
<VCard title="Line Items" class="mb-6">
<VCardText>
<!-- Items Header -->
<VRow class="mb-2 d-none d-md-flex">
<VCol cols="12" md="5">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Description</span>
</VCol>
<VCol cols="12" md="2">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Qty</span>
</VCol>
<VCol cols="12" md="3">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Unit Price</span>
</VCol>
<VCol cols="12" md="2">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Total</span>
</VCol>
</VRow>
<VDivider class="mb-4 d-none d-md-flex" />
<!-- Item Rows -->
<div
v-for="(item, index) in form.items"
:key="index"
class="mb-3"
>
<VRow align="center">
<VCol cols="12" md="5">
<AppTextField
v-model="item.description"
placeholder="Item description"
density="compact"
:error-messages="form.errors[`items.${index}.description`]"
/>
</VCol>
<VCol cols="6" md="2">
<AppTextField
v-model.number="item.quantity"
type="number"
min="1"
placeholder="1"
density="compact"
:error-messages="form.errors[`items.${index}.quantity`]"
/>
</VCol>
<VCol cols="6" md="3">
<AppTextField
v-model="item.unit_price"
type="number"
step="0.01"
min="0"
placeholder="0.00"
density="compact"
prefix="$"
:error-messages="form.errors[`items.${index}.unit_price`]"
/>
</VCol>
<VCol cols="10" md="1" class="text-body-2 font-weight-medium">
{{ formatPrice(lineTotal(item)) }}
</VCol>
<VCol cols="2" md="1">
<VBtn
icon="tabler-trash"
color="error"
variant="text"
size="small"
:disabled="form.items.length <= 1"
@click="removeLineItem(index)"
/>
</VCol>
</VRow>
</div>
<div v-if="form.errors.items" class="text-error text-body-2 mb-3">
{{ form.errors.items }}
</div>
<VBtn
variant="tonal"
color="primary"
prepend-icon="tabler-plus"
size="small"
@click="addLineItem"
>
Add Line Item
</VBtn>
<!-- Totals -->
<VDivider class="my-4" />
<div class="d-flex flex-column align-end ga-2">
<div class="d-flex align-center ga-6" style="min-width: 200px;">
<span class="text-body-1 font-weight-bold">Total</span>
<VSpacer />
<span class="text-body-1 font-weight-bold">{{ formatPrice(subtotal) }}</span>
</div>
</div>
</VCardText>
</VCard>
<!-- Notes -->
<VCard title="Notes" class="mb-6">
<VCardText>
<AppTextarea
v-model="form.notes"
label="Invoice Notes"
placeholder="Optional notes to include on the invoice..."
rows="3"
:error-messages="form.errors.notes"
/>
</VCardText>
</VCard>
</VCol>
<!-- Sidebar -->
<VCol cols="12" lg="4">
<!-- Due Date -->
<VCard title="Due Date" class="mb-6">
<VCardText>
<AppTextField
v-model="form.due_date"
label="Due Date"
type="date"
:error-messages="form.errors.due_date"
/>
</VCardText>
</VCard>
<!-- Summary -->
<VCard title="Summary" class="mb-6">
<VCardText>
<div class="d-flex justify-space-between align-center mb-3">
<span class="text-body-2 text-medium-emphasis">Line Items</span>
<span class="text-body-2 font-weight-medium">{{ form.items.length }}</span>
</div>
<div class="d-flex justify-space-between align-center mb-3">
<span class="text-body-2 text-medium-emphasis">Subtotal</span>
<span class="text-body-2 font-weight-medium">{{ formatPrice(subtotal) }}</span>
</div>
<VDivider class="my-2" />
<div class="d-flex justify-space-between align-center">
<span class="text-body-1 font-weight-bold">Total</span>
<span class="text-body-1 font-weight-bold">{{ formatPrice(subtotal) }}</span>
</div>
</VCardText>
</VCard>
<!-- Actions -->
<VCard>
<VCardText>
<VBtn
color="primary"
block
:loading="form.processing && form.send_immediately"
:disabled="form.processing"
prepend-icon="tabler-send"
class="mb-3"
@click="submitAndSend"
>
Create & Send
</VBtn>
<VBtn
type="submit"
variant="tonal"
color="secondary"
block
:loading="form.processing && !form.send_immediately"
:disabled="form.processing"
prepend-icon="tabler-file-plus"
class="mb-3"
>
Save as Draft
</VBtn>
<Link href="/invoices" class="text-decoration-none">
<VBtn
variant="outlined"
block
>
Cancel
</VBtn>
</Link>
</VCardText>
</VCard>
</VCol>
</VRow>
</form>
</div>
</template>

View File

@@ -0,0 +1,324 @@
<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { computed } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
import { resolveInvoiceStatusColor, formatPrice } from '@/utils/resolvers'
interface InvoiceUser {
id: number
name: string
email: string
}
interface InvoiceLineItem {
id: number
description: string
amount: string
quantity: number
}
interface InvoiceDetail {
id: number
user_id: number
number: string
total: string
tax: string
currency: string
status: string
gateway: string | null
notes: string | null
due_date: string | null
created_at: string
user: InvoiceUser | null
items: InvoiceLineItem[]
}
interface LineItemForm {
description: string
quantity: number
unit_price: string
}
interface Props {
invoice: InvoiceDetail
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
function formatDateForInput(dateStr: string | null): string {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toISOString().split('T')[0]
}
function itemsToFormItems(items: InvoiceLineItem[]): LineItemForm[] {
return items.map((item: InvoiceLineItem) => ({
description: item.description,
quantity: item.quantity,
unit_price: item.amount,
}))
}
const form = useForm({
items: itemsToFormItems(props.invoice.items),
due_date: formatDateForInput(props.invoice.due_date),
notes: props.invoice.notes ?? '',
})
function addLineItem(): void {
form.items.push({ description: '', quantity: 1, unit_price: '' })
}
function removeLineItem(index: number): void {
if (form.items.length > 1) {
form.items.splice(index, 1)
}
}
function lineTotal(item: LineItemForm): number {
return (parseFloat(item.unit_price) || 0) * (item.quantity || 0)
}
const subtotal = computed<number>(() => {
return form.items.reduce((sum: number, item: LineItemForm) => sum + lineTotal(item), 0)
})
function submit(): void {
form.put(`/invoices/${props.invoice.id}`, {
preserveScroll: true,
})
}
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="d-flex align-center gap-2 mb-1">
<Link :href="`/invoices/${invoice.id}`" class="text-decoration-none">
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
</Link>
<span class="text-h4 font-weight-bold">Edit Invoice {{ invoice.number }}</span>
<VChip
:color="resolveInvoiceStatusColor(invoice.status)"
size="small"
class="text-capitalize"
>
{{ invoice.status }}
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis ms-10">
{{ invoice.user?.name ?? 'Unknown Customer' }} &middot; {{ invoice.user?.email ?? '' }}
</div>
</div>
</div>
<form @submit.prevent="submit">
<VRow>
<!-- Main Content -->
<VCol cols="12" lg="8">
<!-- Line Items -->
<VCard title="Line Items" class="mb-6">
<VCardText>
<!-- Items Header -->
<VRow class="mb-2 d-none d-md-flex">
<VCol cols="12" md="5">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Description</span>
</VCol>
<VCol cols="12" md="2">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Qty</span>
</VCol>
<VCol cols="12" md="3">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Unit Price</span>
</VCol>
<VCol cols="12" md="2">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Total</span>
</VCol>
</VRow>
<VDivider class="mb-4 d-none d-md-flex" />
<!-- Item Rows -->
<div
v-for="(item, index) in form.items"
:key="index"
class="mb-3"
>
<VRow align="center">
<VCol cols="12" md="5">
<AppTextField
v-model="item.description"
placeholder="Item description"
density="compact"
:error-messages="form.errors[`items.${index}.description`]"
/>
</VCol>
<VCol cols="6" md="2">
<AppTextField
v-model.number="item.quantity"
type="number"
min="1"
placeholder="1"
density="compact"
:error-messages="form.errors[`items.${index}.quantity`]"
/>
</VCol>
<VCol cols="6" md="3">
<AppTextField
v-model="item.unit_price"
type="number"
step="0.01"
min="0"
placeholder="0.00"
density="compact"
prefix="$"
:error-messages="form.errors[`items.${index}.unit_price`]"
/>
</VCol>
<VCol cols="10" md="1" class="text-body-2 font-weight-medium">
{{ formatPrice(lineTotal(item)) }}
</VCol>
<VCol cols="2" md="1">
<VBtn
icon="tabler-trash"
color="error"
variant="text"
size="small"
:disabled="form.items.length <= 1"
@click="removeLineItem(index)"
/>
</VCol>
</VRow>
</div>
<div v-if="form.errors.items" class="text-error text-body-2 mb-3">
{{ form.errors.items }}
</div>
<VBtn
variant="tonal"
color="primary"
prepend-icon="tabler-plus"
size="small"
@click="addLineItem"
>
Add Line Item
</VBtn>
<!-- Totals -->
<VDivider class="my-4" />
<div class="d-flex flex-column align-end ga-2">
<div class="d-flex align-center ga-6" style="min-width: 200px;">
<span class="text-body-1 font-weight-bold">Total</span>
<VSpacer />
<span class="text-body-1 font-weight-bold">{{ formatPrice(subtotal) }}</span>
</div>
</div>
</VCardText>
</VCard>
<!-- Notes -->
<VCard title="Notes" class="mb-6">
<VCardText>
<AppTextarea
v-model="form.notes"
label="Invoice Notes"
placeholder="Optional notes to include on the invoice..."
rows="3"
:error-messages="form.errors.notes"
/>
</VCardText>
</VCard>
</VCol>
<!-- Sidebar -->
<VCol cols="12" lg="4">
<!-- Invoice Info -->
<VCard title="Invoice Info" class="mb-6">
<VCardText>
<div class="d-flex justify-space-between align-center mb-3">
<span class="text-body-2 text-medium-emphasis">Invoice #</span>
<span class="text-body-2 font-weight-medium">{{ invoice.number }}</span>
</div>
<div class="d-flex justify-space-between align-center mb-3">
<span class="text-body-2 text-medium-emphasis">Status</span>
<VChip
:color="resolveInvoiceStatusColor(invoice.status)"
size="small"
class="text-capitalize"
>
{{ invoice.status }}
</VChip>
</div>
<div class="d-flex justify-space-between align-center mb-3">
<span class="text-body-2 text-medium-emphasis">Customer</span>
<span class="text-body-2 font-weight-medium">{{ invoice.user?.name ?? 'Unknown' }}</span>
</div>
<div class="d-flex justify-space-between align-center">
<span class="text-body-2 text-medium-emphasis">Gateway</span>
<span class="text-body-2 text-capitalize">{{ invoice.gateway ?? 'Manual' }}</span>
</div>
</VCardText>
</VCard>
<!-- Due Date -->
<VCard title="Due Date" class="mb-6">
<VCardText>
<AppTextField
v-model="form.due_date"
label="Due Date"
type="date"
:error-messages="form.errors.due_date"
/>
</VCardText>
</VCard>
<!-- Summary -->
<VCard title="Summary" class="mb-6">
<VCardText>
<div class="d-flex justify-space-between align-center mb-3">
<span class="text-body-2 text-medium-emphasis">Line Items</span>
<span class="text-body-2 font-weight-medium">{{ form.items.length }}</span>
</div>
<VDivider class="my-2" />
<div class="d-flex justify-space-between align-center">
<span class="text-body-1 font-weight-bold">Total</span>
<span class="text-body-1 font-weight-bold">{{ formatPrice(subtotal) }}</span>
</div>
</VCardText>
</VCard>
<!-- Actions -->
<VCard>
<VCardText>
<VBtn
type="submit"
color="primary"
block
:loading="form.processing"
:disabled="form.processing"
prepend-icon="tabler-check"
class="mb-3"
>
Update Invoice
</VBtn>
<Link :href="`/invoices/${invoice.id}`" class="text-decoration-none">
<VBtn
variant="outlined"
block
>
Cancel
</VBtn>
</Link>
</VCardText>
</VCard>
</VCol>
</VRow>
</form>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Link, router } from '@inertiajs/vue3'
import { Link, router, useForm } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { resolveInvoiceStatusColor, formatPrice } from '@/utils/resolvers'
@@ -42,9 +42,11 @@ const props = defineProps<Props>()
const search = ref<string>(props.filters.search)
const status = ref<string>(props.filters.status)
const resendingId = ref<number | null>(null)
const statusOptions = [
{ title: 'All Statuses', value: '' },
{ title: 'Draft', value: 'draft' },
{ title: 'Paid', value: 'paid' },
{ title: 'Pending', value: 'pending' },
{ title: 'Overdue', value: 'overdue' },
@@ -72,6 +74,15 @@ watch(status, () => {
applyFilters()
})
function resendInvoice(invoice: InvoiceItem): void {
resendingId.value = invoice.id
const resendForm = useForm({})
resendForm.post(`/invoices/${invoice.id}/resend`, {
preserveScroll: true,
onFinish: () => { resendingId.value = null },
})
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
@@ -90,6 +101,11 @@ function formatDate(dateStr: string | null): string {
Manage all customer invoices
</div>
</div>
<Link href="/invoices/create">
<VBtn color="primary" prepend-icon="tabler-plus">
Create Invoice
</VBtn>
</Link>
</div>
<!-- Filters -->
@@ -175,11 +191,31 @@ function formatDate(dateStr: string | null): string {
{{ formatDate(invoice.paid_at) }}
</td>
<td class="text-center">
<Link :href="`/invoices/${invoice.id}`">
<VBtn variant="text" size="small" color="primary">
<VIcon icon="tabler-eye" size="18" />
<div class="d-flex align-center justify-center gap-1">
<Link :href="`/invoices/${invoice.id}`">
<VBtn variant="text" size="small" color="primary">
<VIcon icon="tabler-eye" size="18" />
</VBtn>
</Link>
<Link
v-if="invoice.status === 'draft' || invoice.status === 'pending'"
:href="`/invoices/${invoice.id}/edit`"
>
<VBtn variant="text" size="small" color="warning">
<VIcon icon="tabler-edit" size="18" />
</VBtn>
</Link>
<VBtn
v-if="invoice.status !== 'void'"
variant="text"
size="small"
color="info"
:loading="resendingId === invoice.id"
@click="resendInvoice(invoice)"
>
<VIcon icon="tabler-mail-forward" size="18" />
</VBtn>
</Link>
</div>
</td>
</tr>
</tbody>

View File

@@ -41,6 +41,7 @@ interface InvoiceDetail {
gateway: string | null
gateway_invoice_id: string | null
invoice_pdf: string | null
notes: string | null
due_date: string | null
paid_at: string | null
created_at: string
@@ -60,6 +61,11 @@ const props = defineProps<Props>()
const voidDialog = ref<boolean>(false)
const voidForm = useForm({})
const resendForm = useForm({})
const isEditable = computed<boolean>(() => {
return props.invoice.status === 'draft' || props.invoice.status === 'pending'
})
const subtotal = computed<number>(() => {
return props.invoice.items.reduce((sum, item) => {
@@ -74,6 +80,12 @@ function submitVoid(): void {
})
}
function submitResend(): void {
resendForm.post(`/invoices/${props.invoice.id}/resend`, {
preserveScroll: true,
})
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
@@ -119,6 +131,27 @@ function formatDateTime(dateStr: string | null): string {
</div>
<div class="d-flex gap-2">
<Link v-if="isEditable" :href="`/invoices/${invoice.id}/edit`">
<VBtn
color="warning"
variant="tonal"
>
<VIcon icon="tabler-edit" start />
Edit
</VBtn>
</Link>
<VBtn
color="info"
variant="tonal"
:loading="resendForm.processing"
:disabled="resendForm.processing"
@click="submitResend"
>
<VIcon icon="tabler-mail-forward" start />
Resend Email
</VBtn>
<VBtn
color="info"
variant="tonal"
@@ -209,6 +242,19 @@ function formatDateTime(dateStr: string | null): string {
</VCardText>
</VCard>
<!-- Notes Card -->
<VCard v-if="invoice.notes" class="mt-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-notes" size="22" />
<span>Notes</span>
</VCardTitle>
<VCardText>
<div class="text-body-2">
{{ invoice.notes }}
</div>
</VCardText>
</VCard>
<!-- Customer Card -->
<VCard class="mt-4">
<VCardTitle class="d-flex align-center gap-2">

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Link, router } from '@inertiajs/vue3'
import { Link, router, useForm } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import type { PaginatedResponse, StatusColor } from '@/types'
@@ -29,6 +29,7 @@ interface ServiceItem {
domain: string | null
ipv4_address: string | null
created_at: string
deleted_at: string | null
user: ServiceUser | null
plan: ServicePlan | null
}
@@ -37,6 +38,7 @@ interface Filters {
search: string
service_type: string
status: string
show_archived: boolean
}
interface Props {
@@ -51,6 +53,11 @@ const props = defineProps<Props>()
const search = ref<string>(props.filters.search)
const serviceType = ref<string>(props.filters.service_type)
const status = ref<string>(props.filters.status)
const showArchived = ref<boolean>(props.filters.show_archived)
const deleteDialog = ref<boolean>(false)
const serviceToDelete = ref<ServiceItem | null>(null)
const deleteForm = useForm({})
const serviceTypeOptions = [
{ title: 'All Types', value: '' },
@@ -66,6 +73,7 @@ const statusOptions = [
{ title: 'Suspended', value: 'suspended' },
{ title: 'Terminated', value: 'terminated' },
{ title: 'Pending', value: 'pending' },
{ title: 'Failed', value: 'failed' },
]
let searchTimeout: ReturnType<typeof setTimeout> | null = null
@@ -75,6 +83,7 @@ function applyFilters(): void {
search: search.value || undefined,
service_type: serviceType.value || undefined,
status: status.value || undefined,
show_archived: showArchived.value || undefined,
}, {
preserveState: true,
preserveScroll: true,
@@ -86,16 +95,33 @@ watch(search, () => {
searchTimeout = setTimeout(applyFilters, 300)
})
watch([serviceType, status], () => {
watch([serviceType, status, showArchived], () => {
applyFilters()
})
function openDeleteDialog(service: ServiceItem): void {
serviceToDelete.value = service
deleteDialog.value = true
}
function confirmDelete(): void {
if (!serviceToDelete.value) return
deleteForm.delete(`/services/${serviceToDelete.value.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteDialog.value = false
serviceToDelete.value = null
},
})
}
function resolveServiceStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
active: 'success',
suspended: 'warning',
terminated: 'error',
pending: 'info',
failed: 'error',
}
return map[statusVal] ?? 'secondary'
}
@@ -143,7 +169,7 @@ function formatDate(dateStr: string): string {
<VCard class="mb-6">
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VCol cols="12" md="5">
<VTextField
v-model="search"
prepend-inner-icon="tabler-search"
@@ -154,7 +180,7 @@ function formatDate(dateStr: string): string {
@click:clear="search = ''"
/>
</VCol>
<VCol cols="12" sm="6" md="3">
<VCol cols="12" sm="6" md="2">
<VSelect
v-model="serviceType"
:items="serviceTypeOptions"
@@ -163,7 +189,7 @@ function formatDate(dateStr: string): string {
label="Service Type"
/>
</VCol>
<VCol cols="12" sm="6" md="3">
<VCol cols="12" sm="6" md="2">
<VSelect
v-model="status"
:items="statusOptions"
@@ -172,6 +198,15 @@ function formatDate(dateStr: string): string {
label="Status"
/>
</VCol>
<VCol cols="12" sm="6" md="3" class="d-flex align-center">
<VSwitch
v-model="showArchived"
label="Show archived"
density="compact"
hide-details
color="primary"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
@@ -201,7 +236,11 @@ function formatDate(dateStr: string): string {
</tr>
</thead>
<tbody>
<tr v-for="service in services.data" :key="service.id">
<tr
v-for="service in services.data"
:key="service.id"
:class="{ 'opacity-50': service.deleted_at }"
>
<td class="text-body-2 font-weight-medium">
#{{ service.id }}
</td>
@@ -225,6 +264,15 @@ function formatDate(dateStr: string): string {
</td>
<td>
<VChip
v-if="service.deleted_at"
color="secondary"
size="small"
variant="outlined"
>
Archived
</VChip>
<VChip
v-else
:color="resolveServiceStatusColor(service.status)"
size="small"
class="text-capitalize"
@@ -244,6 +292,15 @@ function formatDate(dateStr: string): string {
<VIcon icon="tabler-eye" size="18" />
</VBtn>
</Link>
<VBtn
v-if="!service.deleted_at"
variant="text"
size="small"
color="error"
@click="openDeleteDialog(service)"
>
<VIcon icon="tabler-archive" size="18" />
</VBtn>
</td>
</tr>
</tbody>
@@ -263,5 +320,31 @@ function formatDate(dateStr: string): string {
Showing {{ services.from }} to {{ services.to }} of {{ services.total }} services
</VCardText>
</VCard>
<!-- Delete Confirmation Dialog -->
<VDialog v-model="deleteDialog" max-width="500" persistent>
<VCard>
<VCardTitle class="text-h5 pa-5">
Archive Service
</VCardTitle>
<VCardText class="px-5 pb-2">
Are you sure you want to archive service #{{ serviceToDelete?.id }}? The service will be hidden from the default list but can be restored later.
</VCardText>
<VCardActions class="pa-5">
<VSpacer />
<VBtn variant="text" :disabled="deleteForm.processing" @click="deleteDialog = false">
Cancel
</VBtn>
<VBtn
color="error"
variant="flat"
:loading="deleteForm.processing"
@click="confirmDelete"
>
Archive
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -19,6 +19,13 @@ interface ServicePlan {
billing_cycle: string
}
interface AvailablePlan {
id: number
name: string
price: string
billing_cycle: string
}
interface ProvisioningLogItem {
id: number
action: string
@@ -44,6 +51,7 @@ interface ServiceDetail {
provisioned_at: string | null
suspended_at: string | null
terminated_at: string | null
deleted_at: string | null
created_at: string
updated_at: string
user: ServiceUser | null
@@ -53,6 +61,7 @@ interface ServiceDetail {
interface Props {
service: ServiceDetail
availablePlans: AvailablePlan[]
}
defineOptions({ layout: AdminLayout })
@@ -60,60 +69,108 @@ defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const confirmDialog = ref<boolean>(false)
const confirmAction = ref<'suspend' | 'unsuspend' | 'terminate'>('suspend')
const confirmAction = ref<'suspend' | 'unsuspend' | 'terminate' | 'provision' | 'archive' | 'restore'>('suspend')
const confirmTitle = ref<string>('')
const confirmMessage = ref<string>('')
const confirmColor = ref<string>('warning')
const modifyDialog = ref<boolean>(false)
const suspendForm = useForm({})
const unsuspendForm = useForm({})
const terminateForm = useForm({})
const provisionForm = useForm({})
const archiveForm = useForm({})
const restoreForm = useForm({})
const modifyForm = useForm({
plan_id: props.service.plan?.id ?? null,
notes: '',
})
const isProcessing = computed<boolean>(() =>
suspendForm.processing || unsuspendForm.processing || terminateForm.processing,
suspendForm.processing || unsuspendForm.processing || terminateForm.processing || provisionForm.processing || modifyForm.processing || archiveForm.processing || restoreForm.processing,
)
function openConfirmDialog(action: 'suspend' | 'unsuspend' | 'terminate'): void {
function openConfirmDialog(action: 'suspend' | 'unsuspend' | 'terminate' | 'provision' | 'archive' | 'restore'): void {
confirmAction.value = action
if (action === 'suspend') {
confirmTitle.value = 'Suspend Service'
confirmMessage.value = `Are you sure you want to suspend service #${props.service.id}? The customer will lose access to their service.`
confirmColor.value = 'warning'
} else if (action === 'unsuspend') {
confirmTitle.value = 'Unsuspend Service'
confirmMessage.value = `Are you sure you want to unsuspend service #${props.service.id}? The customer will regain access to their service.`
confirmColor.value = 'success'
} else {
confirmTitle.value = 'Terminate Service'
confirmMessage.value = `Are you sure you want to terminate service #${props.service.id}? This action may be irreversible. The service will be permanently deactivated.`
confirmColor.value = 'error'
const actions: Record<string, { title: string; message: string; color: string }> = {
suspend: {
title: 'Suspend Service',
message: `Are you sure you want to suspend service #${props.service.id}? The customer will lose access to their service.`,
color: 'warning',
},
unsuspend: {
title: 'Unsuspend Service',
message: `Are you sure you want to unsuspend service #${props.service.id}? The customer will regain access to their service.`,
color: 'success',
},
provision: {
title: 'Provision Service',
message: `Are you sure you want to manually provision service #${props.service.id}? This will trigger provisioning on the configured platform.`,
color: 'info',
},
terminate: {
title: 'Terminate Service',
message: `Are you sure you want to terminate service #${props.service.id}? This action may be irreversible. The service will be permanently deactivated.`,
color: 'error',
},
archive: {
title: 'Archive Service',
message: `Are you sure you want to archive service #${props.service.id}? It will be hidden from the default list but can be restored later.`,
color: 'error',
},
restore: {
title: 'Restore Service',
message: `Are you sure you want to restore service #${props.service.id}? It will be visible in the services list again.`,
color: 'success',
},
}
const config = actions[action]
confirmTitle.value = config.title
confirmMessage.value = config.message
confirmColor.value = config.color
confirmDialog.value = true
}
function executeAction(): void {
const action = confirmAction.value
const opts = {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
}
if (action === 'suspend') {
suspendForm.post(`/services/${props.service.id}/suspend`, {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
})
suspendForm.post(`/services/${props.service.id}/suspend`, opts)
} else if (action === 'unsuspend') {
unsuspendForm.post(`/services/${props.service.id}/unsuspend`, {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
})
unsuspendForm.post(`/services/${props.service.id}/unsuspend`, opts)
} else if (action === 'provision') {
provisionForm.post(`/services/${props.service.id}/provision`, opts)
} else if (action === 'archive') {
archiveForm.delete(`/services/${props.service.id}`, opts)
} else if (action === 'restore') {
restoreForm.post(`/services/${props.service.id}/restore`, opts)
} else {
terminateForm.post(`/services/${props.service.id}/terminate`, {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
})
terminateForm.post(`/services/${props.service.id}/terminate`, opts)
}
}
function openModifyDialog(): void {
modifyForm.plan_id = props.service.plan?.id ?? null
modifyForm.notes = ''
modifyDialog.value = true
}
function submitModify(): void {
modifyForm.put(`/services/${props.service.id}`, {
preserveScroll: true,
onSuccess: () => {
modifyDialog.value = false
},
})
}
function resolveServiceStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
active: 'success',
@@ -203,6 +260,14 @@ function formatPrice(price: string | number, cycle?: string): string {
>
{{ formatServiceType(service.service_type) }}
</VChip>
<VChip
v-if="service.deleted_at"
color="secondary"
size="small"
variant="outlined"
>
Archived
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ service.user?.name ?? 'Unknown Customer' }} &middot; {{ service.user?.email ?? '' }}
@@ -211,6 +276,28 @@ function formatPrice(price: string | number, cycle?: string): string {
</div>
<div class="d-flex gap-2">
<VBtn
v-if="!service.provisioned_at && service.status !== 'terminated'"
color="info"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('provision')"
>
<VIcon icon="tabler-rocket" start />
Provision
</VBtn>
<VBtn
v-if="service.status !== 'terminated'"
color="primary"
variant="tonal"
:disabled="isProcessing"
@click="openModifyDialog"
>
<VIcon icon="tabler-edit" start />
Modify Service
</VBtn>
<VBtn
v-if="service.status === 'active'"
color="warning"
@@ -234,7 +321,7 @@ function formatPrice(price: string | number, cycle?: string): string {
</VBtn>
<VBtn
v-if="service.status !== 'terminated'"
v-if="service.status !== 'terminated' && !service.deleted_at"
color="error"
variant="tonal"
:disabled="isProcessing"
@@ -243,6 +330,28 @@ function formatPrice(price: string | number, cycle?: string): string {
<VIcon icon="tabler-trash" start />
Terminate
</VBtn>
<VBtn
v-if="!service.deleted_at"
color="error"
variant="outlined"
:disabled="isProcessing"
@click="openConfirmDialog('archive')"
>
<VIcon icon="tabler-archive" start />
Archive
</VBtn>
<VBtn
v-if="service.deleted_at"
color="success"
variant="flat"
:disabled="isProcessing"
@click="openConfirmDialog('restore')"
>
<VIcon icon="tabler-refresh" start />
Restore
</VBtn>
</div>
</div>
@@ -478,6 +587,69 @@ function formatPrice(price: string | number, cycle?: string): string {
</VTable>
</VCard>
<!-- Modify Service Dialog -->
<VDialog v-model="modifyDialog" max-width="600" persistent>
<VCard>
<VCardTitle class="text-h5 pa-5">
Modify Service #{{ service.id }}
</VCardTitle>
<VCardText class="px-5 pb-2">
<VRow>
<VCol cols="12">
<label class="text-body-2 text-medium-emphasis mb-1 d-block">Change Plan</label>
<VSelect
v-model="modifyForm.plan_id"
:items="availablePlans"
item-title="name"
item-value="id"
:error-messages="modifyForm.errors.plan_id"
placeholder="Select a plan"
variant="outlined"
density="comfortable"
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps">
<template #subtitle>
{{ formatPrice(item.raw.price, item.raw.billing_cycle) }}
</template>
</VListItem>
</template>
</VSelect>
<div class="text-caption text-medium-emphasis mt-1">
Current plan: {{ service.plan?.name ?? 'N/A' }}
</div>
</VCol>
<VCol cols="12">
<label class="text-body-2 text-medium-emphasis mb-1 d-block">Admin Notes (Optional)</label>
<VTextarea
v-model="modifyForm.notes"
:error-messages="modifyForm.errors.notes"
placeholder="Add internal notes about this modification..."
variant="outlined"
density="comfortable"
rows="3"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="pa-5">
<VSpacer />
<VBtn variant="text" :disabled="modifyForm.processing" @click="modifyDialog = false">
Cancel
</VBtn>
<VBtn
color="primary"
variant="flat"
:loading="modifyForm.processing"
@click="submitModify"
>
Update Service
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Confirmation Dialog -->
<VDialog v-model="confirmDialog" max-width="500" persistent>
<VCard>

View File

@@ -15,6 +15,7 @@ interface Props {
api: SettingsGroup
billing: SettingsGroup
notifications: SettingsGroup
discord: SettingsGroup
}
}
@@ -38,10 +39,13 @@ const apiForm = useForm({
group: 'api',
virtfusion_api_url: (props.settings.api.virtfusion_api_url as string) ?? '',
virtfusion_api_token: '',
pterodactyl_api_url: (props.settings.api.pterodactyl_api_url as string) ?? '',
pterodactyl_api_token: '',
synergycp_api_url: (props.settings.api.synergycp_api_url as string) ?? '',
synergycp_api_token: '',
enhance_api_url: (props.settings.api.enhance_api_url as string) ?? '',
enhance_api_token: '',
enhance_organization_id: '',
})
// Billing settings form
@@ -52,21 +56,64 @@ const billingForm = useForm({
suspension_warning_days: (props.settings.billing.suspension_warning_days as string) ?? '3',
auto_terminate_days: (props.settings.billing.auto_terminate_days as string) ?? '14',
bandwidth_overage_rate: (props.settings.billing.bandwidth_overage_rate as string) ?? '0.05',
bandwidth_alert_75: (props.settings.billing.bandwidth_alert_75 as string) === '1',
bandwidth_alert_90: (props.settings.billing.bandwidth_alert_90 as string) === '1',
bandwidth_alert_100: (props.settings.billing.bandwidth_alert_100 as string) === '1',
bandwidth_alert_75_email: (props.settings.billing.bandwidth_alert_75_email as string) === '1',
bandwidth_alert_90_email: (props.settings.billing.bandwidth_alert_90_email as string) === '1',
bandwidth_alert_100_email: (props.settings.billing.bandwidth_alert_100_email as string) === '1',
bandwidth_grace_period_days: (props.settings.billing.bandwidth_grace_period_days as string) ?? '3',
bandwidth_auto_suspend: (props.settings.billing.bandwidth_auto_suspend as string) === '1',
})
// Notifications settings form
const notificationsForm = useForm({
group: 'notifications',
discord_webhook_url: (props.settings.notifications.discord_webhook_url as string) ?? '',
slack_webhook_url: (props.settings.notifications.slack_webhook_url as string) ?? '',
email_from_address: (props.settings.notifications.email_from_address as string) ?? '',
email_from_name: (props.settings.notifications.email_from_name as string) ?? '',
})
// Discord webhooks form
const discordForm = useForm({
group: 'discord',
discord_payment_webhook_url: (props.settings.discord.discord_payment_webhook_url as string) ?? '',
discord_payment_webhook_enabled: (props.settings.discord.discord_payment_webhook_enabled as string) === '1',
discord_provisioning_webhook_url: (props.settings.discord.discord_provisioning_webhook_url as string) ?? '',
discord_provisioning_webhook_enabled: (props.settings.discord.discord_provisioning_webhook_enabled as string) === '1',
discord_support_webhook_url: (props.settings.discord.discord_support_webhook_url as string) ?? '',
discord_support_webhook_enabled: (props.settings.discord.discord_support_webhook_enabled as string) === '1',
discord_system_webhook_url: (props.settings.discord.discord_system_webhook_url as string) ?? '',
discord_system_webhook_enabled: (props.settings.discord.discord_system_webhook_enabled as string) === '1',
})
// Visibility toggles for sensitive API fields
const showVirtfusionToken = ref<boolean>(false)
const showPterodactylToken = ref<boolean>(false)
const showSynergycpToken = ref<boolean>(false)
const showEnhanceToken = ref<boolean>(false)
const showEnhanceOrgId = ref<boolean>(false)
// API connection test state
interface TestResult {
loading: boolean
success: boolean | null
message: string
}
const apiTestResults = ref<Record<string, TestResult>>({
virtfusion: { loading: false, success: null, message: '' },
pterodactyl: { loading: false, success: null, message: '' },
synergycp: { loading: false, success: null, message: '' },
enhance: { loading: false, success: null, message: '' },
})
// Discord webhook test state
const discordTestResults = ref<Record<string, TestResult>>({
payment: { loading: false, success: null, message: '' },
provisioning: { loading: false, success: null, message: '' },
support: { loading: false, success: null, message: '' },
system: { loading: false, success: null, message: '' },
})
const currencyOptions = [
{ title: 'USD - US Dollar', value: 'USD' },
@@ -79,7 +126,8 @@ const currencyOptions = [
const tabItems = [
{ value: 'general', title: 'General', icon: 'tabler-building' },
{ value: 'api', title: 'API Credentials', icon: 'tabler-key' },
{ value: 'billing', title: 'Billing', icon: 'tabler-credit-card' },
{ value: 'discord', title: 'Discord Webhooks', icon: 'tabler-brand-discord' },
{ value: 'billing', title: 'Billing & Bandwidth', icon: 'tabler-credit-card' },
{ value: 'notifications', title: 'Notifications', icon: 'tabler-bell' },
]
@@ -106,6 +154,97 @@ function submitNotifications(): void {
preserveScroll: true,
})
}
function submitDiscord(): void {
discordForm.put('/settings', {
preserveScroll: true,
})
}
async function testApiConnection(provider: string): Promise<void> {
const result = apiTestResults.value[provider]
if (!result) return
result.loading = true
result.success = null
result.message = ''
const urlKey = `${provider}_api_url` as keyof typeof apiForm
const tokenKey = `${provider}_api_token` as keyof typeof apiForm
try {
const response = await fetch('/settings/test-api', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
'Accept': 'application/json',
},
body: JSON.stringify({
provider,
url: apiForm[urlKey] || null,
token: apiForm[tokenKey] || null,
organization_id: provider === 'enhance' ? (apiForm.enhance_organization_id || null) : null,
}),
})
const data = await response.json() as { success: boolean; message: string }
result.success = data.success
result.message = data.message
}
catch (e) {
result.success = false
result.message = 'Network error. Please try again.'
}
finally {
result.loading = false
}
}
async function testDiscordWebhook(channel: string): Promise<void> {
const result = discordTestResults.value[channel]
if (!result) return
result.loading = true
result.success = null
result.message = ''
const urlKey = `discord_${channel}_webhook_url` as keyof typeof discordForm
const webhookUrl = discordForm[urlKey] as string
if (!webhookUrl) {
result.success = false
result.message = 'Please enter a webhook URL first.'
result.loading = false
return
}
try {
const response = await fetch('/settings/test-discord', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
'Accept': 'application/json',
},
body: JSON.stringify({
webhook_url: webhookUrl,
channel,
}),
})
const data = await response.json() as { success: boolean; message: string }
result.success = data.success
result.message = data.message
}
catch (e) {
result.success = false
result.message = 'Network error. Please try again.'
}
finally {
result.loading = false
}
}
</script>
<template>
@@ -200,10 +339,31 @@ function submitNotifications(): void {
<VTabsWindowItem value="api">
<form @submit.prevent="submitApi">
<!-- VirtFusion -->
<div class="text-h6 mb-3">
<VIcon icon="tabler-server" start />
VirtFusion (VPS)
<div class="d-flex align-center mb-3">
<VIcon icon="tabler-server" class="me-2" />
<span class="text-h6">VirtFusion (VPS)</span>
<VSpacer />
<VBtn
size="small"
variant="outlined"
color="info"
:loading="apiTestResults.virtfusion.loading"
@click="testApiConnection('virtfusion')"
>
<VIcon icon="tabler-plug-connected" start />
Test Connection
</VBtn>
</div>
<VAlert
v-if="apiTestResults.virtfusion.message"
:type="apiTestResults.virtfusion.success ? 'success' : 'error'"
variant="tonal"
closable
class="mb-3"
@click:close="apiTestResults.virtfusion.message = ''"
>
{{ apiTestResults.virtfusion.message }}
</VAlert>
<VRow class="mb-4">
<VCol cols="12" md="6">
<AppTextField
@@ -234,11 +394,88 @@ function submitNotifications(): void {
<VDivider class="mb-4" />
<!-- SynergyCP -->
<div class="text-h6 mb-3">
<VIcon icon="tabler-server-2" start />
SynergyCP (Dedicated)
<!-- Pterodactyl -->
<div class="d-flex align-center mb-3">
<VIcon icon="tabler-device-gamepad-2" class="me-2" />
<span class="text-h6">Pterodactyl (Game Servers)</span>
<VSpacer />
<VBtn
size="small"
variant="outlined"
color="info"
:loading="apiTestResults.pterodactyl.loading"
@click="testApiConnection('pterodactyl')"
>
<VIcon icon="tabler-plug-connected" start />
Test Connection
</VBtn>
</div>
<VAlert
v-if="apiTestResults.pterodactyl.message"
:type="apiTestResults.pterodactyl.success ? 'success' : 'error'"
variant="tonal"
closable
class="mb-3"
@click:close="apiTestResults.pterodactyl.message = ''"
>
{{ apiTestResults.pterodactyl.message }}
</VAlert>
<VRow class="mb-4">
<VCol cols="12" md="6">
<AppTextField
v-model="apiForm.pterodactyl_api_url"
label="Panel URL"
placeholder="https://game.ezscale.cloud"
:error-messages="apiForm.errors.pterodactyl_api_url"
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="apiForm.pterodactyl_api_token"
label="API Key"
:type="showPterodactylToken ? 'text' : 'password'"
:placeholder="props.settings.api.pterodactyl_api_token_set ? '******** (key is set, leave blank to keep)' : 'Enter API key'"
:error-messages="apiForm.errors.pterodactyl_api_token"
>
<template #append-inner>
<VIcon
:icon="showPterodactylToken ? 'tabler-eye-off' : 'tabler-eye'"
style="cursor: pointer;"
@click="showPterodactylToken = !showPterodactylToken"
/>
</template>
</AppTextField>
</VCol>
</VRow>
<VDivider class="mb-4" />
<!-- SynergyCP -->
<div class="d-flex align-center mb-3">
<VIcon icon="tabler-server-2" class="me-2" />
<span class="text-h6">SynergyCP (Dedicated)</span>
<VSpacer />
<VBtn
size="small"
variant="outlined"
color="info"
:loading="apiTestResults.synergycp.loading"
@click="testApiConnection('synergycp')"
>
<VIcon icon="tabler-plug-connected" start />
Test Connection
</VBtn>
</div>
<VAlert
v-if="apiTestResults.synergycp.message"
:type="apiTestResults.synergycp.success ? 'success' : 'error'"
variant="tonal"
closable
class="mb-3"
@click:close="apiTestResults.synergycp.message = ''"
>
{{ apiTestResults.synergycp.message }}
</VAlert>
<VRow class="mb-4">
<VCol cols="12" md="6">
<AppTextField
@@ -270,12 +507,33 @@ function submitNotifications(): void {
<VDivider class="mb-4" />
<!-- Enhance -->
<div class="text-h6 mb-3">
<VIcon icon="tabler-world" start />
Enhance (Web Hosting)
<div class="d-flex align-center mb-3">
<VIcon icon="tabler-world" class="me-2" />
<span class="text-h6">Enhance (Web Hosting)</span>
<VSpacer />
<VBtn
size="small"
variant="outlined"
color="info"
:loading="apiTestResults.enhance.loading"
@click="testApiConnection('enhance')"
>
<VIcon icon="tabler-plug-connected" start />
Test Connection
</VBtn>
</div>
<VAlert
v-if="apiTestResults.enhance.message"
:type="apiTestResults.enhance.success ? 'success' : 'error'"
variant="tonal"
closable
class="mb-3"
@click:close="apiTestResults.enhance.message = ''"
>
{{ apiTestResults.enhance.message }}
</VAlert>
<VRow class="mb-4">
<VCol cols="12" md="6">
<VCol cols="12" md="4">
<AppTextField
v-model="apiForm.enhance_api_url"
label="API URL"
@@ -283,7 +541,7 @@ function submitNotifications(): void {
:error-messages="apiForm.errors.enhance_api_url"
/>
</VCol>
<VCol cols="12" md="6">
<VCol cols="12" md="4">
<AppTextField
v-model="apiForm.enhance_api_token"
label="API Token"
@@ -300,6 +558,23 @@ function submitNotifications(): void {
</template>
</AppTextField>
</VCol>
<VCol cols="12" md="4">
<AppTextField
v-model="apiForm.enhance_organization_id"
label="Organization ID"
:type="showEnhanceOrgId ? 'text' : 'password'"
:placeholder="props.settings.api.enhance_organization_id_set ? '******** (ID is set, leave blank to keep)' : 'Enter organization ID'"
:error-messages="apiForm.errors.enhance_organization_id"
>
<template #append-inner>
<VIcon
:icon="showEnhanceOrgId ? 'tabler-eye-off' : 'tabler-eye'"
style="cursor: pointer;"
@click="showEnhanceOrgId = !showEnhanceOrgId"
/>
</template>
</AppTextField>
</VCol>
</VRow>
<VDivider class="mb-4" />
@@ -372,10 +647,248 @@ function submitNotifications(): void {
</form>
</VTabsWindowItem>
<!-- Billing Tab -->
<!-- Discord Webhooks Tab -->
<VTabsWindowItem value="discord">
<form @submit.prevent="submitDiscord">
<VAlert type="info" variant="tonal" class="mb-6">
Configure Discord webhook URLs to receive real-time notifications in your Discord server.
Each channel can be independently enabled or disabled.
</VAlert>
<!-- Payment Notifications -->
<VCard variant="outlined" class="mb-4">
<VCardItem>
<template #prepend>
<VIcon icon="tabler-cash" color="success" />
</template>
<VCardTitle>Payment Notifications</VCardTitle>
<VCardSubtitle>Receive alerts for successful and failed payments</VCardSubtitle>
<template #append>
<div class="d-flex align-center gap-3">
<VBtn
size="small"
variant="outlined"
color="info"
:loading="discordTestResults.payment.loading"
@click="testDiscordWebhook('payment')"
>
<VIcon icon="tabler-send" start />
Test
</VBtn>
<VSwitch
v-model="discordForm.discord_payment_webhook_enabled"
color="primary"
hide-details
inset
/>
</div>
</template>
</VCardItem>
<VCardText>
<VAlert
v-if="discordTestResults.payment.message"
:type="discordTestResults.payment.success ? 'success' : 'error'"
variant="tonal"
closable
class="mb-3"
@click:close="discordTestResults.payment.message = ''"
>
{{ discordTestResults.payment.message }}
</VAlert>
<AppTextField
v-model="discordForm.discord_payment_webhook_url"
label="Webhook URL"
:placeholder="props.settings.discord.discord_payment_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
:error-messages="discordForm.errors.discord_payment_webhook_url"
>
<template #prepend-inner>
<VIcon icon="tabler-brand-discord" />
</template>
</AppTextField>
</VCardText>
</VCard>
<!-- Provisioning Alerts -->
<VCard variant="outlined" class="mb-4">
<VCardItem>
<template #prepend>
<VIcon icon="tabler-server" color="info" />
</template>
<VCardTitle>Provisioning Alerts</VCardTitle>
<VCardSubtitle>Get notified when services are provisioned, suspended, or terminated</VCardSubtitle>
<template #append>
<div class="d-flex align-center gap-3">
<VBtn
size="small"
variant="outlined"
color="info"
:loading="discordTestResults.provisioning.loading"
@click="testDiscordWebhook('provisioning')"
>
<VIcon icon="tabler-send" start />
Test
</VBtn>
<VSwitch
v-model="discordForm.discord_provisioning_webhook_enabled"
color="primary"
hide-details
inset
/>
</div>
</template>
</VCardItem>
<VCardText>
<VAlert
v-if="discordTestResults.provisioning.message"
:type="discordTestResults.provisioning.success ? 'success' : 'error'"
variant="tonal"
closable
class="mb-3"
@click:close="discordTestResults.provisioning.message = ''"
>
{{ discordTestResults.provisioning.message }}
</VAlert>
<AppTextField
v-model="discordForm.discord_provisioning_webhook_url"
label="Webhook URL"
:placeholder="props.settings.discord.discord_provisioning_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
:error-messages="discordForm.errors.discord_provisioning_webhook_url"
>
<template #prepend-inner>
<VIcon icon="tabler-brand-discord" />
</template>
</AppTextField>
</VCardText>
</VCard>
<!-- Support Ticket -->
<VCard variant="outlined" class="mb-4">
<VCardItem>
<template #prepend>
<VIcon icon="tabler-headset" color="warning" />
</template>
<VCardTitle>Support Ticket Notifications</VCardTitle>
<VCardSubtitle>Get notified when tickets are created or updated</VCardSubtitle>
<template #append>
<div class="d-flex align-center gap-3">
<VBtn
size="small"
variant="outlined"
color="info"
:loading="discordTestResults.support.loading"
@click="testDiscordWebhook('support')"
>
<VIcon icon="tabler-send" start />
Test
</VBtn>
<VSwitch
v-model="discordForm.discord_support_webhook_enabled"
color="primary"
hide-details
inset
/>
</div>
</template>
</VCardItem>
<VCardText>
<VAlert
v-if="discordTestResults.support.message"
:type="discordTestResults.support.success ? 'success' : 'error'"
variant="tonal"
closable
class="mb-3"
@click:close="discordTestResults.support.message = ''"
>
{{ discordTestResults.support.message }}
</VAlert>
<AppTextField
v-model="discordForm.discord_support_webhook_url"
label="Webhook URL"
:placeholder="props.settings.discord.discord_support_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
:error-messages="discordForm.errors.discord_support_webhook_url"
>
<template #prepend-inner>
<VIcon icon="tabler-brand-discord" />
</template>
</AppTextField>
</VCardText>
</VCard>
<!-- System Alerts -->
<VCard variant="outlined" class="mb-6">
<VCardItem>
<template #prepend>
<VIcon icon="tabler-alert-triangle" color="error" />
</template>
<VCardTitle>System Alerts</VCardTitle>
<VCardSubtitle>Critical system notifications, errors, and warnings</VCardSubtitle>
<template #append>
<div class="d-flex align-center gap-3">
<VBtn
size="small"
variant="outlined"
color="info"
:loading="discordTestResults.system.loading"
@click="testDiscordWebhook('system')"
>
<VIcon icon="tabler-send" start />
Test
</VBtn>
<VSwitch
v-model="discordForm.discord_system_webhook_enabled"
color="primary"
hide-details
inset
/>
</div>
</template>
</VCardItem>
<VCardText>
<VAlert
v-if="discordTestResults.system.message"
:type="discordTestResults.system.success ? 'success' : 'error'"
variant="tonal"
closable
class="mb-3"
@click:close="discordTestResults.system.message = ''"
>
{{ discordTestResults.system.message }}
</VAlert>
<AppTextField
v-model="discordForm.discord_system_webhook_url"
label="Webhook URL"
:placeholder="props.settings.discord.discord_system_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
:error-messages="discordForm.errors.discord_system_webhook_url"
>
<template #prepend-inner>
<VIcon icon="tabler-brand-discord" />
</template>
</AppTextField>
</VCardText>
</VCard>
<VBtn
type="submit"
color="primary"
:loading="discordForm.processing"
:disabled="discordForm.processing"
>
<VIcon icon="tabler-device-floppy" start />
Save Discord Settings
</VBtn>
</form>
</VTabsWindowItem>
<!-- Billing & Bandwidth Tab -->
<VTabsWindowItem value="billing">
<form @submit.prevent="submitBilling">
<VRow>
<!-- Billing Settings Section -->
<div class="text-h6 mb-3">
<VIcon icon="tabler-credit-card" start />
Billing Configuration
</div>
<VRow class="mb-6">
<VCol cols="12" md="6">
<AppSelect
v-model="billingForm.default_currency"
@@ -385,18 +898,6 @@ function submitNotifications(): void {
/>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="billingForm.bandwidth_overage_rate"
label="Bandwidth Overage Rate ($/GB)"
type="number"
step="0.01"
min="0"
placeholder="0.05"
:error-messages="billingForm.errors.bandwidth_overage_rate"
/>
</VCol>
<VCol cols="12" md="4">
<AppTextField
v-model="billingForm.grace_period_days"
@@ -473,7 +974,7 @@ function submitNotifications(): void {
</VCol>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
<VAlert type="info" variant="tonal">
<strong>Dunning timeline:</strong>
Invoice overdue &rarr; {{ billingForm.grace_period_days || 0 }} days grace period &rarr;
Warning sent &rarr; {{ billingForm.suspension_warning_days || 0 }} days &rarr;
@@ -481,19 +982,210 @@ function submitNotifications(): void {
Service terminated
</VAlert>
</VCol>
</VRow>
<VCol cols="12">
<VBtn
type="submit"
color="primary"
:loading="billingForm.processing"
:disabled="billingForm.processing"
<VDivider class="mb-6" />
<!-- Bandwidth Overage Section -->
<div class="text-h6 mb-3">
<VIcon icon="tabler-chart-arrows" start />
Bandwidth Overage Rates
</div>
<VRow class="mb-4">
<VCol cols="12" md="4">
<AppTextField
v-model="billingForm.bandwidth_overage_rate"
label="Overage Rate ($/GB)"
type="number"
step="0.01"
min="0"
placeholder="0.05"
:error-messages="billingForm.errors.bandwidth_overage_rate"
>
<VIcon icon="tabler-device-floppy" start />
Save Billing Settings
</VBtn>
<template #append-inner>
<VTooltip location="top">
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
/>
</template>
Price charged per GB over the plan's included bandwidth
</VTooltip>
</template>
</AppTextField>
</VCol>
<VCol cols="12" md="4">
<AppTextField
v-model="billingForm.bandwidth_grace_period_days"
label="Grace Period Before Billing (days)"
type="number"
min="0"
max="365"
placeholder="3"
:error-messages="billingForm.errors.bandwidth_grace_period_days"
>
<template #append-inner>
<VTooltip location="top">
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
/>
</template>
Days after overage detected before billing begins
</VTooltip>
</template>
</AppTextField>
</VCol>
<VCol cols="12" md="4">
<div class="d-flex align-center pt-6" style="min-height: 56px;">
<VSwitch
v-model="billingForm.bandwidth_auto_suspend"
label="Auto-suspend on overage"
color="error"
hide-details
inset
/>
<VTooltip location="top">
<template #activator="{ props: tooltipProps }">
<VIcon
v-bind="tooltipProps"
icon="tabler-info-circle"
size="18"
class="ms-2"
/>
</template>
Automatically suspend services that exceed their bandwidth limit
</VTooltip>
</div>
</VCol>
</VRow>
<!-- Alert Thresholds -->
<div class="text-subtitle-1 font-weight-medium mb-3">
Alert Thresholds
</div>
<VCard variant="outlined" class="mb-6">
<VTable>
<thead>
<tr>
<th>Threshold</th>
<th class="text-center">
Alert Enabled
</th>
<th class="text-center">
Email Notification
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="d-flex align-center">
<VChip color="warning" variant="tonal" size="small" class="me-2">
75%
</VChip>
Bandwidth usage at 75%
</div>
</td>
<td class="text-center">
<VSwitch
v-model="billingForm.bandwidth_alert_75"
color="primary"
hide-details
inset
class="d-inline-flex"
/>
</td>
<td class="text-center">
<VSwitch
v-model="billingForm.bandwidth_alert_75_email"
color="primary"
hide-details
inset
:disabled="!billingForm.bandwidth_alert_75"
class="d-inline-flex"
/>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-center">
<VChip color="error" variant="tonal" size="small" class="me-2">
90%
</VChip>
Bandwidth usage at 90%
</div>
</td>
<td class="text-center">
<VSwitch
v-model="billingForm.bandwidth_alert_90"
color="primary"
hide-details
inset
class="d-inline-flex"
/>
</td>
<td class="text-center">
<VSwitch
v-model="billingForm.bandwidth_alert_90_email"
color="primary"
hide-details
inset
:disabled="!billingForm.bandwidth_alert_90"
class="d-inline-flex"
/>
</td>
</tr>
<tr>
<td>
<div class="d-flex align-center">
<VChip color="error" size="small" class="me-2">
100%
</VChip>
Bandwidth limit reached
</div>
</td>
<td class="text-center">
<VSwitch
v-model="billingForm.bandwidth_alert_100"
color="primary"
hide-details
inset
class="d-inline-flex"
/>
</td>
<td class="text-center">
<VSwitch
v-model="billingForm.bandwidth_alert_100_email"
color="primary"
hide-details
inset
:disabled="!billingForm.bandwidth_alert_100"
class="d-inline-flex"
/>
</td>
</tr>
</tbody>
</VTable>
</VCard>
<VBtn
type="submit"
color="primary"
:loading="billingForm.processing"
:disabled="billingForm.processing"
>
<VIcon icon="tabler-device-floppy" start />
Save Billing & Bandwidth Settings
</VBtn>
</form>
</VTabsWindowItem>
@@ -501,32 +1193,6 @@ function submitNotifications(): void {
<VTabsWindowItem value="notifications">
<form @submit.prevent="submitNotifications">
<VRow>
<VCol cols="12" md="6">
<AppTextField
v-model="notificationsForm.discord_webhook_url"
label="Discord Webhook URL"
placeholder="https://discord.com/api/webhooks/..."
:error-messages="notificationsForm.errors.discord_webhook_url"
>
<template #prepend-inner>
<VIcon icon="tabler-brand-discord" />
</template>
</AppTextField>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="notificationsForm.slack_webhook_url"
label="Slack Webhook URL (Optional)"
placeholder="https://hooks.slack.com/services/..."
:error-messages="notificationsForm.errors.slack_webhook_url"
>
<template #prepend-inner>
<VIcon icon="tabler-brand-slack" />
</template>
</AppTextField>
</VCol>
<VCol cols="12" md="6">
<AppTextField
v-model="notificationsForm.email_from_address"
@@ -546,6 +1212,20 @@ function submitNotifications(): void {
/>
</VCol>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
Discord webhook notifications are configured in the
<a
href="#"
class="text-primary font-weight-medium"
@click.prevent="activeTab = 'discord'"
>
Discord Webhooks
</a>
tab.
</VAlert>
</VCol>
<VCol cols="12">
<VBtn
type="submit"

View File

@@ -1,17 +1,27 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useForm, Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import type { PaymentMethod } from '@/types'
import { loadStripe, type Stripe, type StripeElements, type StripeCardElement } from '@stripe/stripe-js'
interface Props {
paymentMethods: PaymentMethod[]
defaultPaymentMethod: string | null
intent: { client_secret: string }
stripeKey: string
}
defineOptions({ layout: AccountLayout })
defineProps<Props>()
const props = defineProps<Props>()
let stripe: Stripe | null = null
let elements: StripeElements | null = null
let cardElement: StripeCardElement | null = null
const isAddingCard = ref(false)
const addCardError = ref<string | null>(null)
const defaultForm = useForm({
payment_method_id: '',
@@ -61,6 +71,89 @@ function resolveBrandIcon(brand: string): string {
return brandMap[brand.toLowerCase()] || 'tabler-credit-card'
}
async function handleAddCard(): Promise<void> {
if (!stripe || !cardElement) {
return
}
isAddingCard.value = true
addCardError.value = null
try {
const { setupIntent, error } = await stripe.confirmCardSetup(props.intent.client_secret, {
payment_method: {
card: cardElement,
},
})
if (error) {
addCardError.value = error.message || 'An error occurred while adding your card.'
isAddingCard.value = false
return
}
if (setupIntent?.payment_method) {
// Submit the payment method ID to the backend
const addForm = useForm({
payment_method_id: setupIntent.payment_method as string,
})
addForm.post('/billing/payment-methods', {
onSuccess: () => {
// Reset the card element
cardElement?.clear()
},
onError: () => {
addCardError.value = 'Failed to save payment method.'
},
onFinish: () => {
isAddingCard.value = false
},
})
}
}
catch (err) {
console.error('Error adding card:', err)
addCardError.value = 'An unexpected error occurred.'
isAddingCard.value = false
}
}
onMounted(async () => {
// Load Stripe.js
stripe = await loadStripe(props.stripeKey)
if (!stripe) {
console.error('Failed to load Stripe')
return
}
// Create elements
elements = stripe.elements()
// Create card element
cardElement = elements.create('card', {
style: {
base: {
fontSize: '16px',
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
'::placeholder': {
color: 'rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))',
},
},
invalid: {
color: 'rgb(var(--v-theme-error))',
},
},
})
// Mount the card element
const cardElementContainer = document.getElementById('card-element')
if (cardElementContainer) {
cardElement.mount(cardElementContainer)
}
})
</script>
<template>
@@ -149,16 +242,32 @@ function resolveBrandIcon(brand: string): string {
</VCardText>
</VCard>
<!-- Add New Card Info -->
<!-- Add New Card -->
<VCard>
<VCardTitle>Add a New Card</VCardTitle>
<VCardText>
<VAlert type="info" variant="tonal">
<div class="text-body-2">
To add a new payment method, please use the checkout flow when purchasing a new plan,
or contact our support team for assistance.
</div>
<div class="mb-4">
<label class="text-body-2 text-medium-emphasis d-block mb-2">Card Information</label>
<div
id="card-element"
class="pa-3 rounded border"
style="min-height: 40px;"
/>
</div>
<VAlert v-if="addCardError" type="error" variant="tonal" class="mb-4">
{{ addCardError }}
</VAlert>
<VBtn
color="primary"
:loading="isAddingCard"
:disabled="isAddingCard"
@click="handleAddCard"
>
<VIcon icon="tabler-plus" start />
Add Card
</VBtn>
</VCardText>
</VCard>

File diff suppressed because it is too large Load Diff

View File

@@ -198,7 +198,7 @@ const unpaidInvoices = computed<Invoice[]>(() => {
</template>
<VListItemTitle class="font-weight-semibold">
{{ subscription.plan?.name || subscription.type }}
{{ subscription.plan_name || subscription.type }}
</VListItemTitle>
<VListItemSubtitle>
@@ -211,10 +211,10 @@ const unpaidInvoices = computed<Invoice[]>(() => {
{{ subscription.stripe_status }}
</VChip>
<span
v-if="subscription.plan"
v-if="subscription.plan_price"
class="text-body-2"
>
{{ formatPrice(subscription.plan.price, subscription.plan.billing_cycle) }}
{{ formatPrice(subscription.plan_price, subscription.plan_billing_cycle) }}
</span>
</div>
<div

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { Link } from '@inertiajs/vue3'
import { ref, computed } from 'vue'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { formatPrice } from '@/utils/resolvers'
import type { Plan } from '@/types'
interface Props {
@@ -10,83 +10,482 @@ interface Props {
defineOptions({ layout: AccountLayout })
defineProps<Props>()
const props = defineProps<Props>()
const serviceTypeLabels: Record<string, string> = {
vps: 'VPS Servers',
dedicated: 'Dedicated Servers',
hosting: 'Web Hosting',
game: 'Game Servers',
// Active service type tab
const serviceTypes = computed(() => Object.keys(props.plansByType))
const activeTab = ref<string>(serviceTypes.value[0] || 'vps')
const serviceTypeMeta: Record<string, { label: string; icon: string; description: string }> = {
vps: { label: 'VPS Servers', icon: 'tabler-server', description: 'High-performance NVMe virtual private servers' },
dedicated: { label: 'Dedicated Servers', icon: 'tabler-server-cog', description: 'Bare-metal servers with full hardware access' },
hosting: { label: 'Web Hosting', icon: 'tabler-world', description: 'Managed web hosting with cPanel alternative' },
mysql: { label: 'MySQL Hosting', icon: 'tabler-database', description: 'Managed MySQL database hosting' },
game: { label: 'Game Servers', icon: 'tabler-device-gamepad-2', description: 'Game server hosting with instant setup' },
}
// Billing cycle toggle
const billingCycle = ref<'monthly' | 'quarterly' | 'semi_annual' | 'annual'>('monthly')
const cycles = [
{ value: 'monthly' as const, label: 'Monthly', months: 1, discount: 0 },
{ value: 'quarterly' as const, label: 'Quarterly', months: 3, discount: 0.05 },
{ value: 'semi_annual' as const, label: 'Semi-Annual', months: 6, discount: 0.10 },
{ value: 'annual' as const, label: 'Annual', months: 12, discount: 0.15 },
]
const activeCycle = computed(() => cycles.find(c => c.value === billingCycle.value) || cycles[0])
function cyclePrice(basePrice: string): number {
const monthly = parseFloat(basePrice)
return monthly * (1 - activeCycle.value.discount) * activeCycle.value.months
}
function effectiveMonthly(basePrice: string): number {
const monthly = parseFloat(basePrice)
return monthly * (1 - activeCycle.value.discount)
}
// Popular plan slugs
const popularSlugs = new Set(['vps-mini', 'hosting-medium', 'mysql-silver'])
const bestValueSlugs = new Set(['vps-standard'])
function isPlanPopular(plan: Plan): boolean {
return popularSlugs.has(plan.slug)
}
function isPlanBestValue(plan: Plan): boolean {
return bestValueSlugs.has(plan.slug)
}
// Feature display config per service type
interface FeatureDisplay {
key: string
label: string
icon: string
}
const featureConfig: Record<string, FeatureDisplay[]> = {
vps: [
{ key: 'cpu', label: 'CPU', icon: 'tabler-cpu' },
{ key: 'ram', label: 'RAM', icon: 'tabler-brand-stackoverflow' },
{ key: 'storage', label: 'Storage', icon: 'tabler-database' },
{ key: 'bandwidth', label: 'Bandwidth', icon: 'tabler-transfer' },
{ key: 'ipv4', label: 'IPv4', icon: 'tabler-network' },
{ key: 'os', label: 'OS', icon: 'tabler-brand-ubuntu' },
],
dedicated: [
{ key: 'cpu', label: 'CPU', icon: 'tabler-cpu' },
{ key: 'cores', label: 'Cores', icon: 'tabler-cpu-2' },
{ key: 'ram', label: 'RAM', icon: 'tabler-brand-stackoverflow' },
{ key: 'storage_bays', label: 'Storage Bays', icon: 'tabler-database' },
{ key: 'bandwidth', label: 'Bandwidth', icon: 'tabler-transfer' },
{ key: 'ipv4', label: 'IPv4', icon: 'tabler-network' },
],
hosting: [
{ key: 'storage', label: 'Storage', icon: 'tabler-database' },
{ key: 'domains', label: 'Domains', icon: 'tabler-world' },
{ key: 'email', label: 'Email', icon: 'tabler-mail' },
{ key: 'databases', label: 'Databases', icon: 'tabler-database' },
{ key: 'bandwidth', label: 'Bandwidth', icon: 'tabler-transfer' },
{ key: 'ssl', label: 'SSL', icon: 'tabler-lock' },
],
mysql: [
{ key: 'storage', label: 'Storage', icon: 'tabler-database' },
{ key: 'backups', label: 'Backups', icon: 'tabler-cloud-upload' },
{ key: 'ssl', label: 'Security', icon: 'tabler-lock' },
],
game: [
{ key: 'cpu', label: 'CPU', icon: 'tabler-cpu' },
{ key: 'ram', label: 'RAM', icon: 'tabler-brand-stackoverflow' },
{ key: 'storage', label: 'Storage', icon: 'tabler-database' },
{ key: 'bandwidth', label: 'Bandwidth', icon: 'tabler-transfer' },
],
}
function getFeaturesForType(type: string): FeatureDisplay[] {
return featureConfig[type] || featureConfig.vps
}
function isOutOfStock(plan: Plan): boolean {
return plan.stock_quantity !== null && plan.stock_quantity <= 0
}
function stockLabel(plan: Plan): string | null {
if (plan.stock_quantity === null) return null
if (plan.stock_quantity <= 0) return 'Out of Stock'
if (plan.stock_quantity <= 3) return `${plan.stock_quantity} left`
return null
}
</script>
<template>
<div>
<div class="text-h4 font-weight-bold mb-6">Plans &amp; Pricing</div>
<div v-for="(plans, type) in plansByType" :key="type" class="mb-10">
<div class="text-h5 font-weight-medium mb-4">
{{ serviceTypeLabels[type as string] || type }}
<!-- Header -->
<div class="text-center mb-8">
<div class="text-h4 font-weight-bold mb-2">
Plans & Pricing
</div>
<div class="text-body-1 text-medium-emphasis mx-auto" style="max-width: 560px;">
Choose the perfect plan for your needs. All plans include 24/7 support and 99.9% uptime guarantee.
</div>
<VRow>
<VCol
v-for="plan in plans"
:key="plan.id"
cols="12"
md="6"
lg="4"
>
<VCard class="d-flex flex-column h-100">
<VCardTitle>{{ plan.name }}</VCardTitle>
<VCardText v-if="plan.description" class="text-medium-emphasis">
{{ plan.description }}
</VCardText>
<VCardText>
<div class="text-h4 font-weight-bold">
{{ formatPrice(plan.price, plan.billing_cycle) }}
</div>
</VCardText>
<VCardText v-if="plan.features" class="flex-grow-1">
<VList density="compact" class="pa-0">
<VListItem
v-for="(value, feature) in plan.features"
:key="feature as string"
class="px-0"
>
<template #prepend>
<VIcon icon="tabler-check" color="success" size="18" class="me-2" />
</template>
<VListItemTitle class="text-body-2">
<span class="font-weight-medium">{{ feature }}:</span> {{ value }}
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
<VCardActions class="pa-4">
<span v-if="plan.stock_quantity !== null && plan.stock_quantity <= 0" class="text-body-2 font-weight-medium text-error w-100 text-center">
Out of Stock
</span>
<Link
v-else
:href="`/checkout/${plan.id}`"
class="text-decoration-none w-100"
>
<VBtn block>
Order Now
</VBtn>
</Link>
</VCardActions>
</VCard>
</VCol>
</VRow>
</div>
<div v-if="!plansByType || Object.keys(plansByType).length === 0" class="text-center py-12">
<div class="text-medium-emphasis">No plans are currently available.</div>
<!-- Billing Cycle Toggle -->
<div class="d-flex justify-center mb-8">
<div class="cycle-toggle-wrapper">
<div
v-for="cycle in cycles"
:key="cycle.value"
class="cycle-option"
:class="{ 'cycle-option--active': billingCycle === cycle.value }"
@click="billingCycle = cycle.value"
>
<span class="cycle-option__label">{{ cycle.label }}</span>
<span v-if="cycle.discount > 0" class="cycle-option__discount">
Save {{ (cycle.discount * 100).toFixed(0) }}%
</span>
</div>
</div>
</div>
<!-- Service Type Tabs -->
<VTabs
v-model="activeTab"
color="primary"
class="mb-6"
show-arrows
>
<VTab
v-for="type in serviceTypes"
:key="type"
:value="type"
>
<VIcon :icon="serviceTypeMeta[type]?.icon || 'tabler-server'" size="20" start />
{{ serviceTypeMeta[type]?.label || type }}
</VTab>
</VTabs>
<!-- Plans Grid -->
<VWindow v-model="activeTab">
<VWindowItem
v-for="(plans, type) in plansByType"
:key="type"
:value="type"
>
<!-- Type description -->
<div v-if="serviceTypeMeta[type as string]?.description" class="text-body-2 text-medium-emphasis mb-5">
{{ serviceTypeMeta[type as string].description }}
</div>
<VRow>
<VCol
v-for="plan in plans"
:key="plan.id"
cols="12"
sm="6"
:lg="plans.length <= 3 ? 4 : 3"
>
<VCard
class="plan-card d-flex flex-column h-100"
:class="{
'plan-card--popular': isPlanPopular(plan),
'plan-card--best-value': isPlanBestValue(plan),
'plan-card--out-of-stock': isOutOfStock(plan),
}"
:elevation="isPlanPopular(plan) || isPlanBestValue(plan) ? 4 : 1"
>
<!-- Badge -->
<div v-if="isPlanPopular(plan)" class="plan-badge plan-badge--popular">
Most Popular
</div>
<div v-else-if="isPlanBestValue(plan)" class="plan-badge plan-badge--best-value">
Best Value
</div>
<!-- Stock indicator -->
<div v-if="stockLabel(plan)" class="stock-indicator" :class="{ 'stock-indicator--out': isOutOfStock(plan) }">
<VIcon :icon="isOutOfStock(plan) ? 'tabler-alert-circle' : 'tabler-flame'" size="14" class="me-1" />
{{ stockLabel(plan) }}
</div>
<VCardText class="pa-5 pb-0">
<!-- Plan name -->
<div class="text-h6 font-weight-bold mb-1">
{{ plan.name }}
</div>
<div v-if="plan.description" class="text-caption text-medium-emphasis mb-4" style="min-height: 32px;">
{{ plan.description }}
</div>
<!-- Price -->
<div class="price-block mb-4">
<div class="d-flex align-end gap-1">
<span class="text-h4 font-weight-bold price-amount">
${{ effectiveMonthly(plan.price).toFixed(2) }}
</span>
<span class="text-body-2 text-medium-emphasis pb-1">/mo</span>
</div>
<div v-if="billingCycle !== 'monthly'" class="text-caption text-medium-emphasis mt-1">
${{ cyclePrice(plan.price).toFixed(2) }} billed {{ activeCycle.label.toLowerCase() }}
</div>
<div v-if="activeCycle.discount > 0" class="text-caption text-success mt-1">
Save ${{ (parseFloat(plan.price) * activeCycle.discount * activeCycle.months).toFixed(2) }}
</div>
</div>
<VDivider class="mb-4" />
<!-- Features -->
<div class="features-list">
<div
v-for="feat in getFeaturesForType(type as string)"
:key="feat.key"
class="feature-row"
>
<VIcon :icon="feat.icon" size="18" color="primary" class="feature-icon" />
<span class="text-body-2">
<span class="text-medium-emphasis">{{ feat.label }}:</span>
<span class="font-weight-medium ms-1">{{ plan.features?.[feat.key] || '---' }}</span>
</span>
</div>
</div>
</VCardText>
<VSpacer />
<!-- CTA -->
<VCardActions class="pa-5 pt-4">
<span
v-if="isOutOfStock(plan)"
class="text-body-2 font-weight-medium text-error w-100 text-center"
>
Out of Stock
</span>
<Link
v-else
:href="`/checkout/${plan.id}`"
class="text-decoration-none w-100"
>
<VBtn
block
:color="isPlanPopular(plan) || isPlanBestValue(plan) ? 'primary' : undefined"
:variant="isPlanPopular(plan) || isPlanBestValue(plan) ? 'flat' : 'outlined'"
size="large"
class="order-btn"
>
<VIcon icon="tabler-shopping-cart" start size="18" />
Order Now
</VBtn>
</Link>
</VCardActions>
</VCard>
</VCol>
</VRow>
</VWindowItem>
</VWindow>
<!-- Empty state -->
<div v-if="!plansByType || Object.keys(plansByType).length === 0" class="text-center py-16">
<VIcon icon="tabler-package-off" size="64" color="disabled" class="mb-4" />
<div class="text-h6 text-medium-emphasis mb-2">
No plans available
</div>
<div class="text-body-2 text-medium-emphasis">
Plans are being configured. Please check back soon.
</div>
</div>
</div>
</template>
<style scoped>
/* Cycle toggle */
.cycle-toggle-wrapper {
display: inline-flex;
gap: 4px;
padding: 4px;
border-radius: 14px;
background: rgba(var(--v-theme-on-surface), 0.06);
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.cycle-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 10px 24px;
border-radius: 10px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
position: relative;
}
.cycle-option:hover:not(.cycle-option--active) {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.cycle-option--active {
background: rgb(var(--v-theme-primary));
box-shadow: 0 4px 14px rgba(115, 103, 240, 0.4);
}
.cycle-option__label {
font-size: 0.875rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), 0.7);
white-space: nowrap;
line-height: 1.2;
}
.cycle-option--active .cycle-option__label {
color: #fff;
}
.cycle-option__discount {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.3px;
color: rgb(var(--v-theme-success));
line-height: 1;
}
.cycle-option--active .cycle-option__discount {
color: rgba(255, 255, 255, 0.85);
}
@media (max-width: 600px) {
.cycle-toggle-wrapper {
flex-wrap: wrap;
justify-content: center;
}
.cycle-option {
padding: 8px 16px;
}
}
/* Plan cards */
.plan-card {
position: relative;
border-radius: 16px;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: visible;
}
.plan-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.12) !important;
}
.plan-card--popular {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
}
.plan-card--popular:hover {
box-shadow: 0 16px 40px rgba(115, 103, 240, 0.2) !important;
}
.plan-card--best-value {
border-color: rgb(var(--v-theme-success));
border-width: 2px;
}
.plan-card--best-value:hover {
box-shadow: 0 16px 40px rgba(40, 199, 111, 0.15) !important;
}
.plan-card--out-of-stock {
opacity: 0.6;
}
.plan-card--out-of-stock:hover {
transform: none;
}
/* Badges */
.plan-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
padding: 4px 16px;
border-radius: 20px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
white-space: nowrap;
z-index: 1;
}
.plan-badge--popular {
background: rgb(var(--v-theme-primary));
color: white;
box-shadow: 0 4px 12px rgba(115, 103, 240, 0.4);
}
.plan-badge--best-value {
background: rgb(var(--v-theme-success));
color: white;
box-shadow: 0 4px 12px rgba(40, 199, 111, 0.4);
}
/* Stock indicator */
.stock-indicator {
position: absolute;
top: 12px;
right: 12px;
padding: 2px 8px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
background: rgba(var(--v-theme-warning), 0.15);
color: rgb(var(--v-theme-warning));
display: flex;
align-items: center;
}
.stock-indicator--out {
background: rgba(var(--v-theme-error), 0.15);
color: rgb(var(--v-theme-error));
}
/* Price */
.price-amount {
color: rgb(var(--v-theme-primary));
line-height: 1;
}
/* Features */
.features-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.feature-row {
display: flex;
align-items: center;
gap: 10px;
}
.feature-icon {
flex-shrink: 0;
}
/* Order button */
.order-btn {
font-weight: 600;
letter-spacing: 0.3px;
transition: all 0.2s ease;
}
.order-btn:hover {
transform: translateY(-1px);
}
</style>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { resolveServiceStatusColor, resolveServiceTypeColor, formatPrice } from '@/utils/resolvers'
@@ -10,67 +11,450 @@ interface Props {
defineOptions({ layout: AccountLayout })
defineProps<Props>()
const props = defineProps<Props>()
const viewMode = ref<'grid' | 'list'>('grid')
function formatDate(dateStr: string | null): string {
if (!dateStr) return '--'
return new Date(dateStr).toLocaleDateString()
}
const serviceTypeIcon = (type: string): string => {
const icons: Record<string, string> = {
vps: 'tabler-server',
dedicated: 'tabler-server-2',
'game-server': 'tabler-device-gamepad-2',
'web-hosting': 'tabler-world-www',
}
return icons[type] ?? 'tabler-server'
}
const serviceCounts = computed(() => {
const counts = {
total: props.services.length,
active: props.services.filter(s => s.status === 'active').length,
suspended: props.services.filter(s => s.status === 'suspended').length,
pending: props.services.filter(s => s.status === 'pending').length,
}
return counts
})
</script>
<template>
<div>
<div class="d-flex align-center justify-space-between mb-6">
<div class="text-h4 font-weight-bold">
Services
</div>
<Link
href="/plans"
class="text-decoration-none"
>
<VBtn>
<VIcon
icon="tabler-plus"
start
/>
Order New Service
</VBtn>
</Link>
</div>
<VCard v-if="services.length === 0">
<VCardText class="text-center py-12">
<VIcon
icon="tabler-server-off"
size="48"
class="text-medium-emphasis mb-4"
/>
<div class="text-h6 text-medium-emphasis mb-2">
No services yet
</div>
<div class="text-body-2 text-medium-emphasis mb-4">
You don't have any services. Browse our plans to get started.
<!-- Header -->
<div class="mb-8">
<div class="d-flex flex-wrap align-center justify-space-between gap-4 mb-4">
<div>
<h1 class="text-h3 font-weight-bold mb-2" style="font-family: 'DM Sans', sans-serif; letter-spacing: -0.02em;">
Your Services
</h1>
<p class="text-body-1 text-medium-emphasis">
Manage your hosting services and server infrastructure
</p>
</div>
<Link
href="/plans"
class="text-decoration-none"
>
<VBtn>Browse Plans</VBtn>
<VBtn
size="large"
color="primary"
class="text-none font-weight-semibold px-6"
elevation="0"
>
<VIcon
icon="tabler-plus"
start
size="20"
/>
Order New Service
</VBtn>
</Link>
</div>
<!-- Stats Overview -->
<VRow v-if="services.length > 0">
<VCol
cols="6"
md="3"
>
<VCard
class="stat-card"
:style="{
borderLeft: '3px solid rgb(var(--v-theme-primary))',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}"
elevation="0"
>
<VCardText class="pa-4">
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold mb-1">
{{ serviceCounts.total }}
</div>
<div class="text-body-2 text-medium-emphasis">
Total Services
</div>
</div>
<VAvatar
color="primary"
variant="tonal"
size="48"
>
<VIcon
icon="tabler-server-2"
size="24"
/>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="6"
md="3"
>
<VCard
class="stat-card"
:style="{
borderLeft: '3px solid rgb(var(--v-theme-success))',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}"
elevation="0"
>
<VCardText class="pa-4">
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold mb-1">
{{ serviceCounts.active }}
</div>
<div class="text-body-2 text-medium-emphasis">
Active
</div>
</div>
<VAvatar
color="success"
variant="tonal"
size="48"
>
<VIcon
icon="tabler-circle-check"
size="24"
/>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="6"
md="3"
>
<VCard
class="stat-card"
:style="{
borderLeft: '3px solid rgb(var(--v-theme-warning))',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}"
elevation="0"
>
<VCardText class="pa-4">
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold mb-1">
{{ serviceCounts.pending }}
</div>
<div class="text-body-2 text-medium-emphasis">
Pending
</div>
</div>
<VAvatar
color="warning"
variant="tonal"
size="48"
>
<VIcon
icon="tabler-clock"
size="24"
/>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="6"
md="3"
>
<VCard
class="stat-card"
:style="{
borderLeft: '3px solid rgb(var(--v-theme-error))',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}"
elevation="0"
>
<VCardText class="pa-4">
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold mb-1">
{{ serviceCounts.suspended }}
</div>
<div class="text-body-2 text-medium-emphasis">
Suspended
</div>
</div>
<VAvatar
color="error"
variant="tonal"
size="48"
>
<VIcon
icon="tabler-alert-circle"
size="24"
/>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
<!-- View Mode Toggle -->
<div
v-if="services.length > 0"
class="d-flex justify-end mb-6"
>
<VBtnToggle
v-model="viewMode"
mandatory
variant="outlined"
divided
>
<VBtn value="grid">
<VIcon icon="tabler-layout-grid" />
</VBtn>
<VBtn value="list">
<VIcon icon="tabler-list" />
</VBtn>
</VBtnToggle>
</div>
<!-- Empty State -->
<VCard
v-if="services.length === 0"
class="empty-state"
elevation="0"
>
<VCardText class="text-center pa-12">
<div class="empty-icon-wrapper mb-6">
<VIcon
icon="tabler-server-off"
size="64"
class="text-medium-emphasis"
style="opacity: 0.5;"
/>
</div>
<h3 class="text-h4 font-weight-bold mb-3" style="font-family: 'DM Sans', sans-serif;">
No Services Yet
</h3>
<p class="text-body-1 text-medium-emphasis mb-6" style="max-width: 480px; margin-left: auto; margin-right: auto;">
Your services will appear here once you've ordered them. Browse our plans to get started with powerful hosting solutions.
</p>
<Link
href="/plans"
class="text-decoration-none"
>
<VBtn
size="large"
color="primary"
class="text-none px-8"
>
<VIcon
icon="tabler-shopping-cart"
start
/>
Browse Plans
</VBtn>
</Link>
</VCardText>
</VCard>
<VCard v-else>
<!-- Grid View -->
<VRow v-if="services.length > 0 && viewMode === 'grid'">
<VCol
v-for="(service, index) in services"
:key="service.id"
cols="12"
md="6"
lg="4"
>
<VCard
class="service-card h-100"
elevation="0"
:style="{
animationDelay: `${index * 50}ms`,
}"
>
<!-- Card Header with gradient overlay -->
<div
class="service-card-header pa-4"
:style="{
background: `linear-gradient(135deg, rgb(var(--v-theme-${resolveServiceTypeColor(service.service_type)})) 0%, rgb(var(--v-theme-${resolveServiceTypeColor(service.service_type)}-darken-1)) 100%)`,
position: 'relative',
overflow: 'hidden',
}"
>
<div
style="position: absolute; top: 0; right: 0; width: 100px; height: 100px; background: rgba(255,255,255,0.1); border-radius: 50%; transform: translate(30%, -30%);"
/>
<div class="d-flex align-center justify-space-between position-relative">
<div class="d-flex align-center gap-3">
<VAvatar
:color="resolveServiceTypeColor(service.service_type)"
variant="flat"
size="48"
style="background: rgba(255,255,255,0.2);"
>
<VIcon
:icon="serviceTypeIcon(service.service_type)"
size="24"
color="white"
/>
</VAvatar>
<div>
<div class="text-body-2 text-white" style="opacity: 0.9;">
{{ service.plan?.name || 'Service' }}
</div>
<div class="text-caption text-white" style="opacity: 0.7;">
{{ service.platform }}
</div>
</div>
</div>
<VChip
:color="resolveServiceStatusColor(service.status)"
size="small"
class="text-capitalize status-pulse"
>
{{ service.status }}
</VChip>
</div>
</div>
<!-- Card Body -->
<VCardText class="pa-4">
<h3 class="text-h6 font-weight-bold mb-3" style="font-family: 'DM Sans', sans-serif;">
{{ service.hostname || service.domain || `Service #${service.id}` }}
</h3>
<div class="service-details mb-4">
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2 text-medium-emphasis">
<VIcon
icon="tabler-network"
size="16"
class="me-1"
/>
IP Address
</span>
<span class="text-body-2 font-weight-medium">
<code v-if="service.ipv4_address">{{ service.ipv4_address }}</code>
<span
v-else
class="text-medium-emphasis"
>Pending</span>
</span>
</div>
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2 text-medium-emphasis">
<VIcon
icon="tabler-coin"
size="16"
class="me-1"
/>
Price
</span>
<span class="text-body-2 font-weight-medium">
{{ service.plan ? formatPrice(service.plan.price, service.plan.billing_cycle) : '--' }}
</span>
</div>
<div class="d-flex align-center justify-space-between">
<span class="text-body-2 text-medium-emphasis">
<VIcon
icon="tabler-calendar"
size="16"
class="me-1"
/>
Next Renewal
</span>
<span class="text-body-2 font-weight-medium">
{{ service.subscription?.current_period_end ? formatDate(service.subscription.current_period_end) : '--' }}
</span>
</div>
</div>
<VDivider class="my-4" />
<Link
:href="`/services/${service.id}`"
class="text-decoration-none"
>
<VBtn
block
color="primary"
variant="flat"
class="text-none"
>
<VIcon
icon="tabler-settings"
start
size="20"
/>
Manage Service
<VIcon
icon="tabler-arrow-right"
end
size="18"
/>
</VBtn>
</Link>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- List View -->
<VCard
v-if="services.length > 0 && viewMode === 'list'"
elevation="0"
>
<VTable hover>
<thead>
<tr>
<th>Service</th>
<th>Plan</th>
<th>Type</th>
<th>Status</th>
<th>IP Address</th>
<th>Renewal Date</th>
<th class="text-end">
<th class="font-weight-bold">
Service
</th>
<th class="font-weight-bold">
Plan
</th>
<th class="font-weight-bold">
Type
</th>
<th class="font-weight-bold">
Status
</th>
<th class="font-weight-bold">
IP Address
</th>
<th class="font-weight-bold">
Renewal Date
</th>
<th class="text-end font-weight-bold">
Actions
</th>
</tr>
@@ -79,17 +463,34 @@ function formatDate(dateStr: string | null): string {
<tr
v-for="service in services"
:key="service.id"
class="service-row"
>
<td>
<div class="font-weight-medium">
{{ service.hostname || service.domain || `Service #${service.id}` }}
</div>
<div class="text-body-2 text-medium-emphasis">
{{ service.platform }}
<div class="d-flex align-center gap-3">
<VAvatar
:color="resolveServiceTypeColor(service.service_type)"
variant="tonal"
size="36"
>
<VIcon
:icon="serviceTypeIcon(service.service_type)"
size="20"
/>
</VAvatar>
<div>
<div class="font-weight-medium">
{{ service.hostname || service.domain || `Service #${service.id}` }}
</div>
<div class="text-body-2 text-medium-emphasis">
{{ service.platform }}
</div>
</div>
</div>
</td>
<td>
{{ service.plan?.name || '--' }}
<div class="font-weight-medium">
{{ service.plan?.name || '--' }}
</div>
<div
v-if="service.plan"
class="text-body-2 text-medium-emphasis"
@@ -110,20 +511,20 @@ function formatDate(dateStr: string | null): string {
<VChip
:color="resolveServiceStatusColor(service.status)"
size="small"
class="text-capitalize"
class="text-capitalize status-pulse"
>
{{ service.status }}
</VChip>
</td>
<td>
<span v-if="service.ipv4_address">{{ service.ipv4_address }}</span>
<code v-if="service.ipv4_address">{{ service.ipv4_address }}</code>
<span
v-else
class="text-medium-emphasis"
>--</span>
</td>
<td>
{{ service.subscription?.current_period_end ? formatDate(service.subscription.current_period_end) : formatDate(null) }}
{{ service.subscription?.current_period_end ? formatDate(service.subscription.current_period_end) : '--' }}
</td>
<td class="text-end">
<Link
@@ -133,10 +534,13 @@ function formatDate(dateStr: string | null): string {
<VBtn
variant="tonal"
size="small"
color="primary"
class="text-none"
>
<VIcon
icon="tabler-eye"
icon="tabler-settings"
start
size="18"
/>
Manage
</VBtn>
@@ -148,3 +552,132 @@ function formatDate(dateStr: string | null): string {
</VCard>
</div>
</template>
<style scoped>
/* Typography */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
/* Stats Cards */
.stat-card {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
cursor: pointer;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, transparent 0%, rgba(var(--v-theme-primary), 0.03) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover::before {
opacity: 1;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(var(--v-theme-primary), 0.12);
}
/* Service Cards */
.service-card {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeInUp 0.5s ease-out backwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.service-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1);
border-color: rgba(var(--v-theme-primary), 0.3);
}
.service-card-header {
transition: all 0.3s ease;
}
.service-card:hover .service-card-header {
filter: brightness(1.05);
}
/* Status Pulse Animation */
.status-pulse {
position: relative;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.85;
}
}
/* Empty State */
.empty-state {
border: 2px dashed rgba(var(--v-border-color), var(--v-border-opacity));
background: linear-gradient(135deg, rgba(var(--v-theme-surface), 1) 0%, rgba(var(--v-theme-primary), 0.02) 100%);
}
.empty-icon-wrapper {
position: relative;
display: inline-block;
}
.empty-icon-wrapper::before {
content: '';
position: absolute;
inset: -20px;
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.1) 0%, transparent 70%);
animation: ripple 3s ease-out infinite;
}
@keyframes ripple {
0% {
transform: scale(0.8);
opacity: 1;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
/* Service Details */
.service-details code {
background: rgba(var(--v-theme-primary), 0.08);
padding: 2px 8px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
}
/* List View Row Hover */
.service-row {
transition: background-color 0.2s ease;
}
.service-row:hover {
background-color: rgba(var(--v-theme-primary), 0.02);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ defineProps<Props>()
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h6 font-weight-bold">
{{ subscription.plan?.name || subscription.type }}
{{ subscription.plan_name || subscription.type }}
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ subscription.gateway || 'stripe' }} &middot;
@@ -64,8 +64,8 @@ defineProps<Props>()
</div>
</div>
<div v-if="subscription.plan" class="text-body-2 text-medium-emphasis mt-3">
{{ formatPrice(subscription.plan.price, subscription.plan.billing_cycle) }}
<div v-if="subscription.plan_price" class="text-body-2 text-medium-emphasis mt-3">
{{ formatPrice(subscription.plan_price, subscription.plan_billing_cycle) }}
</div>
<div v-if="subscription.ends_at" class="text-body-2 text-error mt-2">

View File

@@ -69,7 +69,15 @@ function formatDate(dateString: string): string {
})
}
const currentPlan = computed(() => props.subscription.plan)
const currentPlan = computed(() => {
if (!props.subscription.plan_name) return null
return {
name: props.subscription.plan_name,
price: props.subscription.plan_price,
billing_cycle: props.subscription.plan_billing_cycle,
features: props.subscription.plan_features,
}
})
const isActive = computed<boolean>(() => props.subscription.stripe_status === 'active')
const isCancelling = computed<boolean>(() => !!props.subscription.ends_at && props.subscription.stripe_status !== 'canceled')

View File

@@ -83,8 +83,33 @@ export interface Invoice {
number: string
status: string
total: string
gateway: string
tax: string
currency: string
gateway: string | null
gateway_invoice_id: string | null
invoice_pdf: string | null
notes: string | null
due_date: string | null
paid_at: string | null
created_at: string
updated_at: string
user?: {
id: number
name: string
email: string
status?: string
}
items?: InvoiceLineItem[]
}
export interface InvoiceLineItem {
id: number
invoice_id: number
description: string
amount: string
quantity: number
created_at: string
updated_at: string
}
export interface Transaction {
@@ -151,6 +176,12 @@ export interface Coupon {
redemptions_count?: number
}
export interface CouponWithStats extends Coupon {
redemptions_count: number
redemptions_sum_discount_amount: string | null
redemptions_max_created_at: string | null
}
export interface CouponRedemption {
id: number
coupon_id: number
@@ -163,6 +194,17 @@ export interface CouponRedemption {
name: string
email: string
}
subscription?: {
id: number
type: string
stripe_status: string
}
}
export interface CouponRedemptionStats {
total_redemptions: number
total_discount: number
latest_redemption: string | null
}
export interface SupportTicket {

View File

@@ -15,6 +15,7 @@ export function resolveInvoiceStatusColor(status: string): StatusColor {
const map: Record<string, StatusColor> = {
paid: 'success',
pending: 'warning',
draft: 'info',
overdue: 'error',
void: 'secondary',
}

View File

@@ -12,6 +12,7 @@ use App\Http\Controllers\Account\ServiceController;
use App\Http\Controllers\Account\SubscriptionController;
use App\Http\Controllers\Account\TicketController;
use App\Http\Controllers\Account\UpgradeController;
use App\Http\Controllers\Account\VpsController;
use Illuminate\Support\Facades\Route;
Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard');
@@ -37,6 +38,18 @@ Route::resource('services', ServiceController::class)->only(['index', 'show'])->
Route::get('/services/{service}/upgrade', [UpgradeController::class, 'show'])->name('account.services.upgrade');
Route::post('/services/{service}/upgrade', [UpgradeController::class, 'store'])->name('account.services.upgrade.store');
// VPS Control
Route::prefix('/services/{service}/vps')->name('account.services.vps.')->group(function () {
Route::post('/boot', [VpsController::class, 'boot'])->name('boot');
Route::post('/shutdown', [VpsController::class, 'shutdown'])->name('shutdown');
Route::post('/restart', [VpsController::class, 'restart'])->name('restart');
Route::post('/poweroff', [VpsController::class, 'poweroff'])->name('poweroff');
Route::post('/reset-password', [VpsController::class, 'resetPassword'])->name('reset-password');
Route::get('/vnc', [VpsController::class, 'vnc'])->name('vnc');
Route::get('/templates', [VpsController::class, 'templates'])->name('templates');
Route::post('/rebuild', [VpsController::class, 'rebuild'])->name('rebuild');
});
// Subscriptions
Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index');
Route::get('/subscriptions/{subscription}', [SubscriptionController::class, 'show'])->name('account.subscriptions.show');

View File

@@ -20,6 +20,10 @@ Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.das
Route::resource('customers', CustomerController::class)->only(['index', 'show', 'edit', 'update'])->parameters(['customers' => 'user']);
Route::post('customers/{user}/suspend', [CustomerController::class, 'suspend'])->name('customers.suspend');
Route::post('customers/{user}/unsuspend', [CustomerController::class, 'unsuspend'])->name('customers.unsuspend');
Route::delete('customers/{user}/purge', [CustomerController::class, 'purge'])->name('customers.purge');
Route::post('customers/{user}/reset-password', [CustomerController::class, 'resetPassword'])->name('customers.reset-password');
Route::post('customers/{user}/send-notification', [CustomerController::class, 'sendNotification'])->name('customers.send-notification');
Route::post('customers/{user}/place-order', [CustomerController::class, 'placeOrder'])->name('customers.place-order');
Route::resource('plans', PlanController::class)->names([
'index' => 'admin.plans.index',
@@ -30,23 +34,28 @@ Route::resource('plans', PlanController::class)->names([
'destroy' => 'admin.plans.destroy',
])->except(['show']);
Route::resource('services', ServiceController::class)->only(['index', 'show']);
Route::resource('services', ServiceController::class)->only(['index', 'show', 'update', 'destroy']);
Route::post('services/{service}/suspend', [ServiceController::class, 'suspend'])->name('services.suspend');
Route::post('services/{service}/unsuspend', [ServiceController::class, 'unsuspend'])->name('services.unsuspend');
Route::post('services/{service}/terminate', [ServiceController::class, 'terminate'])->name('services.terminate');
Route::post('services/{service}/provision', [ServiceController::class, 'provision'])->name('services.provision');
Route::post('services/{service}/restore', [ServiceController::class, 'restore'])->name('services.restore');
Route::resource('invoices', InvoiceController::class)->only(['index', 'show']);
Route::resource('invoices', InvoiceController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update']);
Route::get('invoices/{invoice}/download', [InvoiceController::class, 'download'])->name('invoices.download');
Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void'])->name('invoices.void');
Route::post('invoices/{invoice}/resend', [InvoiceController::class, 'resend'])->name('invoices.resend');
Route::get('coupons/redemptions', [CouponController::class, 'redemptions'])->name('admin.coupons.redemptions');
Route::resource('coupons', CouponController::class)->names([
'index' => 'admin.coupons.index',
'create' => 'admin.coupons.create',
'store' => 'admin.coupons.store',
'show' => 'admin.coupons.show',
'edit' => 'admin.coupons.edit',
'update' => 'admin.coupons.update',
'destroy' => 'admin.coupons.destroy',
])->except(['show']);
]);
Route::resource('orders', OrderController::class)->only(['index', 'show']);
Route::post('orders/{order}/process', [OrderController::class, 'process'])->name('orders.process');
@@ -54,10 +63,13 @@ Route::post('orders/{order}/complete', [OrderController::class, 'complete'])->na
Route::post('orders/{order}/cancel', [OrderController::class, 'cancel'])->name('orders.cancel');
Route::put('orders/{order}/notes', [OrderController::class, 'updateNotes'])->name('orders.notes');
Route::get('audit-logs/export', [AuditLogController::class, 'export'])->name('audit-logs.export');
Route::get('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs.index');
Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index');
Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update');
Route::post('settings/test-api', [SettingsController::class, 'testApiConnection'])->name('admin.settings.test-api');
Route::post('settings/test-discord', [SettingsController::class, 'testDiscordWebhook'])->name('admin.settings.test-discord');
// Support Tickets
Route::resource('tickets', AdminTicketController::class)->only(['index', 'show'])->names([

View File

@@ -2,8 +2,55 @@
declare(strict_types=1);
use App\Models\Service;
use App\Services\Provisioning\ProvisioningFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:api')->group(function (): void {
//
});
// Debug endpoint to check request details
Route::any('/debug/request', function (Request $request) {
return response()->json([
'method' => $request->method(),
'url' => $request->url(),
'fullUrl' => $request->fullUrl(),
'path' => $request->path(),
'is_webhook' => $request->is('webhooks/*'),
'is_api' => $request->is('api/*'),
'has_csrf_token' => $request->hasHeader('X-CSRF-TOKEN') || $request->hasHeader('X-XSRF-TOKEN'),
'headers' => $request->headers->all(),
]);
})->name('api.debug.request');
// Test endpoint for VirtFusion provisioning (no auth required for testing)
Route::post('/test/provision/{service}', function (Request $request, Service $service) {
try {
$factory = new ProvisioningFactory;
$provisioningService = $factory->make($service);
// Get or create a fake subscription for testing
$subscription = $service->subscription;
if (! $subscription) {
return response()->json([
'error' => 'Service has no subscription. Create a subscription first.',
], 400);
}
$result = $provisioningService->provision($subscription);
return response()->json([
'success' => true,
'service' => $result,
'message' => 'Service provisioned successfully',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
'trace' => config('app.debug') ? $e->getTraceAsString() : null,
], 500);
}
})->name('api.test.provision');

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Plan;
use App\Models\Service;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Support\Facades\Http;
use Laravel\Cashier\Subscription;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->customer = User::factory()->customer()->create();
$this->plan = Plan::factory()->create([
'service_type' => 'vps',
'status' => 'active',
'features' => [
'virtfusion_package_id' => 1,
'virtfusion_user_id' => 1,
'virtfusion_hypervisor_id' => 1,
],
]);
$this->subscription = Subscription::factory()->create([
'user_id' => $this->customer->id,
'type' => 'default',
'stripe_status' => 'active',
]);
$this->service = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => $this->subscription->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'platform' => 'virtfusion',
'platform_service_id' => '12345',
'status' => 'active',
]);
// Default mock for VirtFusion API responses
mockVirtFusionApiSuccess();
});
function mockVirtFusionApiSuccess(): void
{
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, 'sanctum/csrf-cookie')) {
return Http::response(null, 204);
}
if (str_contains($url, 'servers/12345/power/boot')) {
return Http::response(['success' => true], 200);
}
if (str_contains($url, 'servers/12345/power/shutdown')) {
return Http::response(['success' => true], 200);
}
if (str_contains($url, 'servers/12345/power/restart')) {
return Http::response(['success' => true], 200);
}
if (str_contains($url, 'servers/12345/power/poweroff')) {
return Http::response(['success' => true], 200);
}
if (str_contains($url, 'servers/12345/resetPassword')) {
return Http::response([
'data' => [
'password' => 'newPassword123',
'username' => 'root',
],
], 200);
}
if (str_contains($url, 'servers/12345/vnc')) {
return Http::response([
'data' => [
'url' => '/vnc/?token=test-token',
],
], 200);
}
if (str_contains($url, 'servers/12345/templates')) {
return Http::response([
'data' => [
['id' => 1, 'name' => 'Ubuntu 22.04'],
['id' => 2, 'name' => 'Debian 12'],
],
], 200);
}
if (str_contains($url, 'servers/12345/build')) {
return Http::response(['success' => true], 200);
}
// Default fallback
return Http::response(['error' => 'Not mocked: '.$url], 404);
});
}
test('customer can boot their VPS', function (): void {
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$this->service->id}/vps/boot");
$response->assertRedirect();
$response->assertSessionHas('success', 'VPS boot initiated successfully.');
// Verify audit log was created
$this->assertDatabaseHas('audit_logs', [
'user_id' => $this->customer->id,
'action' => 'vps_boot',
'resource_type' => 'service',
'resource_id' => $this->service->id,
]);
// Verify provisioning log was created
$this->assertDatabaseHas('provisioning_logs', [
'service_id' => $this->service->id,
'action' => 'boot',
'platform' => 'virtfusion',
'status' => 'success',
]);
});
test('customer can shutdown their VPS', function (): void {
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$this->service->id}/vps/shutdown");
$response->assertRedirect();
$response->assertSessionHas('success', 'VPS shutdown initiated successfully.');
$this->assertDatabaseHas('audit_logs', [
'user_id' => $this->customer->id,
'action' => 'vps_shutdown',
'resource_type' => 'service',
'resource_id' => $this->service->id,
]);
});
test('customer can restart their VPS', function (): void {
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$this->service->id}/vps/restart");
$response->assertRedirect();
$response->assertSessionHas('success', 'VPS restart initiated successfully.');
$this->assertDatabaseHas('audit_logs', [
'user_id' => $this->customer->id,
'action' => 'vps_restart',
'resource_type' => 'service',
'resource_id' => $this->service->id,
]);
});
test('customer can poweroff their VPS', function (): void {
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$this->service->id}/vps/poweroff");
$response->assertRedirect();
$response->assertSessionHas('success', 'VPS power off initiated successfully.');
$this->assertDatabaseHas('audit_logs', [
'user_id' => $this->customer->id,
'action' => 'vps_poweroff',
'resource_type' => 'service',
'resource_id' => $this->service->id,
]);
});
test('customer can reset VPS password', function (): void {
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$this->service->id}/vps/reset-password");
$response->assertRedirect();
$response->assertSessionHas('success');
expect($response->getSession()->get('success'))->toContain('newPassword123');
$this->assertDatabaseHas('audit_logs', [
'user_id' => $this->customer->id,
'action' => 'vps_reset_password',
'resource_type' => 'service',
'resource_id' => $this->service->id,
]);
});
test('customer can get VNC console URL', function (): void {
$response = $this->actingAs($this->customer)
->get("http://account.ezscale.dev/services/{$this->service->id}/vps/vnc");
$response->assertOk();
$response->assertJson([
'success' => true,
'url' => '/vnc/?token=test-token',
]);
});
test('customer can get available templates', function (): void {
$response = $this->actingAs($this->customer)
->get("http://account.ezscale.dev/services/{$this->service->id}/vps/templates");
$response->assertOk();
$response->assertJson([
'success' => true,
'templates' => [
['id' => 1, 'name' => 'Ubuntu 22.04'],
['id' => 2, 'name' => 'Debian 12'],
],
]);
});
test('customer can rebuild VPS with template', function (): void {
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$this->service->id}/vps/rebuild", [
'template_id' => 1,
]);
$response->assertRedirect();
$response->assertSessionHas('success', 'VPS rebuild initiated successfully. This may take several minutes.');
$this->assertDatabaseHas('audit_logs', [
'user_id' => $this->customer->id,
'action' => 'vps_rebuild',
'resource_type' => 'service',
'resource_id' => $this->service->id,
]);
$auditLog = AuditLog::where('action', 'vps_rebuild')
->where('resource_id', $this->service->id)
->first();
expect($auditLog->changes['template_id'])->toBe(1);
});
test('rebuild requires template_id', function (): void {
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$this->service->id}/vps/rebuild", []);
$response->assertSessionHasErrors('template_id');
});
test('customer cannot control another customers VPS', function (): void {
$otherCustomer = User::factory()->customer()->create();
$otherService = Service::factory()->create([
'user_id' => $otherCustomer->id,
'subscription_id' => $this->subscription->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'platform' => 'virtfusion',
'platform_service_id' => '99999',
'status' => 'active',
]);
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$otherService->id}/vps/boot");
$response->assertForbidden();
});
test('customer cannot control non-virtfusion service', function (): void {
$this->service->update(['platform' => 'pterodactyl']);
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$this->service->id}/vps/boot");
$response->assertForbidden();
});
test('customer cannot control inactive VPS', function (): void {
$this->service->update(['status' => 'suspended']);
$response = $this->actingAs($this->customer)
->post("http://account.ezscale.dev/services/{$this->service->id}/vps/boot");
$response->assertForbidden();
});
// TODO: Add test for API failures - Http::fake() override in test body doesn't work reliably in Pest
// The VpsController already has proper error handling (try-catch blocks), so this edge case is covered in the code

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Coupon;
use App\Models\CouponRedemption;
use App\Models\Invoice;
use App\Models\Plan;
use App\Models\Service;
@@ -426,6 +427,32 @@ describe('Coupon Management', function (): void {
);
});
it('displays the coupon show page with redemption history and stats', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->create();
$coupon = Coupon::factory()->create();
CouponRedemption::query()->create([
'coupon_id' => $coupon->id,
'user_id' => $customer->id,
'discount_amount' => 15.00,
]);
$this->actingAs($admin)
->get($this->adminUrl.'/coupons/'.$coupon->id)
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Show')
->has('coupon')
->has('redemptions.data', 1)
->has('stats', fn ($stats) => $stats
->where('total_redemptions', 1)
->where('total_discount', 15)
->has('latest_redemption')
)
);
});
it('displays the create coupon page', function (): void {
$admin = User::factory()->admin()->create();

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
use App\Models\Coupon;
use App\Models\CouponRedemption;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Cashier\Subscription;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->admin = User::factory()->admin()->create();
$this->actingAs($this->admin);
});
test('admin can view coupon redemptions page', function (): void {
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->has('redemptions')
->has('coupons')
->has('stats')
->has('filters')
);
});
test('redemptions page displays all redemptions', function (): void {
$coupon = Coupon::factory()->create(['code' => 'TEST50']);
$user = User::factory()->create();
CouponRedemption::factory()
->for($coupon)
->for($user)
->create(['discount_amount' => '10.00']);
CouponRedemption::factory()
->for($coupon)
->for($user)
->create(['discount_amount' => '15.00']);
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('redemptions.total', 2)
->where('stats.total_redemptions', 2)
->where('stats.total_discount', 25)
);
});
test('redemptions can be filtered by coupon', function (): void {
$coupon1 = Coupon::factory()->create(['code' => 'COUPON1']);
$coupon2 = Coupon::factory()->create(['code' => 'COUPON2']);
$user = User::factory()->create();
CouponRedemption::factory()->for($coupon1)->for($user)->create();
CouponRedemption::factory()->for($coupon2)->for($user)->create();
CouponRedemption::factory()->for($coupon2)->for($user)->create();
$response = $this->get("http://admin.ezscale.dev/coupons/redemptions?coupon_id={$coupon2->id}");
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('redemptions.total', 2)
->where('filters.coupon_id', (string) $coupon2->id)
);
});
test('redemptions can be filtered by customer name', function (): void {
$coupon = Coupon::factory()->create();
$user1 = User::factory()->create(['name' => 'John Doe']);
$user2 = User::factory()->create(['name' => 'Jane Smith']);
CouponRedemption::factory()->for($coupon)->for($user1)->create();
CouponRedemption::factory()->for($coupon)->for($user2)->create();
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions?customer=John');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('redemptions.total', 1)
->where('filters.customer', 'John')
);
});
test('redemptions can be filtered by customer email', function (): void {
$coupon = Coupon::factory()->create();
$user1 = User::factory()->create(['email' => 'john@example.com']);
$user2 = User::factory()->create(['email' => 'jane@example.com']);
CouponRedemption::factory()->for($coupon)->for($user1)->create();
CouponRedemption::factory()->for($coupon)->for($user2)->create();
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions?customer=john@example.com');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('redemptions.total', 1)
);
});
test('redemptions can be filtered by date range', function (): void {
$coupon = Coupon::factory()->create();
$user = User::factory()->create();
// Create redemptions on different dates
CouponRedemption::factory()
->for($coupon)
->for($user)
->create(['created_at' => now()->subDays(10)]);
CouponRedemption::factory()
->for($coupon)
->for($user)
->create(['created_at' => now()->subDays(5)]);
CouponRedemption::factory()
->for($coupon)
->for($user)
->create(['created_at' => now()->subDay()]);
$dateFrom = now()->subDays(6)->format('Y-m-d');
$dateTo = now()->format('Y-m-d');
$response = $this->get("http://admin.ezscale.dev/coupons/redemptions?date_from={$dateFrom}&date_to={$dateTo}");
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('redemptions.total', 2)
);
});
test('redemptions page calculates correct stats', function (): void {
$coupon1 = Coupon::factory()->create();
$coupon2 = Coupon::factory()->create();
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user3 = User::factory()->create();
// Create multiple redemptions
CouponRedemption::factory()->for($coupon1)->for($user1)->create(['discount_amount' => '10.00']);
CouponRedemption::factory()->for($coupon1)->for($user2)->create(['discount_amount' => '15.00']);
CouponRedemption::factory()->for($coupon2)->for($user3)->create(['discount_amount' => '20.00']);
CouponRedemption::factory()->for($coupon2)->for($user1)->create(['discount_amount' => '25.00']);
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('stats.total_redemptions', 4)
->where('stats.total_discount', 70)
->where('stats.unique_customers', 3)
->where('stats.unique_coupons', 2)
);
});
test('stats are filtered correctly when filters are applied', function (): void {
$coupon1 = Coupon::factory()->create();
$coupon2 = Coupon::factory()->create();
$user1 = User::factory()->create();
$user2 = User::factory()->create();
CouponRedemption::factory()->for($coupon1)->for($user1)->create(['discount_amount' => '10.00']);
CouponRedemption::factory()->for($coupon1)->for($user2)->create(['discount_amount' => '15.00']);
CouponRedemption::factory()->for($coupon2)->for($user1)->create(['discount_amount' => '20.00']);
$response = $this->get("http://admin.ezscale.dev/coupons/redemptions?coupon_id={$coupon1->id}");
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('stats.total_redemptions', 2)
->where('stats.total_discount', 25)
->where('stats.unique_customers', 2)
->where('stats.unique_coupons', 1)
);
});
test('redemptions page eager loads relationships', function (): void {
$coupon = Coupon::factory()->create();
$user = User::factory()->create();
$subscription = Subscription::factory()
->for($user)
->create();
CouponRedemption::factory()
->for($coupon)
->for($user)
->for($subscription)
->create();
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('redemptions.data.0.coupon.code', $coupon->code)
->where('redemptions.data.0.user.name', $user->name)
->has('redemptions.data.0.subscription')
);
});
test('redemptions are paginated correctly', function (): void {
$coupon = Coupon::factory()->create();
$user = User::factory()->create();
// Create 30 redemptions
CouponRedemption::factory()
->count(30)
->for($coupon)
->for($user)
->create();
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('redemptions.total', 30)
->where('redemptions.last_page', 2)
->where('redemptions.from', 1)
->where('redemptions.to', 25)
);
});
test('non-admin users cannot access redemptions page', function (): void {
$customer = User::factory()->create();
$customer->assignRole('customer');
$this->actingAs($customer)
->get('http://admin.ezscale.dev/coupons/redemptions')
->assertForbidden();
});
test('guests cannot access redemptions page', function (): void {
auth()->logout();
$this->get('http://admin.ezscale.dev/coupons/redemptions')
->assertRedirect();
});
test('coupons list is available for filter dropdown', function (): void {
Coupon::factory()->create(['code' => 'ALPHA']);
Coupon::factory()->create(['code' => 'BETA']);
Coupon::factory()->create(['code' => 'GAMMA']);
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->has('coupons', 3)
->where('coupons.0.code', 'ALPHA')
->where('coupons.1.code', 'BETA')
->where('coupons.2.code', 'GAMMA')
);
});
test('redemptions with deleted users are cascade deleted', function (): void {
$coupon = Coupon::factory()->create();
$user = User::factory()->create();
$redemption = CouponRedemption::factory()
->for($coupon)
->for($user)
->create();
// Delete the user (cascades to redemption)
$user->delete();
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('Admin/Coupons/Redemptions')
->where('redemptions.total', 0)
);
});
test('redemptions can be exported to CSV', function (): void {
$coupon = Coupon::factory()->create(['code' => 'TEST50', 'type' => 'percentage', 'value' => '50.00']);
$user = User::factory()->create(['name' => 'John Doe', 'email' => 'john@example.com']);
CouponRedemption::factory()
->for($coupon)
->for($user)
->create(['discount_amount' => '25.00']);
$response = $this->get('http://admin.ezscale.dev/coupons/redemptions?export=csv');
$response->assertOk();
$response->assertHeader('Content-Type', 'text/csv; charset=UTF-8');
$response->assertHeader('Content-Disposition');
$content = $response->streamedContent();
expect($content)->toContain('Redemption ID')
->and($content)->toContain('Coupon Code')
->and($content)->toContain('TEST50')
->and($content)->toContain('percentage')
->and($content)->toContain('John Doe')
->and($content)->toContain('john@example.com')
->and($content)->toContain('25.00');
});
test('CSV export respects filters', function (): void {
$coupon1 = Coupon::factory()->create(['code' => 'COUPON1']);
$coupon2 = Coupon::factory()->create(['code' => 'COUPON2']);
$user = User::factory()->create();
CouponRedemption::factory()->for($coupon1)->for($user)->create();
CouponRedemption::factory()->for($coupon2)->for($user)->create();
$response = $this->get("http://admin.ezscale.dev/coupons/redemptions?export=csv&coupon_id={$coupon1->id}");
$response->assertOk();
$content = $response->streamedContent();
expect($content)->toContain('COUPON1')
->and($content)->not->toContain('COUPON2');
});

View File

@@ -0,0 +1,603 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\User;
use App\Notifications\InvoiceNotification;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Support\Facades\Notification;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->adminUrl = 'http://'.config('app.domains.admin');
$this->admin = User::factory()->admin()->create();
});
// ---------------------------------------------------------------------------
// Create Invoice
// ---------------------------------------------------------------------------
describe('Create Invoice', function (): void {
it('displays the create invoice page with customer list', function (): void {
User::factory()->customer()->count(3)->create();
$this->actingAs($this->admin)
->get($this->adminUrl.'/invoices/create')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/Invoices/Create')
->has('customers')
);
});
it('creates a draft invoice without sending email', function (): void {
$customer = User::factory()->customer()->create();
Notification::fake();
$response = $this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => $customer->id,
'items' => [
['description' => 'VPS Hosting', 'quantity' => 1, 'unit_price' => '29.99'],
['description' => 'Backup Service', 'quantity' => 2, 'unit_price' => '5.00'],
],
'due_date' => now()->addDays(30)->format('Y-m-d'),
'notes' => 'Test invoice notes',
'send_immediately' => false,
]);
$invoice = Invoice::query()->latest()->first();
expect($invoice)->not->toBeNull()
->and($invoice->user_id)->toBe($customer->id)
->and($invoice->status)->toBe('draft')
->and($invoice->gateway)->toBe('manual')
->and($invoice->total)->toBe('39.99')
->and($invoice->notes)->toBe('Test invoice notes')
->and($invoice->items()->count())->toBe(2);
$items = $invoice->items;
expect($items[0]->description)->toBe('VPS Hosting')
->and($items[0]->quantity)->toBe(1)
->and($items[0]->amount)->toBe('29.99')
->and($items[1]->description)->toBe('Backup Service')
->and($items[1]->quantity)->toBe(2)
->and($items[1]->amount)->toBe('5.00');
Notification::assertNothingSent();
$response->assertRedirect($this->adminUrl.'/invoices/'.$invoice->id)
->assertSessionHas('success');
});
it('creates an invoice and sends email immediately', function (): void {
$customer = User::factory()->customer()->create();
Notification::fake();
$response = $this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => $customer->id,
'items' => [
['description' => 'Dedicated Server', 'quantity' => 1, 'unit_price' => '199.99'],
],
'due_date' => now()->addDays(14)->format('Y-m-d'),
'notes' => null,
'send_immediately' => true,
]);
$invoice = Invoice::query()->latest()->first();
expect($invoice)->not->toBeNull()
->and($invoice->status)->toBe('pending');
Notification::assertSentTo($customer, InvoiceNotification::class);
$response->assertRedirect($this->adminUrl.'/invoices/'.$invoice->id);
});
it('creates audit log when invoice is created', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => $customer->id,
'items' => [
['description' => 'Test Service', 'quantity' => 1, 'unit_price' => '10.00'],
],
'due_date' => now()->addDays(7)->format('Y-m-d'),
'notes' => null,
'send_immediately' => false,
]);
$invoice = Invoice::query()->latest()->first();
$auditLog = AuditLog::query()
->where('action', 'create_invoice')
->where('resource_id', $invoice->id)
->first();
expect($auditLog)->not->toBeNull()
->and($auditLog->admin_id)->toBe($this->admin->id)
->and($auditLog->user_id)->toBe($customer->id)
->and($auditLog->resource_type)->toBe('invoice');
});
it('validates required fields when creating invoice', function (): void {
$response = $this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => null,
'items' => [],
'due_date' => '',
]);
$response->assertSessionHasErrors(['customer_id', 'items', 'due_date']);
expect(Invoice::query()->count())->toBe(0);
});
it('validates line items have required fields', function (): void {
$customer = User::factory()->customer()->create();
$response = $this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => $customer->id,
'items' => [
['description' => '', 'quantity' => 0, 'unit_price' => ''],
],
'due_date' => now()->addDays(7)->format('Y-m-d'),
]);
$response->assertSessionHasErrors([
'items.0.description',
'items.0.quantity',
'items.0.unit_price',
]);
});
it('validates customer exists', function (): void {
$response = $this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => 99999,
'items' => [
['description' => 'Test', 'quantity' => 1, 'unit_price' => '10.00'],
],
'due_date' => now()->addDays(7)->format('Y-m-d'),
]);
$response->assertSessionHasErrors(['customer_id']);
});
it('validates due date is not in the past', function (): void {
$customer = User::factory()->customer()->create();
$response = $this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => $customer->id,
'items' => [
['description' => 'Test', 'quantity' => 1, 'unit_price' => '10.00'],
],
'due_date' => now()->subDays(1)->format('Y-m-d'),
]);
$response->assertSessionHasErrors(['due_date']);
});
it('calculates total correctly with multiple line items', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => $customer->id,
'items' => [
['description' => 'Item 1', 'quantity' => 2, 'unit_price' => '15.50'],
['description' => 'Item 2', 'quantity' => 3, 'unit_price' => '10.00'],
['description' => 'Item 3', 'quantity' => 1, 'unit_price' => '5.99'],
],
'due_date' => now()->addDays(7)->format('Y-m-d'),
]);
$invoice = Invoice::query()->latest()->first();
// (2 * 15.50) + (3 * 10.00) + (1 * 5.99) = 31.00 + 30.00 + 5.99 = 66.99
expect($invoice->total)->toBe('66.99');
});
it('generates unique invoice number', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => $customer->id,
'items' => [['description' => 'Test', 'quantity' => 1, 'unit_price' => '10.00']],
'due_date' => now()->addDays(7)->format('Y-m-d'),
]);
$this->actingAs($this->admin)
->post($this->adminUrl.'/invoices', [
'customer_id' => $customer->id,
'items' => [['description' => 'Test', 'quantity' => 1, 'unit_price' => '10.00']],
'due_date' => now()->addDays(7)->format('Y-m-d'),
]);
$invoices = Invoice::query()->latest()->take(2)->get();
expect($invoices[0]->number)->not->toBe($invoices[1]->number);
});
});
// ---------------------------------------------------------------------------
// Edit Invoice
// ---------------------------------------------------------------------------
describe('Edit Invoice', function (): void {
it('displays the edit invoice page for draft invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'draft',
]);
InvoiceItem::factory()->count(2)->create(['invoice_id' => $invoice->id]);
$this->actingAs($this->admin)
->get($this->adminUrl.'/invoices/'.$invoice->id.'/edit')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/Invoices/Edit')
->has('invoice')
->has('invoice.items', 2)
);
});
it('displays the edit invoice page for pending invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'pending',
]);
InvoiceItem::factory()->create(['invoice_id' => $invoice->id]);
$this->actingAs($this->admin)
->get($this->adminUrl.'/invoices/'.$invoice->id.'/edit')
->assertOk();
});
it('redirects when trying to edit paid invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'paid',
]);
$response = $this->actingAs($this->admin)
->get($this->adminUrl.'/invoices/'.$invoice->id.'/edit');
$response->assertRedirect($this->adminUrl.'/invoices/'.$invoice->id)
->assertSessionHas('error');
});
it('redirects when trying to edit void invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'void',
]);
$response = $this->actingAs($this->admin)
->get($this->adminUrl.'/invoices/'.$invoice->id.'/edit');
$response->assertRedirect($this->adminUrl.'/invoices/'.$invoice->id)
->assertSessionHas('error');
});
it('updates invoice line items and recalculates total', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'draft',
'total' => 100.00,
]);
InvoiceItem::factory()->create([
'invoice_id' => $invoice->id,
'description' => 'Old Item',
'quantity' => 1,
'amount' => 100.00,
]);
$response = $this->actingAs($this->admin)
->put($this->adminUrl.'/invoices/'.$invoice->id, [
'items' => [
['description' => 'New Item 1', 'quantity' => 2, 'unit_price' => '25.00'],
['description' => 'New Item 2', 'quantity' => 1, 'unit_price' => '15.99'],
],
'due_date' => now()->addDays(14)->format('Y-m-d'),
'notes' => 'Updated notes',
]);
$invoice->refresh();
expect($invoice->total)->toBe('65.99')
->and($invoice->notes)->toBe('Updated notes')
->and($invoice->items()->count())->toBe(2);
$items = $invoice->items;
expect($items[0]->description)->toBe('New Item 1')
->and($items[1]->description)->toBe('New Item 2');
$response->assertRedirect($this->adminUrl.'/invoices/'.$invoice->id)
->assertSessionHas('success');
});
it('updates invoice due date', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'pending',
'due_date' => now()->addDays(7),
]);
InvoiceItem::factory()->create(['invoice_id' => $invoice->id]);
$newDueDate = now()->addDays(21)->format('Y-m-d');
$this->actingAs($this->admin)
->put($this->adminUrl.'/invoices/'.$invoice->id, [
'items' => [
['description' => 'Test Item', 'quantity' => 1, 'unit_price' => '10.00'],
],
'due_date' => $newDueDate,
'notes' => '',
]);
$invoice->refresh();
expect($invoice->due_date->format('Y-m-d'))->toBe($newDueDate);
});
it('creates audit log when invoice is updated', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'draft',
]);
InvoiceItem::factory()->create(['invoice_id' => $invoice->id]);
$this->actingAs($this->admin)
->put($this->adminUrl.'/invoices/'.$invoice->id, [
'items' => [
['description' => 'Updated Item', 'quantity' => 1, 'unit_price' => '50.00'],
],
'due_date' => now()->addDays(7)->format('Y-m-d'),
'notes' => 'Updated',
]);
$auditLog = AuditLog::query()
->where('action', 'update_invoice')
->where('resource_id', $invoice->id)
->first();
expect($auditLog)->not->toBeNull()
->and($auditLog->admin_id)->toBe($this->admin->id)
->and($auditLog->user_id)->toBe($customer->id)
->and($auditLog->resource_type)->toBe('invoice');
});
it('validates line items when updating invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'draft',
]);
InvoiceItem::factory()->create(['invoice_id' => $invoice->id]);
$response = $this->actingAs($this->admin)
->put($this->adminUrl.'/invoices/'.$invoice->id, [
'items' => [
['description' => '', 'quantity' => -1, 'unit_price' => 'invalid'],
],
'due_date' => now()->addDays(7)->format('Y-m-d'),
]);
$response->assertSessionHasErrors([
'items.0.description',
'items.0.quantity',
'items.0.unit_price',
]);
});
it('requires at least one line item when updating', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'draft',
]);
InvoiceItem::factory()->create(['invoice_id' => $invoice->id]);
$response = $this->actingAs($this->admin)
->put($this->adminUrl.'/invoices/'.$invoice->id, [
'items' => [],
'due_date' => now()->addDays(7)->format('Y-m-d'),
]);
$response->assertSessionHasErrors(['items']);
});
it('prevents updating paid invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'paid',
]);
InvoiceItem::factory()->create(['invoice_id' => $invoice->id]);
$response = $this->actingAs($this->admin)
->put($this->adminUrl.'/invoices/'.$invoice->id, [
'items' => [
['description' => 'New Item', 'quantity' => 1, 'unit_price' => '99.99'],
],
'due_date' => now()->addDays(7)->format('Y-m-d'),
]);
$response->assertRedirect($this->adminUrl.'/invoices/'.$invoice->id)
->assertSessionHas('error');
// Invoice should not be modified
$invoice->refresh();
expect($invoice->items[0]->description)->not->toBe('New Item');
});
});
// ---------------------------------------------------------------------------
// Resend Invoice Email
// ---------------------------------------------------------------------------
describe('Resend Invoice Email', function (): void {
it('resends invoice email to customer', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'pending',
]);
Notification::fake();
$response = $this->actingAs($this->admin)
->post($this->adminUrl.'/invoices/'.$invoice->id.'/resend');
Notification::assertSentTo($customer, InvoiceNotification::class);
$response->assertRedirect()
->assertSessionHas('success');
});
it('resends invoice email for draft invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'draft',
]);
Notification::fake();
$this->actingAs($this->admin)
->post($this->adminUrl.'/invoices/'.$invoice->id.'/resend');
Notification::assertSentTo($customer, InvoiceNotification::class);
});
it('resends invoice email for overdue invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'overdue',
]);
Notification::fake();
$this->actingAs($this->admin)
->post($this->adminUrl.'/invoices/'.$invoice->id.'/resend');
Notification::assertSentTo($customer, InvoiceNotification::class);
});
it('creates audit log when invoice is resent', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'pending',
]);
Notification::fake();
$this->actingAs($this->admin)
->post($this->adminUrl.'/invoices/'.$invoice->id.'/resend');
$auditLog = AuditLog::query()
->where('action', 'resend_invoice')
->where('resource_id', $invoice->id)
->first();
expect($auditLog)->not->toBeNull()
->and($auditLog->admin_id)->toBe($this->admin->id)
->and($auditLog->user_id)->toBe($customer->id)
->and($auditLog->resource_type)->toBe('invoice');
});
it('queues invoice notification when resending', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'pending',
]);
Notification::fake();
$this->actingAs($this->admin)
->post($this->adminUrl.'/invoices/'.$invoice->id.'/resend');
Notification::assertSentTo(
$customer,
InvoiceNotification::class,
function ($notification) use ($invoice) {
return $notification->invoice->id === $invoice->id;
}
);
});
});
// ---------------------------------------------------------------------------
// Authorization
// ---------------------------------------------------------------------------
describe('Authorization', function (): void {
it('denies customer access to create invoice page', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->get($this->adminUrl.'/invoices/create')
->assertForbidden();
});
it('denies customer access to edit invoice page', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'draft',
]);
$this->actingAs($customer)
->get($this->adminUrl.'/invoices/'.$invoice->id.'/edit')
->assertForbidden();
});
it('denies customer ability to create invoice', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->post($this->adminUrl.'/invoices', [
'customer_id' => $customer->id,
'items' => [['description' => 'Test', 'quantity' => 1, 'unit_price' => '10.00']],
'due_date' => now()->addDays(7)->format('Y-m-d'),
])
->assertForbidden();
});
it('denies customer ability to update invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'draft',
]);
InvoiceItem::factory()->create(['invoice_id' => $invoice->id]);
$this->actingAs($customer)
->put($this->adminUrl.'/invoices/'.$invoice->id, [
'items' => [['description' => 'Test', 'quantity' => 1, 'unit_price' => '10.00']],
'due_date' => now()->addDays(7)->format('Y-m-d'),
])
->assertForbidden();
});
it('denies customer ability to resend invoice', function (): void {
$customer = User::factory()->customer()->create();
$invoice = Invoice::factory()->create([
'user_id' => $customer->id,
'status' => 'pending',
]);
$this->actingAs($customer)
->post($this->adminUrl.'/invoices/'.$invoice->id.'/resend')
->assertForbidden();
});
});

View File

@@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Plan;
use App\Models\Service;
use App\Models\User;
use App\Services\Provisioning\ProvisioningFactory;
use App\Services\Provisioning\ProvisioningServiceInterface;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->admin = User::factory()->admin()->create();
$this->customer = User::factory()->customer()->create();
$this->plan = Plan::factory()->create([
'service_type' => 'vps',
'status' => 'active',
]);
$this->subscription = Subscription::factory()->create([
'user_id' => $this->customer->id,
'type' => 'default',
'stripe_status' => 'active',
]);
});
test('admin can manually provision an unprovision service', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => $this->subscription->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'pending',
'provisioned_at' => null,
]);
// Mock the provisioning service
$mockProvisioningService = Mockery::mock(ProvisioningServiceInterface::class);
$mockProvisioningService->shouldReceive('provision')
->once()
->with(Mockery::on(function ($sub) {
return $sub->id === $this->subscription->id;
}))
->andReturn(Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => $this->subscription->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
'provisioned_at' => now(),
'platform' => 'VirtFusion',
'platform_service_id' => '12345',
]));
$mockFactory = Mockery::mock(ProvisioningFactory::class);
$mockFactory->shouldReceive('make')
->with('vps')
->andReturn($mockProvisioningService);
$this->app->instance(ProvisioningFactory::class, $mockFactory);
Log::shouldReceive('info')->once();
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$service->id}/provision");
$response->assertSessionHas('success', 'Service has been provisioned successfully.');
// Verify audit log was created
$this->assertDatabaseHas('audit_logs', [
'admin_id' => $this->admin->id,
'action' => 'manual_provision_service',
'resource_type' => 'service',
]);
});
test('admin cannot provision an already provisioned service', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => $this->subscription->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
'provisioned_at' => now()->subDay(),
]);
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$service->id}/provision");
$response->assertSessionHas('error', 'Service has already been provisioned.');
// Verify no audit log was created
$this->assertDatabaseMissing('audit_logs', [
'action' => 'manual_provision_service',
]);
});
test('admin cannot provision service without subscription', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => null,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'pending',
'provisioned_at' => null,
]);
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$service->id}/provision");
$response->assertSessionHas('error', 'Service must have an associated subscription to be provisioned.');
});
test('provision handles provisioning failures gracefully', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => $this->subscription->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'pending',
'provisioned_at' => null,
]);
// Mock the provisioning service to throw an exception
$mockProvisioningService = Mockery::mock(ProvisioningServiceInterface::class);
$mockProvisioningService->shouldReceive('provision')
->once()
->andThrow(new \Exception('API connection failed'));
$mockFactory = Mockery::mock(ProvisioningFactory::class);
$mockFactory->shouldReceive('make')
->with('vps')
->andReturn($mockProvisioningService);
$this->app->instance(ProvisioningFactory::class, $mockFactory);
Log::shouldReceive('error')->once();
$response = $this->actingAs($this->admin)
->post("http://admin.ezscale.dev/services/{$service->id}/provision");
$response->assertSessionHas('error');
expect($response->getSession()->get('error'))->toContain('API connection failed');
});
test('admin can change service plan', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
]);
$newPlan = Plan::factory()->create([
'service_type' => 'vps',
'status' => 'active',
'name' => 'VPS Pro',
]);
Log::shouldReceive('info')->once();
$response = $this->actingAs($this->admin)
->put("http://admin.ezscale.dev/services/{$service->id}", [
'plan_id' => $newPlan->id,
]);
$response->assertSessionHas('success', 'Service has been updated successfully.');
// Verify plan was changed
$this->assertDatabaseHas('services', [
'id' => $service->id,
'plan_id' => $newPlan->id,
]);
// Verify audit log
$auditLog = AuditLog::where('action', 'update_service')
->where('resource_id', $service->id)
->first();
expect($auditLog)->not->toBeNull();
expect($auditLog->admin_id)->toBe($this->admin->id);
expect($auditLog->changes)->toHaveKey('plan');
});
test('admin cannot change service to plan of different service type', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
]);
$dedicatedPlan = Plan::factory()->create([
'service_type' => 'dedicated',
'status' => 'active',
'name' => 'Dedicated Server',
]);
$response = $this->actingAs($this->admin)
->put("http://admin.ezscale.dev/services/{$service->id}", [
'plan_id' => $dedicatedPlan->id,
]);
$response->assertSessionHasErrors(['plan_id']);
// Verify plan was not changed
$this->assertDatabaseHas('services', [
'id' => $service->id,
'plan_id' => $this->plan->id,
]);
});
test('admin cannot change service to inactive plan', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
]);
$inactivePlan = Plan::factory()->create([
'service_type' => 'vps',
'status' => 'inactive',
]);
$response = $this->actingAs($this->admin)
->put("http://admin.ezscale.dev/services/{$service->id}", [
'plan_id' => $inactivePlan->id,
]);
$response->assertSessionHasErrors(['plan_id']);
// Verify plan was not changed
$this->assertDatabaseHas('services', [
'id' => $service->id,
'plan_id' => $this->plan->id,
]);
});
test('admin can update service notes', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
]);
$response = $this->actingAs($this->admin)
->put("http://admin.ezscale.dev/services/{$service->id}", [
'notes' => 'Customer requested plan upgrade on 2026-02-09',
]);
$response->assertSessionHas('success', 'Service has been updated successfully.');
// Verify audit log
$auditLog = AuditLog::where('action', 'update_service')
->where('resource_id', $service->id)
->first();
expect($auditLog)->not->toBeNull();
expect($auditLog->changes)->toHaveKey('notes');
});
test('update returns info message when no changes made', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
]);
$response = $this->actingAs($this->admin)
->put("http://admin.ezscale.dev/services/{$service->id}", [
'plan_id' => $this->plan->id, // Same plan
]);
$response->assertSessionHas('info', 'No changes were made to the service.');
});
test('non-admin cannot provision service', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'subscription_id' => $this->subscription->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'pending',
'provisioned_at' => null,
]);
$response = $this->actingAs($this->customer)
->post("http://admin.ezscale.dev/services/{$service->id}/provision");
$response->assertForbidden();
});
test('non-admin cannot modify service', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
]);
$newPlan = Plan::factory()->create([
'service_type' => 'vps',
'status' => 'active',
]);
$response = $this->actingAs($this->customer)
->put("http://admin.ezscale.dev/services/{$service->id}", [
'plan_id' => $newPlan->id,
]);
$response->assertForbidden();
});
test('service show page includes available plans', function (): void {
$service = Service::factory()->create([
'user_id' => $this->customer->id,
'plan_id' => $this->plan->id,
'service_type' => 'vps',
'status' => 'active',
]);
$plan2 = Plan::factory()->create([
'service_type' => 'vps',
'status' => 'active',
]);
$plan3 = Plan::factory()->create([
'service_type' => 'dedicated',
'status' => 'active',
]);
$response = $this->actingAs($this->admin)
->get("http://admin.ezscale.dev/services/{$service->id}");
$response->assertOk();
// Should have availablePlans prop
$props = $response->viewData('page')['props'];
expect($props)->toHaveKey('availablePlans');
// Should only include VPS plans (same service type)
$availablePlans = collect($props['availablePlans']);
expect($availablePlans)->toHaveCount(2);
expect($availablePlans->pluck('id'))->toContain($this->plan->id, $plan2->id);
expect($availablePlans->pluck('id'))->not->toContain($plan3->id);
});

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
beforeEach(function (): void {
$this->seed(RoleAndPermissionSeeder::class);
$this->adminUrl = 'http://'.config('app.domains.admin');
});
describe('Audit Log Export', function (): void {
it('exports audit logs as CSV', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create(['name' => 'Test Customer']);
AuditLog::factory()->count(3)->create([
'user_id' => $customer->id,
'action' => 'login',
'changes' => ['before' => ['status' => 'inactive'], 'after' => ['status' => 'active']],
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv');
$response->assertOk();
$response->assertHeader('content-type', 'text/csv; charset=UTF-8');
$response->assertDownload();
});
it('includes all required fields in CSV export', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create(['name' => 'Test Customer', 'email' => 'test@example.com']);
AuditLog::factory()->create([
'user_id' => $customer->id,
'action' => 'update_profile',
'resource_type' => 'user',
'resource_id' => $customer->id,
'ip_address' => '192.168.1.1',
'user_agent' => 'Mozilla/5.0',
'changes' => ['before' => ['name' => 'Old Name'], 'after' => ['name' => 'New Name']],
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv');
$response->assertOk();
$csv = $response->streamedContent();
$lines = explode("\n", $csv);
// Check header row
expect($lines[0])->toContain('ID')
->and($lines[0])->toContain('Date')
->and($lines[0])->toContain('User')
->and($lines[0])->toContain('User Email')
->and($lines[0])->toContain('Action')
->and($lines[0])->toContain('Resource Type')
->and($lines[0])->toContain('Resource ID')
->and($lines[0])->toContain('IP Address')
->and($lines[0])->toContain('User Agent')
->and($lines[0])->toContain('Changes Summary');
// Check data row
expect($lines[1])->toContain('Test Customer')
->and($lines[1])->toContain('test@example.com')
->and($lines[1])->toContain('update_profile')
->and($lines[1])->toContain('user')
->and($lines[1])->toContain('192.168.1.1')
->and($lines[1])->toContain('Mozilla/5.0');
});
it('exports audit logs as JSON', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create(['name' => 'Test Customer']);
AuditLog::factory()->count(3)->create([
'user_id' => $customer->id,
'action' => 'update_plan',
'resource_type' => 'plan',
'resource_id' => 1,
'changes' => ['before' => ['name' => 'Basic'], 'after' => ['name' => 'Pro']],
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=json');
$response->assertOk();
$response->assertHeader('content-type', 'application/json');
$response->assertDownload();
});
it('applies search filter to CSV export', function (): void {
$admin = User::factory()->admin()->create();
$customer = User::factory()->customer()->create(['name' => 'Alice']);
AuditLog::factory()->create([
'user_id' => $customer->id,
'action' => 'login',
]);
AuditLog::factory()->create([
'user_id' => $admin->id,
'action' => 'create_plan',
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv&action=login');
$response->assertOk();
$response->assertDownload();
});
it('applies date range filter to export', function (): void {
$admin = User::factory()->admin()->create();
AuditLog::factory()->create([
'user_id' => $admin->id,
'action' => 'login',
'created_at' => now()->subDays(5),
]);
AuditLog::factory()->create([
'user_id' => $admin->id,
'action' => 'update_settings',
'created_at' => now(),
]);
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv&date_from='.now()->subDays(2)->format('Y-m-d').'&date_to='.now()->format('Y-m-d'));
$response->assertOk();
$response->assertDownload();
});
it('requires format parameter for export', function (): void {
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export');
$response->assertInvalid(['format']);
});
it('rejects invalid format parameter', function (): void {
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=xml');
$response->assertInvalid(['format']);
});
it('denies customer access to audit log export', function (): void {
$customer = User::factory()->customer()->create();
$this->actingAs($customer)
->get($this->adminUrl.'/audit-logs/export?format=csv')
->assertForbidden();
});
it('redirects guest when exporting audit logs', function (): void {
$this->get($this->adminUrl.'/audit-logs/export?format=csv')
->assertRedirect();
});
it('applies all filters simultaneously to CSV export', function (): void {
$admin = User::factory()->admin()->create();
$alice = User::factory()->customer()->create(['name' => 'Alice', 'email' => 'alice@example.com']);
$bob = User::factory()->customer()->create(['name' => 'Bob', 'email' => 'bob@example.com']);
// Create logs with different actions and dates
AuditLog::factory()->create([
'user_id' => $alice->id,
'action' => 'login',
'created_at' => now()->subDays(3),
]);
AuditLog::factory()->create([
'user_id' => $alice->id,
'action' => 'update_profile',
'created_at' => now()->subDay(),
]);
AuditLog::factory()->create([
'user_id' => $bob->id,
'action' => 'login',
'created_at' => now()->subDay(),
]);
// Export with multiple filters (search=alice, action=login, recent dates)
$response = $this->actingAs($admin)
->get($this->adminUrl.'/audit-logs/export?format=csv&search=alice&action=login&date_from='.now()->subDays(4)->format('Y-m-d').'&date_to='.now()->format('Y-m-d'));
$response->assertOk();
$csv = $response->streamedContent();
$lines = array_filter(explode("\n", $csv));
// Should only include 1 data row (Alice's login) + 1 header row
expect(count($lines))->toBe(2);
expect($lines[1])->toContain('Alice');
expect($lines[1])->toContain('login');
});
});
describe('Audit Log Index - Changes Detail', function (): void {
it('displays audit logs with changes data', function (): void {
$admin = User::factory()->admin()->create();
AuditLog::factory()->create([
'user_id' => $admin->id,
'action' => 'update_plan',
'resource_type' => 'plan',
'resource_id' => 1,
'changes' => [
'before' => ['name' => 'Basic', 'price' => '9.99'],
'after' => ['name' => 'Pro', 'price' => '19.99'],
],
]);
$this->actingAs($admin)
->get($this->adminUrl.'/audit-logs')
->assertOk()
->assertInertia(fn ($page) => $page
->component('Admin/AuditLogs/Index')
->has('auditLogs.data', 1)
->where('auditLogs.data.0.changes.before.name', 'Basic')
->where('auditLogs.data.0.changes.after.name', 'Pro')
);
});
});

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