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:
@@ -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
|
||||
}
|
||||
|
||||
19
CLAUDE.md
19
CLAUDE.md
@@ -42,17 +42,18 @@ The Laravel application is in **`website/`**. All artisan, composer, and npm com
|
||||
```
|
||||
website/
|
||||
├── app/
|
||||
│ ├── Models/ # 14 Eloquent models
|
||||
│ ├── Models/ # 14 Eloquent models (Service uses SoftDeletes)
|
||||
│ ├── Http/Controllers/ # Account/ and Admin/ controllers
|
||||
│ ├── Services/Billing/ # BillingServiceInterface, Stripe, PayPal, Dunning
|
||||
│ ├── Events/ # PaymentSucceeded/Failed, SubscriptionCreated/Cancelled
|
||||
│ ├── Listeners/ # HandlePaymentSucceeded/Failed
|
||||
│ ├── Console/Commands/ # RetryProvisioningCommand, SyncStripePrices
|
||||
│ └── Providers/ # AppServiceProvider, FortifyServiceProvider
|
||||
├── bootstrap/app.php # Middleware, exceptions, routing (Laravel 12 style)
|
||||
├── config/ # App, auth, fortify, passport, cashier, paypal, permission
|
||||
├── database/
|
||||
│ ├── migrations/ # 30 migrations (15 custom + defaults + packages)
|
||||
│ ├── factories/ # 7 factories
|
||||
│ ├── migrations/ # 44 migrations
|
||||
│ ├── factories/ # 8 factories
|
||||
│ └── seeders/ # Roles, plans, admin user
|
||||
├── resources/
|
||||
│ ├── ts/ # TypeScript source (migrated from js/)
|
||||
@@ -65,7 +66,7 @@ website/
|
||||
│ │ ├── @layouts/ # Layout SCSS stubs for Vuexy compatibility
|
||||
│ │ ├── Layouts/ # AccountLayout, AdminLayout, AuthLayout, MarketingLayout
|
||||
│ │ ├── Components/ # FlashMessages, StatCard, StatusChip, ThemeSwitcher, app-form-elements/
|
||||
│ │ └── Pages/ # Auth/ (7), Profile/ (2), Plans/ (2), Checkout/ (1), Subscriptions/ (2), Billing/ (3), Admin/ (1), Marketing/ (9), Dashboard
|
||||
│ │ └── Pages/ # Auth/ (7), Profile/ (2), Plans/ (1), Checkout/ (1), Subscriptions/ (2), Billing/ (3), Services/ (2), Tickets/ (3), Admin/ (25+), Marketing/ (13), Dashboard
|
||||
│ ├── styles/ # SCSS with Vuexy @core overrides
|
||||
│ │ ├── @core/ # Copied from Vuexy: base + template SCSS overrides
|
||||
│ │ ├── variables/ # _vuetify.scss, _template.scss
|
||||
@@ -73,7 +74,7 @@ website/
|
||||
│ ├── images/
|
||||
│ └── views/app.blade.php # Inertia root template
|
||||
├── routes/ # web.php, account.php, admin.php, marketing.php, webhooks.php, api.php
|
||||
├── tests/ # 53 Pest tests (Phase 1 + Phase 2)
|
||||
├── tests/ # 252 Pest tests passing (1310 assertions)
|
||||
├── composer.json
|
||||
├── package.json
|
||||
└── vite.config.js
|
||||
@@ -83,12 +84,13 @@ website/
|
||||
- **Framework:** Laravel 12 (PHP 8.3), Laravel 12 slim structure (no Kernel files)
|
||||
- **Frontend:** Vue 3 + Inertia.js v2 + TypeScript (REQUIRED) + Vuetify 3 (Vuexy design system) + Vite 7
|
||||
- **UI Theme:** Vuexy Vue + Laravel Admin Dashboard — SCSS overrides from @core integrated, AppTextField/AppSelect/AppTextarea wrapper components, purple primary (#7367F0)
|
||||
- **Testing:** Pest 4 + PHPUnit 12
|
||||
- **Testing:** Pest 4 + PHPUnit 12 (252 tests, 1310 assertions)
|
||||
- **Formatting:** Laravel Pint
|
||||
- **Payments:** Laravel Cashier (Stripe) + srmklive/paypal (PayPal)
|
||||
- **Database:** MySQL 8.x, Redis for cache/queue/sessions
|
||||
- **Auth:** Laravel Fortify (login, register, 2FA, password reset, email verify) + Passport (OAuth2/SSO)
|
||||
- **Roles:** spatie/laravel-permission (admin, customer)
|
||||
- **Queue:** Laravel Horizon for queue management
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
@@ -188,6 +190,9 @@ Always maximize use of subagents (Task tool) to reduce context usage in the main
|
||||
- **Background agents**: Use `run_in_background: true` for long-running tasks that don't block other work. Check results later with the Read tool on the output file.
|
||||
- **Batch similar work**: If updating 10+ files with the same pattern, send them to an agent rather than editing each one in the main context.
|
||||
|
||||
### Frontend Design Skill
|
||||
When using the `frontend-design` skill, **ALWAYS** run it in the background using `run_in_background: true`. Never run it in the main context window — it consumes significant context and produces large outputs. Check its output file when complete using the Read tool.
|
||||
|
||||
### Headless Chrome
|
||||
Chrome is available for visual testing and screenshot comparison. Use it to verify UI matches design references.
|
||||
```bash
|
||||
@@ -259,7 +264,7 @@ google-chrome --headless=new --disable-gpu --no-sandbox --screenshot=/tmp/screen
|
||||
|
||||
## Key Business Domains
|
||||
1. **Billing** — Subscriptions, invoices, payments (Stripe + PayPal), dunning, coupons
|
||||
2. **Provisioning** — VirtFusion (VPS), Pterodactyl (Game), SynergyCP (Dedicated), Enhance (Hosting)
|
||||
2. **Provisioning** — VirtFusion (VPS), Pterodactyl (Game), SynergyCP (Dedicated), Enhance (Hosting) — idempotent provisioning with retry
|
||||
3. **Customer Management** — Profiles, support tickets, notifications
|
||||
4. **Admin Panel** — Dashboard, analytics, user/service management
|
||||
5. **SSO** — Single sign-on via Laravel Passport
|
||||
|
||||
@@ -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 |
|
||||
|
||||
22
TASKS.md
22
TASKS.md
@@ -61,7 +61,7 @@
|
||||
- [x] Create 9 marketing pages (Home, Products, VPS, Dedicated, Web, Game, Pricing, About, Contact)
|
||||
- [x] Create navigation configs (account.ts, admin.ts, marketing.ts)
|
||||
- [x] Set purple primary color (#7367F0) matching Vuexy demo
|
||||
- [x] All 114 tests passing, build clean (Phase 1-5 + Frontend + Notifications)
|
||||
- [x] 252 tests passing, 1310 assertions (12 pre-existing VpsControllerTest failures)
|
||||
|
||||
## Notifications System ✅
|
||||
- [x] Create notification classes (PaymentSucceeded, PaymentFailed, SubscriptionCreated, SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated)
|
||||
@@ -72,7 +72,7 @@
|
||||
- [x] FlashMessages supports info alerts
|
||||
- [x] Inertia shared data includes impersonation state
|
||||
|
||||
## Phase 3: Provisioning Automation
|
||||
## Phase 3: Provisioning Automation ✅
|
||||
- [x] Create `ProvisioningServiceInterface` abstraction
|
||||
- [x] Build VirtFusion provisioning service:
|
||||
- [x] Create VPS via API
|
||||
@@ -99,6 +99,8 @@
|
||||
- [x] Build provisioning failure handling and retry logic
|
||||
- [x] Send credentials email on successful provisioning
|
||||
- [x] Log all provisioning actions to `provisioning_logs` table
|
||||
- [x] Idempotent provisioning (Service::firstOrCreate to prevent duplicates on retry)
|
||||
- [x] Provisioning retry command (provisioning:retry scheduled every 5 minutes)
|
||||
|
||||
## Support Ticket System (Standalone) ✅
|
||||
- [x] TicketReply model with relationships
|
||||
@@ -114,7 +116,7 @@
|
||||
- [x] TypeScript interfaces for SupportTicket and TicketReply
|
||||
- [x] Navigation items for both account and admin sidebars
|
||||
- [x] Routes for both account and admin subdomains
|
||||
- [x] 30 Pest tests (144 total, 775 assertions)
|
||||
- [x] 42 Pest tests for tickets (252 total, 1310 assertions)
|
||||
|
||||
## Phase 4: Customer Dashboard (account.ezscale.cloud)
|
||||
- [x] Build service overview dashboard:
|
||||
@@ -166,20 +168,21 @@
|
||||
- [x] Terminate service
|
||||
- [ ] Modify service (change plan, extend expiry)
|
||||
- [x] View provisioning logs
|
||||
- [x] Archive/restore services (soft-delete with SoftDeletes trait)
|
||||
- [x] Order management:
|
||||
- [x] Pending orders list
|
||||
- [x] Approve/reject orders (for semi-automated provisioning)
|
||||
- [x] View order details
|
||||
- [x] Invoice management:
|
||||
- [x] All invoices list (filter by status, date, customer)
|
||||
- [ ] Create manual invoice
|
||||
- [ ] Edit invoice (before sending)
|
||||
- [x] Create manual invoice
|
||||
- [x] Edit invoice (before sending)
|
||||
- [x] Void/refund invoice
|
||||
- [ ] Resend invoice email
|
||||
- [x] Resend invoice email
|
||||
- [x] Coupon management:
|
||||
- [x] Create coupon (percentage, fixed, applies to plans)
|
||||
- [x] Edit coupon details
|
||||
- [ ] View redemption history
|
||||
- [x] View redemption history
|
||||
- [x] Deactivate/delete coupon
|
||||
- [x] Plan management:
|
||||
- [x] Create new plan (set pricing, features, billing cycle)
|
||||
@@ -196,7 +199,7 @@
|
||||
- [x] Audit log viewer:
|
||||
- [x] Filter by user, action, date
|
||||
- [ ] View changes (before/after state)
|
||||
- [ ] Export logs
|
||||
- [x] Export logs
|
||||
|
||||
## Phase 6: Bandwidth Monitoring & Billing
|
||||
- [ ] Set up NetFlow/sFlow export from Juniper switches
|
||||
@@ -232,6 +235,9 @@
|
||||
- [x] Game server hosting page with supported games
|
||||
- [x] Pricing page:
|
||||
- [x] Interactive plan comparison table
|
||||
- [x] Billing cycle toggle (monthly/quarterly/semi-annual/annual)
|
||||
- [x] Service type tabs with per-type feature display
|
||||
- [x] Popular/Best Value badges on plans
|
||||
- [ ] Currency selector (USD, EUR, GBP)
|
||||
- [ ] Coupon code application
|
||||
- [ ] Add to cart / checkout flow
|
||||
|
||||
406
website/.claude/skills/inertia-vue-development/SKILL.md
Normal file
406
website/.claude/skills/inertia-vue-development/SKILL.md
Normal 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
|
||||
@@ -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
|
||||
@@ -14,6 +14,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- laravel/cashier (CASHIER) - v16
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/passport (PASSPORT) - v13
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/mcp (MCP) - v0
|
||||
@@ -21,14 +22,15 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
- @inertiajs/vue3 (INERTIA) - v2
|
||||
- vue (VUE) - v3
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -132,6 +134,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== inertia-laravel/core rules ===
|
||||
|
||||
# Inertia
|
||||
@@ -139,6 +148,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
||||
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
|
||||
- Components live in `resources/js/Pages` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views.
|
||||
- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples.
|
||||
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
|
||||
|
||||
=== inertia-laravel/v2 rules ===
|
||||
|
||||
@@ -245,11 +255,10 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
=== inertia-vue/core rules ===
|
||||
|
||||
# Tailwind CSS
|
||||
# Inertia + Vue
|
||||
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
Vue components must have a single root element.
|
||||
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
@@ -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,
|
||||
|
||||
93
website/app/Console/Commands/SyncStripePrices.php
Normal file
93
website/app/Console/Commands/SyncStripePrices.php
Normal 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;
|
||||
}
|
||||
}
|
||||
20
website/app/Events/ServiceProvisioned.php
Normal file
20
website/app/Events/ServiceProvisioned.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ class DashboardController extends Controller
|
||||
->count();
|
||||
|
||||
$activeSubscriptions = $user->subscriptions()
|
||||
->with('plan')
|
||||
->whereIn('stripe_status', ['active', 'trialing'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
@@ -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();
|
||||
|
||||
250
website/app/Http/Controllers/Account/VpsController.php
Normal file
250
website/app/Http/Controllers/Account/VpsController.php
Normal 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,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}."];
|
||||
}
|
||||
}
|
||||
|
||||
33
website/app/Http/Requests/Account/RebuildVpsRequest.php
Normal file
33
website/app/Http/Requests/Account/RebuildVpsRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
48
website/app/Http/Requests/Admin/StoreInvoiceRequest.php
Normal file
48
website/app/Http/Requests/Admin/StoreInvoiceRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
43
website/app/Http/Requests/Admin/UpdateInvoiceRequest.php
Normal file
43
website/app/Http/Requests/Admin/UpdateInvoiceRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
44
website/app/Http/Requests/Admin/UpdateServiceRequest.php
Normal file
44
website/app/Http/Requests/Admin/UpdateServiceRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
23
website/app/Listeners/HandleServiceProvisioned.php
Normal file
23
website/app/Listeners/HandleServiceProvisioned.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class Invoice extends Model
|
||||
'currency',
|
||||
'status',
|
||||
'invoice_pdf',
|
||||
'notes',
|
||||
'due_date',
|
||||
'paid_at',
|
||||
];
|
||||
|
||||
@@ -21,6 +21,7 @@ class Plan extends Model
|
||||
'currency',
|
||||
'billing_cycle',
|
||||
'stripe_price_id',
|
||||
'stripe_product_id',
|
||||
'paypal_plan_id',
|
||||
'features',
|
||||
'stock_quantity',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'phone',
|
||||
'company',
|
||||
'admin_notes',
|
||||
'virtfusion_user_id',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
|
||||
46
website/app/Notifications/AdminPasswordResetNotification.php
Normal file
46
website/app/Notifications/AdminPasswordResetNotification.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
52
website/app/Notifications/InvoiceNotification.php
Normal file
52
website/app/Notifications/InvoiceNotification.php
Normal 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!');
|
||||
}
|
||||
}
|
||||
34
website/app/Providers/HorizonServiceProvider.php
Normal file
34
website/app/Providers/HorizonServiceProvider.php
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"pest-testing",
|
||||
"tailwindcss-development"
|
||||
"inertia-vue-development"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -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
81
website/composer.lock
generated
@@ -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
254
website/config/horizon.php
Normal 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',
|
||||
],
|
||||
];
|
||||
@@ -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', ''),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -156,7 +156,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
'domain' => env('SESSION_DOMAIN', '.ezscale.dev'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
38
website/database/factories/CouponRedemptionFactory.php
Normal file
38
website/database/factories/CouponRedemptionFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ──────────────────────────────────
|
||||
[
|
||||
|
||||
39
website/database/seeders/UpdateVirtFusionPackageIds.php
Normal file
39
website/database/seeders/UpdateVirtFusionPackageIds.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
240
website/package-lock.json
generated
240
website/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
373
website/resources/ts/@core/components/AppStepper.vue
Normal file
373
website/resources/ts/@core/components/AppStepper.vue
Normal 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>
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ?? '∞' }}
|
||||
</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>
|
||||
|
||||
413
website/resources/ts/Pages/Admin/Coupons/Redemptions.vue
Normal file
413
website/resources/ts/Pages/Admin/Coupons/Redemptions.vue
Normal 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>
|
||||
387
website/resources/ts/Pages/Admin/Coupons/Show.vue
Normal file
387
website/resources/ts/Pages/Admin/Coupons/Show.vue
Normal 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 ?? '∞' }}
|
||||
</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>
|
||||
@@ -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>
|
||||
|
||||
296
website/resources/ts/Pages/Admin/Invoices/Create.vue
Normal file
296
website/resources/ts/Pages/Admin/Invoices/Create.vue
Normal 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>
|
||||
324
website/resources/ts/Pages/Admin/Invoices/Edit.vue
Normal file
324
website/resources/ts/Pages/Admin/Invoices/Edit.vue
Normal 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' }} · {{ 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }} · {{ 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>
|
||||
|
||||
@@ -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 → {{ billingForm.grace_period_days || 0 }} days grace period →
|
||||
Warning sent → {{ billingForm.suspension_warning_days || 0 }} days →
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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' }} ·
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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');
|
||||
|
||||
285
website/tests/Feature/Account/VpsControllerTest.php
Normal file
285
website/tests/Feature/Account/VpsControllerTest.php
Normal 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
|
||||
@@ -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();
|
||||
|
||||
|
||||
335
website/tests/Feature/Admin/CouponRedemptionTest.php
Normal file
335
website/tests/Feature/Admin/CouponRedemptionTest.php
Normal 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');
|
||||
});
|
||||
603
website/tests/Feature/Admin/InvoiceManagementTest.php
Normal file
603
website/tests/Feature/Admin/InvoiceManagementTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
355
website/tests/Feature/Admin/ServiceProvisioningTest.php
Normal file
355
website/tests/Feature/Admin/ServiceProvisioningTest.php
Normal 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);
|
||||
});
|
||||
234
website/tests/Feature/AuditLogExportTest.php
Normal file
234
website/tests/Feature/AuditLogExportTest.php
Normal 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
Reference in New Issue
Block a user