From de8ec69ea07a1987062f5cbcc743842b0861ee3bbc33c24a447a9eff894991c2 Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Tue, 17 Mar 2026 07:35:10 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20advanced=20features=20=E2=80=94?= =?UTF-8?q?=20KB,=20tickets=20v2,=20multi-currency,=20cart,=20quotes,=20af?= =?UTF-8?q?filiates,=20credits,=20staff=20RBAC,=20fraud=20detection,=20ser?= =?UTF-8?q?vice=20panels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major additions: - Knowledge base with categories, articles, revisions, and voting - Enhanced ticket system: departments, SLA policies, canned responses, tags, custom fields, satisfaction ratings, internal notes - Multi-currency support with exchange rate sync - Shopping cart and quote system with PDF generation - Affiliate program with referrals, commissions, and payouts - Account credits, credit notes, and debit notes - Staff management with granular role-based permissions - Fraud detection and order risk assessment - ServerHunter SEO integration - Service lifecycle events (suspend/unsuspend/terminate) - Service management panels for VPS, Dedicated, Hosting, and Game servers - Plan lifecycle fields and per-customer overrides - 30+ migrations, 17 factories, 8 feature test suites Co-Authored-By: Claude Opus 4.6 (1M context) --- ipv4-outreach-tickets.txt | 115 +++ .../src/Phases/Phase3Services.php | 2 + scripts/whmcs-migrate/src/StatusMapper.php | 7 +- website/.env.example | 3 + .../BackfillSubscriptionAmountsCommand.php | 141 +++ .../Commands/CheckSlaBreachCommand.php | 28 + .../Commands/SyncExchangeRatesCommand.php | 83 ++ .../Commands/SyncServerHunterCommand.php | 81 ++ website/app/Events/ServiceSuspended.php | 20 + website/app/Events/ServiceSuspending.php | 20 + website/app/Events/ServiceTerminated.php | 20 + website/app/Events/ServiceTerminating.php | 20 + website/app/Events/ServiceUnsuspended.php | 20 + website/app/Events/ServiceUnsuspending.php | 20 + .../Account/AffiliateController.php | 147 +++ .../Controllers/Account/BillingController.php | 14 + .../Controllers/Account/CartController.php | 126 +++ .../Dedicated/DedicatedPanelController.php | 194 ++++ .../Account/Game/GamePanelController.php | 238 +++++ .../Hosting/HostingPanelController.php | 285 ++++++ .../Account/Vps/VpsPanelController.php | 501 +++++++++++ .../Controllers/Admin/AffiliateController.php | 192 ++++ .../Admin/CannedResponseController.php | 122 +++ .../Admin/CreditNoteController.php | 152 ++++ .../Controllers/Admin/CurrencyController.php | 144 +++ .../Controllers/Admin/DashboardController.php | 59 +- .../Controllers/Admin/DebitNoteController.php | 152 ++++ .../Admin/FraudQueueController.php | 109 +++ .../Admin/KnowledgeBaseArticleController.php | 165 ++++ .../Admin/KnowledgeBaseCategoryController.php | 107 +++ .../Http/Controllers/Admin/PlanController.php | 8 + .../Controllers/Admin/QuoteController.php | 234 +++++ .../Http/Controllers/Admin/RoleController.php | 192 ++++ .../Controllers/Admin/StaffController.php | 99 +++ .../Controllers/Admin/TicketController.php | 191 +++- .../Admin/TicketSettingsController.php | 246 +++++ .../Api/ServerHunterController.php | 21 + .../Api/V1/Admin/AdminAnalyticsController.php | 31 +- .../Marketing/KnowledgeBaseController.php | 188 ++++ .../Marketing/QuoteAcceptController.php | 50 ++ .../Middleware/AllowServerHunterSpider.php | 33 + .../Middleware/TrackAffiliateReferral.php | 42 + .../Requests/Admin/StoreCreditNoteRequest.php | 38 + .../Requests/Admin/StoreDebitNoteRequest.php | 42 + .../Requests/Admin/StoreKbArticleRequest.php | 44 + .../Requests/Admin/StoreKbCategoryRequest.php | 41 + .../Http/Requests/Admin/StoreQuoteRequest.php | 46 + .../Http/Requests/Admin/StoreRoleRequest.php | 47 + .../Requests/Admin/UpdateCustomerRequest.php | 2 + .../Requests/Admin/UpdateKbArticleRequest.php | 46 + .../Admin/UpdateKbCategoryRequest.php | 43 + .../Http/Requests/Admin/UpdateRoleRequest.php | 49 + .../RequestAffiliatePayoutRequest.php | 33 + .../app/Http/Requests/StorePlanRequest.php | 4 + website/app/Models/AccountCredit.php | 48 + website/app/Models/Affiliate.php | 79 ++ website/app/Models/AffiliateCommission.php | 50 ++ website/app/Models/AffiliatePayout.php | 37 + website/app/Models/AffiliateReferral.php | 47 + website/app/Models/ArticleRevision.php | 27 + website/app/Models/ArticleVote.php | 36 + website/app/Models/CannedResponse.php | 36 + website/app/Models/CartItem.php | 44 + website/app/Models/CreditNote.php | 61 ++ website/app/Models/Currency.php | 46 + website/app/Models/DebitNote.php | 56 ++ website/app/Models/Department.php | 39 + website/app/Models/KnowledgeBaseArticle.php | 93 ++ website/app/Models/KnowledgeBaseCategory.php | 75 ++ website/app/Models/OrderRiskAssessment.php | 49 + website/app/Models/Plan.php | 21 + website/app/Models/Quote.php | 96 ++ website/app/Models/SlaBusinessHours.php | 25 + website/app/Models/SlaPolicy.php | 35 + website/app/Models/SupportTicket.php | 53 ++ website/app/Models/TicketCustomField.php | 40 + website/app/Models/TicketCustomFieldValue.php | 27 + website/app/Models/TicketReply.php | 2 + .../app/Models/TicketSatisfactionRating.php | 28 + website/app/Models/TicketTag.php | 24 + website/app/Models/User.php | 44 +- .../Notifications/QuoteSentNotification.php | 56 ++ .../ServiceSuspendedNotification.php | 64 ++ .../ServiceSuspensionWarningNotification.php | 64 ++ .../ServiceTerminatedNotification.php | 64 ++ .../ServiceTerminationWarningNotification.php | 64 ++ website/app/Services/AffiliateService.php | 138 +++ website/app/Services/Billing/CartService.php | 187 ++++ .../app/Services/Billing/CreditService.php | 273 ++++++ .../app/Services/Billing/CurrencyService.php | 84 ++ .../app/Services/Billing/DunningService.php | 293 +++++- .../app/Services/FraudDetectionService.php | 251 ++++++ .../Reports/FinancialReportService.php | 25 +- website/app/Services/ServerHunterService.php | 262 ++++++ website/app/Services/Support/SlaService.php | 192 ++++ website/bootstrap/app.php | 6 +- website/config/affiliate.php | 13 + website/config/billing.php | 2 + website/config/fraud.php | 28 + website/config/serverhunter.php | 41 + .../factories/AccountCreditFactory.php | 24 + .../factories/AffiliateCommissionFactory.php | 68 ++ .../database/factories/AffiliateFactory.php | 74 ++ .../factories/AffiliatePayoutFactory.php | 50 ++ .../factories/AffiliateReferralFactory.php | 44 + .../factories/CannedResponseFactory.php | 30 + .../database/factories/CartItemFactory.php | 39 + .../database/factories/CreditNoteFactory.php | 42 + .../database/factories/CurrencyFactory.php | 47 + .../database/factories/DebitNoteFactory.php | 43 + .../database/factories/DepartmentFactory.php | 32 + .../factories/KnowledgeBaseArticleFactory.php | 65 ++ .../KnowledgeBaseCategoryFactory.php | 47 + .../factories/OrderRiskAssessmentFactory.php | 89 ++ .../factories/PaymentTransactionFactory.php | 45 + website/database/factories/QuoteFactory.php | 94 ++ .../database/factories/SlaPolicyFactory.php | 37 + .../database/factories/TicketTagFactory.php | 25 + ...ecurring_amount_to_subscriptions_table.php | 24 + ...01_add_lifecycle_fields_to_plans_table.php | 32 + ...add_lifecycle_overrides_to_users_table.php | 30 + ...17_000010_create_account_credits_table.php | 34 + ...03_17_000011_create_credit_notes_table.php | 34 + ..._03_17_000012_create_debit_notes_table.php | 35 + ...0013_add_credit_balance_to_users_table.php | 28 + ...00020_seed_staff_roles_and_permissions.php | 165 ++++ ...create_knowledge_base_categories_table.php | 30 + ...2_create_knowledge_base_articles_table.php | 38 + ..._100003_create_article_revisions_table.php | 26 + ...3_17_100004_create_article_votes_table.php | 30 + ..._03_17_200001_create_departments_table.php | 29 + ...03_17_200002_create_sla_policies_table.php | 37 + ...200003_create_sla_business_hours_table.php | 27 + ...7_200004_create_canned_responses_table.php | 29 + ..._03_17_200005_create_ticket_tags_table.php | 25 + ...200006_create_support_ticket_tag_pivot.php | 24 + ...0007_create_ticket_custom_fields_table.php | 29 + ...reate_ticket_custom_field_values_table.php | 26 + ...eate_ticket_satisfaction_ratings_table.php | 27 + ...ticket_enhancements_to_support_tickets.php | 48 + ...0011_add_is_internal_to_ticket_replies.php | 24 + ...17_200012_migrate_departments_to_table.php | 47 + ...6_03_17_300001_create_currencies_table.php | 31 + ..._17_300002_add_currency_to_users_table.php | 24 + ...6_03_17_300003_create_cart_items_table.php | 34 + .../2026_03_17_300004_create_quotes_table.php | 41 + ...6_03_17_500001_create_affiliates_table.php | 37 + ...00002_create_affiliate_referrals_table.php | 34 + ...003_create_affiliate_commissions_table.php | 35 + ..._500004_create_affiliate_payouts_table.php | 33 + ...05_create_order_risk_assessments_table.php | 35 + .../database/seeders/ConfigOptionSeeder.php | 2 +- website/database/seeders/CurrencySeeder.php | 29 + .../seeders/RoleAndPermissionSeeder.php | 167 +++- .../ts/Components/Panel/ActivityLog.vue | 86 ++ .../ts/Components/Panel/BandwidthChart.vue | 111 +++ .../ts/Components/Panel/ResourceGauge.vue | 100 +++ .../ts/Components/Panel/ServiceTabLayout.vue | 117 +++ .../ts/Components/Panel/WebConsole.vue | 87 ++ .../Pages/Account/Affiliate/Commissions.vue | 99 +++ .../ts/Pages/Account/Affiliate/Dashboard.vue | 269 ++++++ .../ts/Pages/Account/Affiliate/Payouts.vue | 182 ++++ .../ts/Pages/Account/Affiliate/Referrals.vue | 93 ++ .../ts/Pages/Admin/Affiliates/Commissions.vue | 115 +++ .../ts/Pages/Admin/Affiliates/Index.vue | 218 +++++ .../ts/Pages/Admin/Affiliates/Payouts.vue | 132 +++ .../ts/Pages/Admin/Affiliates/Settings.vue | 107 +++ .../ts/Pages/Admin/Affiliates/Show.vue | 281 ++++++ .../ts/Pages/Admin/CreditNotes/Create.vue | 181 ++++ .../ts/Pages/Admin/CreditNotes/Index.vue | 216 +++++ .../ts/Pages/Admin/CreditNotes/Show.vue | 394 +++++++++ .../ts/Pages/Admin/Currencies/Index.vue | 402 +++++++++ .../ts/Pages/Admin/Customers/Edit.vue | 37 + .../resources/ts/Pages/Admin/Dashboard.vue | 22 +- .../ts/Pages/Admin/DebitNotes/Create.vue | 196 ++++ .../ts/Pages/Admin/DebitNotes/Index.vue | 226 +++++ .../ts/Pages/Admin/DebitNotes/Show.vue | 364 ++++++++ .../ts/Pages/Admin/FraudQueue/Index.vue | 236 +++++ .../ts/Pages/Admin/FraudQueue/Show.vue | 264 ++++++ .../Admin/KnowledgeBase/Articles/Create.vue | 186 ++++ .../Admin/KnowledgeBase/Articles/Edit.vue | 243 +++++ .../Admin/KnowledgeBase/Articles/Index.vue | 270 ++++++ .../Admin/KnowledgeBase/Articles/Show.vue | 141 +++ .../Admin/KnowledgeBase/Categories/Create.vue | 183 ++++ .../Admin/KnowledgeBase/Categories/Edit.vue | 213 +++++ .../Admin/KnowledgeBase/Categories/Index.vue | 151 ++++ .../resources/ts/Pages/Admin/Plans/Create.vue | 55 ++ .../resources/ts/Pages/Admin/Plans/Edit.vue | 55 ++ .../ts/Pages/Admin/Quotes/Create.vue | 311 +++++++ .../resources/ts/Pages/Admin/Quotes/Edit.vue | 324 +++++++ .../resources/ts/Pages/Admin/Quotes/Index.vue | 232 +++++ .../resources/ts/Pages/Admin/Quotes/Show.vue | 433 +++++++++ .../resources/ts/Pages/Admin/Staff/Index.vue | 256 ++++++ .../ts/Pages/Admin/Staff/Roles/Create.vue | 244 +++++ .../ts/Pages/Admin/Staff/Roles/Edit.vue | 275 ++++++ .../ts/Pages/Admin/Staff/Roles/Index.vue | 209 +++++ .../resources/ts/Pages/Admin/Staff/Show.vue | 246 +++++ .../Admin/Tickets/CannedResponses/Create.vue | 148 ++++ .../Admin/Tickets/CannedResponses/Edit.vue | 164 ++++ .../Admin/Tickets/CannedResponses/Index.vue | 174 ++++ .../ts/Pages/Admin/Tickets/Index.vue | 103 ++- .../ts/Pages/Admin/Tickets/Settings.vue | 837 ++++++++++++++++++ .../resources/ts/Pages/Admin/Tickets/Show.vue | 388 +++++++- .../resources/ts/Pages/Billing/Credits.vue | 164 ++++ website/resources/ts/Pages/Cart/Index.vue | 254 ++++++ .../Pages/Marketing/KnowledgeBase/Article.vue | 301 +++++++ .../Marketing/KnowledgeBase/Category.vue | 174 ++++ .../Pages/Marketing/KnowledgeBase/Index.vue | 246 +++++ .../resources/ts/Pages/Marketing/Pricing.vue | 57 +- website/resources/ts/Pages/Quotes/Show.vue | 329 +++++++ .../ts/Pages/Services/Dedicated/Bandwidth.vue | 46 + .../ts/Pages/Services/Dedicated/Console.vue | 44 + .../ts/Pages/Services/Dedicated/Dashboard.vue | 184 ++++ .../ts/Pages/Services/Dedicated/Network.vue | 108 +++ .../ts/Pages/Services/Dedicated/Reinstall.vue | 110 +++ .../ts/Pages/Services/Dedicated/Settings.vue | 60 ++ .../ts/Pages/Services/Game/Backups.vue | 93 ++ .../ts/Pages/Services/Game/Console.vue | 47 + .../ts/Pages/Services/Game/Dashboard.vue | 142 +++ .../ts/Pages/Services/Game/Databases.vue | 74 ++ .../ts/Pages/Services/Game/Files.vue | 95 ++ .../ts/Pages/Services/Game/Schedules.vue | 84 ++ .../ts/Pages/Services/Game/Settings.vue | 65 ++ .../ts/Pages/Services/Game/Startup.vue | 89 ++ .../ts/Pages/Services/Game/SubUsers.vue | 83 ++ .../ts/Pages/Services/Hosting/Cron.vue | 60 ++ .../ts/Pages/Services/Hosting/Dashboard.vue | 196 ++++ .../ts/Pages/Services/Hosting/Databases.vue | 58 ++ .../ts/Pages/Services/Hosting/Dns.vue | 60 ++ .../ts/Pages/Services/Hosting/Domains.vue | 60 ++ .../ts/Pages/Services/Hosting/Email.vue | 56 ++ .../ts/Pages/Services/Hosting/Files.vue | 84 ++ .../ts/Pages/Services/Hosting/Php.vue | 73 ++ .../ts/Pages/Services/Hosting/Settings.vue | 53 ++ .../ts/Pages/Services/Hosting/Ssl.vue | 72 ++ .../ts/Pages/Services/Vps/Activity.vue | 50 ++ .../ts/Pages/Services/Vps/Backups.vue | 94 ++ .../ts/Pages/Services/Vps/Console.vue | 53 ++ .../ts/Pages/Services/Vps/Dashboard.vue | 202 +++++ .../ts/Pages/Services/Vps/Firewall.vue | 186 ++++ .../ts/Pages/Services/Vps/Graphs.vue | 199 +++++ .../ts/Pages/Services/Vps/Network.vue | 171 ++++ .../ts/Pages/Services/Vps/Rebuild.vue | 140 +++ .../ts/Pages/Services/Vps/Settings.vue | 89 ++ .../ts/Pages/Services/Vps/Snapshots.vue | 184 ++++ website/resources/ts/navigation/account.ts | 4 + website/resources/ts/navigation/admin.ts | 10 + website/resources/ts/types/index.ts | 229 +++++ .../resources/views/pdf/credit-note.blade.php | 203 +++++ .../resources/views/pdf/debit-note.blade.php | 207 +++++ website/resources/views/pdf/quote.blade.php | 236 +++++ website/routes/account.php | 81 ++ website/routes/admin.php | 472 ++++++++-- website/routes/api.php | 15 + website/routes/console.php | 2 + website/routes/marketing.php | 14 + website/tests/Feature/AccountCreditTest.php | 422 +++++++++ .../Feature/Admin/StaffPermissionTest.php | 418 +++++++++ website/tests/Feature/AffiliateTest.php | 306 +++++++ website/tests/Feature/CartTest.php | 263 ++++++ website/tests/Feature/FraudDetectionTest.php | 215 +++++ website/tests/Feature/KnowledgeBaseTest.php | 390 ++++++++ website/tests/Feature/Models/UserTest.php | 1 + website/tests/Feature/MultiCurrencyTest.php | 208 +++++ website/tests/Feature/QuoteTest.php | 390 ++++++++ 265 files changed, 29892 insertions(+), 216 deletions(-) create mode 100644 ipv4-outreach-tickets.txt create mode 100644 website/app/Console/Commands/BackfillSubscriptionAmountsCommand.php create mode 100644 website/app/Console/Commands/CheckSlaBreachCommand.php create mode 100644 website/app/Console/Commands/SyncExchangeRatesCommand.php create mode 100644 website/app/Console/Commands/SyncServerHunterCommand.php create mode 100644 website/app/Events/ServiceSuspended.php create mode 100644 website/app/Events/ServiceSuspending.php create mode 100644 website/app/Events/ServiceTerminated.php create mode 100644 website/app/Events/ServiceTerminating.php create mode 100644 website/app/Events/ServiceUnsuspended.php create mode 100644 website/app/Events/ServiceUnsuspending.php create mode 100644 website/app/Http/Controllers/Account/AffiliateController.php create mode 100644 website/app/Http/Controllers/Account/CartController.php create mode 100644 website/app/Http/Controllers/Account/Dedicated/DedicatedPanelController.php create mode 100644 website/app/Http/Controllers/Account/Game/GamePanelController.php create mode 100644 website/app/Http/Controllers/Account/Hosting/HostingPanelController.php create mode 100644 website/app/Http/Controllers/Account/Vps/VpsPanelController.php create mode 100644 website/app/Http/Controllers/Admin/AffiliateController.php create mode 100644 website/app/Http/Controllers/Admin/CannedResponseController.php create mode 100644 website/app/Http/Controllers/Admin/CreditNoteController.php create mode 100644 website/app/Http/Controllers/Admin/CurrencyController.php create mode 100644 website/app/Http/Controllers/Admin/DebitNoteController.php create mode 100644 website/app/Http/Controllers/Admin/FraudQueueController.php create mode 100644 website/app/Http/Controllers/Admin/KnowledgeBaseArticleController.php create mode 100644 website/app/Http/Controllers/Admin/KnowledgeBaseCategoryController.php create mode 100644 website/app/Http/Controllers/Admin/QuoteController.php create mode 100644 website/app/Http/Controllers/Admin/RoleController.php create mode 100644 website/app/Http/Controllers/Admin/StaffController.php create mode 100644 website/app/Http/Controllers/Admin/TicketSettingsController.php create mode 100644 website/app/Http/Controllers/Api/ServerHunterController.php create mode 100644 website/app/Http/Controllers/Marketing/KnowledgeBaseController.php create mode 100644 website/app/Http/Controllers/Marketing/QuoteAcceptController.php create mode 100644 website/app/Http/Middleware/AllowServerHunterSpider.php create mode 100644 website/app/Http/Middleware/TrackAffiliateReferral.php create mode 100644 website/app/Http/Requests/Admin/StoreCreditNoteRequest.php create mode 100644 website/app/Http/Requests/Admin/StoreDebitNoteRequest.php create mode 100644 website/app/Http/Requests/Admin/StoreKbArticleRequest.php create mode 100644 website/app/Http/Requests/Admin/StoreKbCategoryRequest.php create mode 100644 website/app/Http/Requests/Admin/StoreQuoteRequest.php create mode 100644 website/app/Http/Requests/Admin/StoreRoleRequest.php create mode 100644 website/app/Http/Requests/Admin/UpdateKbArticleRequest.php create mode 100644 website/app/Http/Requests/Admin/UpdateKbCategoryRequest.php create mode 100644 website/app/Http/Requests/Admin/UpdateRoleRequest.php create mode 100644 website/app/Http/Requests/RequestAffiliatePayoutRequest.php create mode 100644 website/app/Models/AccountCredit.php create mode 100644 website/app/Models/Affiliate.php create mode 100644 website/app/Models/AffiliateCommission.php create mode 100644 website/app/Models/AffiliatePayout.php create mode 100644 website/app/Models/AffiliateReferral.php create mode 100644 website/app/Models/ArticleRevision.php create mode 100644 website/app/Models/ArticleVote.php create mode 100644 website/app/Models/CannedResponse.php create mode 100644 website/app/Models/CartItem.php create mode 100644 website/app/Models/CreditNote.php create mode 100644 website/app/Models/Currency.php create mode 100644 website/app/Models/DebitNote.php create mode 100644 website/app/Models/Department.php create mode 100644 website/app/Models/KnowledgeBaseArticle.php create mode 100644 website/app/Models/KnowledgeBaseCategory.php create mode 100644 website/app/Models/OrderRiskAssessment.php create mode 100644 website/app/Models/Quote.php create mode 100644 website/app/Models/SlaBusinessHours.php create mode 100644 website/app/Models/SlaPolicy.php create mode 100644 website/app/Models/TicketCustomField.php create mode 100644 website/app/Models/TicketCustomFieldValue.php create mode 100644 website/app/Models/TicketSatisfactionRating.php create mode 100644 website/app/Models/TicketTag.php create mode 100644 website/app/Notifications/QuoteSentNotification.php create mode 100644 website/app/Notifications/ServiceSuspendedNotification.php create mode 100644 website/app/Notifications/ServiceSuspensionWarningNotification.php create mode 100644 website/app/Notifications/ServiceTerminatedNotification.php create mode 100644 website/app/Notifications/ServiceTerminationWarningNotification.php create mode 100644 website/app/Services/AffiliateService.php create mode 100644 website/app/Services/Billing/CartService.php create mode 100644 website/app/Services/Billing/CreditService.php create mode 100644 website/app/Services/Billing/CurrencyService.php create mode 100644 website/app/Services/FraudDetectionService.php create mode 100644 website/app/Services/ServerHunterService.php create mode 100644 website/app/Services/Support/SlaService.php create mode 100644 website/config/affiliate.php create mode 100644 website/config/fraud.php create mode 100644 website/config/serverhunter.php create mode 100644 website/database/factories/AccountCreditFactory.php create mode 100644 website/database/factories/AffiliateCommissionFactory.php create mode 100644 website/database/factories/AffiliateFactory.php create mode 100644 website/database/factories/AffiliatePayoutFactory.php create mode 100644 website/database/factories/AffiliateReferralFactory.php create mode 100644 website/database/factories/CannedResponseFactory.php create mode 100644 website/database/factories/CartItemFactory.php create mode 100644 website/database/factories/CreditNoteFactory.php create mode 100644 website/database/factories/CurrencyFactory.php create mode 100644 website/database/factories/DebitNoteFactory.php create mode 100644 website/database/factories/DepartmentFactory.php create mode 100644 website/database/factories/KnowledgeBaseArticleFactory.php create mode 100644 website/database/factories/KnowledgeBaseCategoryFactory.php create mode 100644 website/database/factories/OrderRiskAssessmentFactory.php create mode 100644 website/database/factories/PaymentTransactionFactory.php create mode 100644 website/database/factories/QuoteFactory.php create mode 100644 website/database/factories/SlaPolicyFactory.php create mode 100644 website/database/factories/TicketTagFactory.php create mode 100644 website/database/migrations/2026_03_16_180347_add_recurring_amount_to_subscriptions_table.php create mode 100644 website/database/migrations/2026_03_17_000001_add_lifecycle_fields_to_plans_table.php create mode 100644 website/database/migrations/2026_03_17_000002_add_lifecycle_overrides_to_users_table.php create mode 100644 website/database/migrations/2026_03_17_000010_create_account_credits_table.php create mode 100644 website/database/migrations/2026_03_17_000011_create_credit_notes_table.php create mode 100644 website/database/migrations/2026_03_17_000012_create_debit_notes_table.php create mode 100644 website/database/migrations/2026_03_17_000013_add_credit_balance_to_users_table.php create mode 100644 website/database/migrations/2026_03_17_000020_seed_staff_roles_and_permissions.php create mode 100644 website/database/migrations/2026_03_17_100001_create_knowledge_base_categories_table.php create mode 100644 website/database/migrations/2026_03_17_100002_create_knowledge_base_articles_table.php create mode 100644 website/database/migrations/2026_03_17_100003_create_article_revisions_table.php create mode 100644 website/database/migrations/2026_03_17_100004_create_article_votes_table.php create mode 100644 website/database/migrations/2026_03_17_200001_create_departments_table.php create mode 100644 website/database/migrations/2026_03_17_200002_create_sla_policies_table.php create mode 100644 website/database/migrations/2026_03_17_200003_create_sla_business_hours_table.php create mode 100644 website/database/migrations/2026_03_17_200004_create_canned_responses_table.php create mode 100644 website/database/migrations/2026_03_17_200005_create_ticket_tags_table.php create mode 100644 website/database/migrations/2026_03_17_200006_create_support_ticket_tag_pivot.php create mode 100644 website/database/migrations/2026_03_17_200007_create_ticket_custom_fields_table.php create mode 100644 website/database/migrations/2026_03_17_200008_create_ticket_custom_field_values_table.php create mode 100644 website/database/migrations/2026_03_17_200009_create_ticket_satisfaction_ratings_table.php create mode 100644 website/database/migrations/2026_03_17_200010_add_ticket_enhancements_to_support_tickets.php create mode 100644 website/database/migrations/2026_03_17_200011_add_is_internal_to_ticket_replies.php create mode 100644 website/database/migrations/2026_03_17_200012_migrate_departments_to_table.php create mode 100644 website/database/migrations/2026_03_17_300001_create_currencies_table.php create mode 100644 website/database/migrations/2026_03_17_300002_add_currency_to_users_table.php create mode 100644 website/database/migrations/2026_03_17_300003_create_cart_items_table.php create mode 100644 website/database/migrations/2026_03_17_300004_create_quotes_table.php create mode 100644 website/database/migrations/2026_03_17_500001_create_affiliates_table.php create mode 100644 website/database/migrations/2026_03_17_500002_create_affiliate_referrals_table.php create mode 100644 website/database/migrations/2026_03_17_500003_create_affiliate_commissions_table.php create mode 100644 website/database/migrations/2026_03_17_500004_create_affiliate_payouts_table.php create mode 100644 website/database/migrations/2026_03_17_500005_create_order_risk_assessments_table.php create mode 100644 website/database/seeders/CurrencySeeder.php create mode 100644 website/resources/ts/Components/Panel/ActivityLog.vue create mode 100644 website/resources/ts/Components/Panel/BandwidthChart.vue create mode 100644 website/resources/ts/Components/Panel/ResourceGauge.vue create mode 100644 website/resources/ts/Components/Panel/ServiceTabLayout.vue create mode 100644 website/resources/ts/Components/Panel/WebConsole.vue create mode 100644 website/resources/ts/Pages/Account/Affiliate/Commissions.vue create mode 100644 website/resources/ts/Pages/Account/Affiliate/Dashboard.vue create mode 100644 website/resources/ts/Pages/Account/Affiliate/Payouts.vue create mode 100644 website/resources/ts/Pages/Account/Affiliate/Referrals.vue create mode 100644 website/resources/ts/Pages/Admin/Affiliates/Commissions.vue create mode 100644 website/resources/ts/Pages/Admin/Affiliates/Index.vue create mode 100644 website/resources/ts/Pages/Admin/Affiliates/Payouts.vue create mode 100644 website/resources/ts/Pages/Admin/Affiliates/Settings.vue create mode 100644 website/resources/ts/Pages/Admin/Affiliates/Show.vue create mode 100644 website/resources/ts/Pages/Admin/CreditNotes/Create.vue create mode 100644 website/resources/ts/Pages/Admin/CreditNotes/Index.vue create mode 100644 website/resources/ts/Pages/Admin/CreditNotes/Show.vue create mode 100644 website/resources/ts/Pages/Admin/Currencies/Index.vue create mode 100644 website/resources/ts/Pages/Admin/DebitNotes/Create.vue create mode 100644 website/resources/ts/Pages/Admin/DebitNotes/Index.vue create mode 100644 website/resources/ts/Pages/Admin/DebitNotes/Show.vue create mode 100644 website/resources/ts/Pages/Admin/FraudQueue/Index.vue create mode 100644 website/resources/ts/Pages/Admin/FraudQueue/Show.vue create mode 100644 website/resources/ts/Pages/Admin/KnowledgeBase/Articles/Create.vue create mode 100644 website/resources/ts/Pages/Admin/KnowledgeBase/Articles/Edit.vue create mode 100644 website/resources/ts/Pages/Admin/KnowledgeBase/Articles/Index.vue create mode 100644 website/resources/ts/Pages/Admin/KnowledgeBase/Articles/Show.vue create mode 100644 website/resources/ts/Pages/Admin/KnowledgeBase/Categories/Create.vue create mode 100644 website/resources/ts/Pages/Admin/KnowledgeBase/Categories/Edit.vue create mode 100644 website/resources/ts/Pages/Admin/KnowledgeBase/Categories/Index.vue create mode 100644 website/resources/ts/Pages/Admin/Quotes/Create.vue create mode 100644 website/resources/ts/Pages/Admin/Quotes/Edit.vue create mode 100644 website/resources/ts/Pages/Admin/Quotes/Index.vue create mode 100644 website/resources/ts/Pages/Admin/Quotes/Show.vue create mode 100644 website/resources/ts/Pages/Admin/Staff/Index.vue create mode 100644 website/resources/ts/Pages/Admin/Staff/Roles/Create.vue create mode 100644 website/resources/ts/Pages/Admin/Staff/Roles/Edit.vue create mode 100644 website/resources/ts/Pages/Admin/Staff/Roles/Index.vue create mode 100644 website/resources/ts/Pages/Admin/Staff/Show.vue create mode 100644 website/resources/ts/Pages/Admin/Tickets/CannedResponses/Create.vue create mode 100644 website/resources/ts/Pages/Admin/Tickets/CannedResponses/Edit.vue create mode 100644 website/resources/ts/Pages/Admin/Tickets/CannedResponses/Index.vue create mode 100644 website/resources/ts/Pages/Admin/Tickets/Settings.vue create mode 100644 website/resources/ts/Pages/Billing/Credits.vue create mode 100644 website/resources/ts/Pages/Cart/Index.vue create mode 100644 website/resources/ts/Pages/Marketing/KnowledgeBase/Article.vue create mode 100644 website/resources/ts/Pages/Marketing/KnowledgeBase/Category.vue create mode 100644 website/resources/ts/Pages/Marketing/KnowledgeBase/Index.vue create mode 100644 website/resources/ts/Pages/Quotes/Show.vue create mode 100644 website/resources/ts/Pages/Services/Dedicated/Bandwidth.vue create mode 100644 website/resources/ts/Pages/Services/Dedicated/Console.vue create mode 100644 website/resources/ts/Pages/Services/Dedicated/Dashboard.vue create mode 100644 website/resources/ts/Pages/Services/Dedicated/Network.vue create mode 100644 website/resources/ts/Pages/Services/Dedicated/Reinstall.vue create mode 100644 website/resources/ts/Pages/Services/Dedicated/Settings.vue create mode 100644 website/resources/ts/Pages/Services/Game/Backups.vue create mode 100644 website/resources/ts/Pages/Services/Game/Console.vue create mode 100644 website/resources/ts/Pages/Services/Game/Dashboard.vue create mode 100644 website/resources/ts/Pages/Services/Game/Databases.vue create mode 100644 website/resources/ts/Pages/Services/Game/Files.vue create mode 100644 website/resources/ts/Pages/Services/Game/Schedules.vue create mode 100644 website/resources/ts/Pages/Services/Game/Settings.vue create mode 100644 website/resources/ts/Pages/Services/Game/Startup.vue create mode 100644 website/resources/ts/Pages/Services/Game/SubUsers.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Cron.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Dashboard.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Databases.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Dns.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Domains.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Email.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Files.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Php.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Settings.vue create mode 100644 website/resources/ts/Pages/Services/Hosting/Ssl.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Activity.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Backups.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Console.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Dashboard.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Firewall.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Graphs.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Network.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Rebuild.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Settings.vue create mode 100644 website/resources/ts/Pages/Services/Vps/Snapshots.vue create mode 100644 website/resources/views/pdf/credit-note.blade.php create mode 100644 website/resources/views/pdf/debit-note.blade.php create mode 100644 website/resources/views/pdf/quote.blade.php create mode 100644 website/tests/Feature/AccountCreditTest.php create mode 100644 website/tests/Feature/Admin/StaffPermissionTest.php create mode 100644 website/tests/Feature/AffiliateTest.php create mode 100644 website/tests/Feature/CartTest.php create mode 100644 website/tests/Feature/FraudDetectionTest.php create mode 100644 website/tests/Feature/KnowledgeBaseTest.php create mode 100644 website/tests/Feature/MultiCurrencyTest.php create mode 100644 website/tests/Feature/QuoteTest.php diff --git a/ipv4-outreach-tickets.txt b/ipv4-outreach-tickets.txt new file mode 100644 index 0000000..c83001c --- /dev/null +++ b/ipv4-outreach-tickets.txt @@ -0,0 +1,115 @@ +== TICKET 1 == +EMAIL: updates@hostigol.com +SUBJECT: Your IPv4 pricing — let's talk +BODY: +Hi there, + +I hope you're doing well. I wanted to personally reach out regarding the recent IPv4 pricing update, as I know this affects a good number of your services with us. + +Looking at your account, this impacts 11 of your VPS services with a total of 41 additional IPv4 addresses. Your current IP add-on cost is $123.00/month, which will move to $328.00/month at the new $8/IP rate — an increase of $205.00/month. + +I completely understand that's a significant change, and we genuinely value the trust you've placed in us. A couple of things that might help: + +• If there are any IPs across your services that you're no longer actively using, we're happy to remove those and bring your cost down +• IPv6 is included at no charge with every VPS — if any of your use cases can work with IPv6, that's another way to offset the increase + +Please don't hesitate to reply here — we're happy to chat about what works best for you. + +Thank you, + +== TICKET 2 == +EMAIL: silvernetservers@gmail.com +SUBJECT: Your IPv4 pricing — let's talk +BODY: +Hi Parind, + +I hope you're doing well. I wanted to personally reach out regarding the recent IPv4 pricing update, as this affects several of your services. + +This impacts 6 of your VPS services with a total of 24 additional IPv4 addresses. Your current IP add-on cost is $72.00/month, which will move to $192.00/month at the new $8/IP rate — an increase of $120.00/month. + +A couple of things that might help: +• If any IPs aren't actively in use, we're happy to remove them and lower your cost +• IPv6 is included at no charge if any of your services can switch over + +Please reply here and let us know how you'd like to proceed. + +Thank you, + +== TICKET 3 == +EMAIL: zoneworxlimited@gmail.com +SUBJECT: Your IPv4 pricing — let's talk +BODY: +Hi, + +I hope you're doing well. I wanted to personally reach out regarding the recent IPv4 pricing update, as this affects several of your services. + +This impacts 4 of your VPS services with a total of 16 additional IPv4 addresses. Your current IP add-on cost is $48.00/month, which will move to $128.00/month — an increase of $80.00/month. + +A couple of options: +• If any of your additional IPs aren't actively needed, we can remove them to bring your cost down +• IPv6 is available at no extra charge if any of your use cases can switch over + +Please reply here and let us know how you'd like to proceed. + +Thank you, + +== TICKET 4 == +EMAIL: fzguiloui@pimarketing.co +SUBJECT: Following up on the IPv4 pricing update +BODY: +Hi Fatima, + +I wanted to follow up on the IPv4 pricing change. You have 7 additional IPs on your Mini VPS, and the new pricing will change your IP add-on cost from $21.00/month to $56.00/month — an increase of $35.00/month. + +A few things to consider: +• If any of those IPs aren't actively needed, we can remove them right away to lower your cost +• If you need the IPs but the new rate is difficult, please let me know — I'd rather work something out than lose you as a customer +• IPv6 is available at no extra charge if any of your use cases can switch over + +Just reply here and we'll figure out the best option for you. + +Thank you, + +== TICKET 5 == +EMAIL: manoj.niks@gmail.com +SUBJECT: Quick note about the IPv4 update +BODY: +Hi Manoj, + +I hope you're doing well. Just a quick note about the recent IPv4 pricing change — it affects 4 of your Standard VPS services that each have an extra IP. + +Your IP add-on cost will go from $12.00/month to $32.00/month — an increase of $20.00/month. + +If any of those extra IPs aren't something you're actively using, just let me know and we'll get them removed for you. Otherwise, no action needed on your end. + +Feel free to reach out if you have any questions. + +Thank you, + +== TICKET 6 == +EMAIL: contact@oomos.com +SUBJECT: Following up on the IPv4 pricing update +BODY: +Hi, + +I hope you're doing well. I wanted to follow up on the IPv4 pricing change. You have 3 additional IPs on your Basic VPS, and the new pricing will change your IP add-on cost from $9.00/month to $24.00/month — an increase of $15.00/month. + +If any of those IPs aren't actively needed, we can remove them to bring your cost down. IPv6 is also available at no extra charge if any of your use cases can switch over. + +Just reply here if you have any questions or would like to make changes. + +Thank you, + +== TICKET 7 == +EMAIL: aartisakhare138@gmail.com +SUBJECT: Following up on the IPv4 pricing update +BODY: +Hi Aarti, + +I hope you're doing well. Just a quick personal follow-up on the IPv4 pricing change — you have 2 additional IPs on your Dev Starter VPS, and your IP add-on cost will go from $6.00/month to $16.00/month. + +If either of those extra IPs isn't something you need anymore, just let me know and we'll take care of it. Otherwise, no worries at all. + +Feel free to reach out if you have questions. + +Thank you, diff --git a/scripts/whmcs-migrate/src/Phases/Phase3Services.php b/scripts/whmcs-migrate/src/Phases/Phase3Services.php index d5b0446..646cc30 100644 --- a/scripts/whmcs-migrate/src/Phases/Phase3Services.php +++ b/scripts/whmcs-migrate/src/Phases/Phase3Services.php @@ -392,6 +392,7 @@ final class Phase3Services extends AbstractPhase $isSuspended = strtolower($whmcsStatus) === 'suspended'; $isTerminated = in_array(strtolower($whmcsStatus), ['terminated', 'cancelled'], true); + $recurringAmount = $this->nullIfEmpty((string) ($product['recurringamount'] ?? '')); $dedicatedIp = $this->nullIfEmpty((string) ($product['dedicatedip'] ?? '')); $domain = $this->nullIfEmpty((string) ($product['domain'] ?? '')); @@ -414,6 +415,7 @@ final class Phase3Services extends AbstractPhase 'stripe_price' => null, 'quantity' => 1, 'billing_cycle' => $billingCycle, + 'recurring_amount' => $recurringAmount, 'current_period_end' => $nextDueDate, 'created_at' => $regDate ?? $now, 'updated_at' => $now, diff --git a/scripts/whmcs-migrate/src/StatusMapper.php b/scripts/whmcs-migrate/src/StatusMapper.php index 2aa2f25..4682166 100644 --- a/scripts/whmcs-migrate/src/StatusMapper.php +++ b/scripts/whmcs-migrate/src/StatusMapper.php @@ -148,15 +148,16 @@ final class StatusMapper * * Returns null for service types that have no auto-provisioning platform. */ - public static function mapPlatform(string $serviceType): ?string + public static function mapPlatform(string $serviceType): string { return match ($serviceType) { 'vps' => 'virtfusion', 'dedicated' => 'synergycp', 'hosting' => 'enhance', 'game_server' => 'pterodactyl', - 'mysql' => null, - 'backups' => null, + 'mysql' => 'manual', + 'backups' => 'manual', + 'other' => 'manual', default => 'virtfusion', }; } diff --git a/website/.env.example b/website/.env.example index 1af35a5..89c3990 100644 --- a/website/.env.example +++ b/website/.env.example @@ -98,4 +98,7 @@ IMAP_VALIDATE_CERT=true IMAP_PROTOCOL=imap IMAP_FOLDER=INBOX +# ServerHunter Integration +SERVERHUNTER_API_KEY= + VITE_APP_NAME="${APP_NAME}" diff --git a/website/app/Console/Commands/BackfillSubscriptionAmountsCommand.php b/website/app/Console/Commands/BackfillSubscriptionAmountsCommand.php new file mode 100644 index 0000000..8dc06c9 --- /dev/null +++ b/website/app/Console/Commands/BackfillSubscriptionAmountsCommand.php @@ -0,0 +1,141 @@ +option('dry-run'); + + if ($dryRun) { + $this->warn('DRY RUN — no changes will be written.'); + } + + $subscriptions = Subscription::query() + ->where('stripe_status', 'active') + ->whereNull('recurring_amount') + ->whereNotNull('plan_id') + ->with('user') + ->get(); + + if ($subscriptions->isEmpty()) { + $this->info('No active subscriptions without recurring_amount found.'); + + return self::SUCCESS; + } + + $this->info("Found {$subscriptions->count()} subscription(s) to backfill."); + + $fromInvoice = 0; + $fromPlanPrice = 0; + $fromPlanBase = 0; + $skipped = 0; + + foreach ($subscriptions as $subscription) { + $amount = null; + $source = 'none'; + + // Strategy 1: Find invoice items matching the plan name + $plan = DB::table('plans')->where('id', $subscription->plan_id)->first(); + + if (! $plan) { + $this->warn(" Subscription #{$subscription->id}: no plan found (plan_id={$subscription->plan_id}), skipping."); + $skipped++; + + continue; + } + + // Look for the most recent paid invoice for this user that has items + // referencing the plan name + $invoiceAmount = Invoice::query() + ->where('user_id', $subscription->user_id) + ->where('status', 'paid') + ->whereHas('items', function ($query) use ($plan): void { + $query->where('description', 'like', '%'.$plan->name.'%'); + }) + ->orderByDesc('paid_at') + ->first(); + + if ($invoiceAmount) { + // Sum the invoice items that match the plan name + $matchingItemsTotal = (float) $invoiceAmount->items() + ->where('description', 'like', '%'.$plan->name.'%') + ->sum(DB::raw('amount * quantity')); + + if ($matchingItemsTotal > 0) { + $amount = $matchingItemsTotal; + $source = 'invoice'; + } + } + + // Strategy 2: Fall back to plan_prices table + if ($amount === null) { + $planPrice = PlanPrice::query() + ->where('plan_id', $subscription->plan_id) + ->where('billing_cycle', $subscription->billing_cycle ?? 'monthly') + ->first(); + + if ($planPrice) { + $amount = (float) $planPrice->price; + $source = 'plan_price'; + } + } + + // Strategy 3: Fall back to plans.price base + if ($amount === null && $plan->price > 0) { + $amount = (float) $plan->price; + $source = 'plan_base'; + } + + if ($amount === null) { + $this->warn(" Subscription #{$subscription->id} (plan: {$plan->name}): no amount found, skipping."); + $skipped++; + + continue; + } + + $userName = $subscription->user?->name ?? "user #{$subscription->user_id}"; + $this->info(" Subscription #{$subscription->id} ({$userName}, plan: {$plan->name}, cycle: {$subscription->billing_cycle}): \${$amount} [source: {$source}]"); + + if (! $dryRun) { + Subscription::query() + ->where('id', $subscription->id) + ->update(['recurring_amount' => $amount]); + } + + match ($source) { + 'invoice' => $fromInvoice++, + 'plan_price' => $fromPlanPrice++, + 'plan_base' => $fromPlanBase++, + default => $skipped++, + }; + } + + $this->newLine(); + $this->info('Backfill summary:'); + $this->info(" From invoice items: {$fromInvoice}"); + $this->info(" From plan prices: {$fromPlanPrice}"); + $this->info(" From plan base: {$fromPlanBase}"); + $this->info(" Skipped: {$skipped}"); + + if ($dryRun) { + $this->warn('DRY RUN — no changes were written. Run without --dry-run to apply.'); + } + + return self::SUCCESS; + } +} diff --git a/website/app/Console/Commands/CheckSlaBreachCommand.php b/website/app/Console/Commands/CheckSlaBreachCommand.php new file mode 100644 index 0000000..ca31d2d --- /dev/null +++ b/website/app/Console/Commands/CheckSlaBreachCommand.php @@ -0,0 +1,28 @@ +checkBreaches(); + + if ($breachCount > 0) { + $this->warn("Found {$breachCount} SLA breach(es)."); + } else { + $this->info('No SLA breaches found.'); + } + + return self::SUCCESS; + } +} diff --git a/website/app/Console/Commands/SyncExchangeRatesCommand.php b/website/app/Console/Commands/SyncExchangeRatesCommand.php new file mode 100644 index 0000000..9297fa9 --- /dev/null +++ b/website/app/Console/Commands/SyncExchangeRatesCommand.php @@ -0,0 +1,83 @@ +base()->first(); + + if (! $baseCurrency) { + $this->error('No base currency configured.'); + + return self::FAILURE; + } + + $this->info("Syncing exchange rates relative to {$baseCurrency->code}..."); + + try { + $response = Http::timeout(15)->get('https://api.exchangerate.host/latest', [ + 'base' => $baseCurrency->code, + ]); + + if (! $response->successful()) { + $this->error('Failed to fetch exchange rates: HTTP '.$response->status()); + Log::error('Exchange rate sync failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return self::FAILURE; + } + + $data = $response->json(); + $rates = $data['rates'] ?? []; + + if (empty($rates)) { + $this->warn('No rates returned from API.'); + + return self::FAILURE; + } + + $currencies = Currency::query()->where('is_base', false)->get(); + $updated = 0; + + foreach ($currencies as $currency) { + if (isset($rates[$currency->code])) { + $currency->update([ + 'exchange_rate' => $rates[$currency->code], + 'last_synced_at' => now(), + ]); + $updated++; + $this->line(" {$currency->code}: {$rates[$currency->code]}"); + } + } + + // Clear currency cache + Cache::forget('currencies:enabled'); + Cache::forget('currencies:base'); + + $this->info("Updated {$updated} exchange rates."); + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->error('Exchange rate sync error: '.$e->getMessage()); + Log::error('Exchange rate sync exception', ['error' => $e->getMessage()]); + + return self::FAILURE; + } + } +} diff --git a/website/app/Console/Commands/SyncServerHunterCommand.php b/website/app/Console/Commands/SyncServerHunterCommand.php new file mode 100644 index 0000000..42bf6ae --- /dev/null +++ b/website/app/Console/Commands/SyncServerHunterCommand.php @@ -0,0 +1,81 @@ +serverHunterService->buildFeed(); + $offerCount = count($feed['offers']); + + if ($offerCount === 0) { + $this->warn('No active VPS or dedicated plans found. Nothing to sync.'); + + return self::SUCCESS; + } + + $this->info("Found {$offerCount} offer(s) to sync with ServerHunter."); + + if ($this->option('dry-run')) { + $this->info('Dry run — not pushing to API. Feed preview:'); + $this->newLine(); + + foreach ($feed['offers'] as $offer) { + $this->line(sprintf( + ' [%s] %s — $%s/mo (%s, %s cores, %sMB RAM, %sGB %s)', + $offer['product_type'], + $offer['name'], + $offer['price'], + $offer['cpu_name'], + $offer['cpu_cores'], + $offer['memory_amount'], + $offer['ssd_capacity'] > 0 ? $offer['ssd_capacity'] : $offer['hdd_capacity'], + $offer['ssd_capacity'] > 0 ? 'SSD/NVMe' : 'HDD', + )); + } + + $this->newLine(); + $this->info('Full JSON output:'); + $this->line(json_encode($feed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + $this->info('Pushing offers to ServerHunter API...'); + + try { + $result = $this->serverHunterService->pushToApi($feed); + + if ($result['successful']) { + $this->info("Successfully pushed {$offerCount} offer(s) to ServerHunter."); + + return self::SUCCESS; + } + + $this->error("ServerHunter API returned HTTP {$result['status']}."); + $this->line(json_encode($result['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return self::FAILURE; + } catch (\RuntimeException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/website/app/Events/ServiceSuspended.php b/website/app/Events/ServiceSuspended.php new file mode 100644 index 0000000..28674b0 --- /dev/null +++ b/website/app/Events/ServiceSuspended.php @@ -0,0 +1,20 @@ +user(); + $affiliate = $user->affiliate; + + if (! $affiliate) { + return Inertia::render('Account/Affiliate/Dashboard', [ + 'affiliate' => null, + 'stats' => null, + ]); + } + + $affiliate->loadCount('referrals', 'commissions', 'payouts'); + + $monthlyEarnings = $affiliate->commissions() + ->where('created_at', '>=', now()->startOfMonth()) + ->sum('amount'); + + $monthlyChart = $affiliate->commissions() + ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total") + ->where('created_at', '>=', now()->subMonths(6)) + ->groupByRaw("DATE_FORMAT(created_at, '%Y-%m')") + ->orderBy('month') + ->get(); + + return Inertia::render('Account/Affiliate/Dashboard', [ + 'affiliate' => $affiliate, + 'stats' => [ + 'total_earned' => (float) $affiliate->total_earned, + 'pending_balance' => (float) $affiliate->pending_balance, + 'total_paid' => (float) $affiliate->total_paid, + 'monthly_earnings' => (float) $monthlyEarnings, + 'referral_count' => $affiliate->referrals_count, + 'commission_count' => $affiliate->commissions_count, + ], + 'monthlyChart' => $monthlyChart, + ]); + } + + public function referrals(Request $request): Response + { + $affiliate = $request->user()->affiliate; + + if (! $affiliate) { + return redirect()->route('account.affiliate.dashboard'); + } + + $referrals = $affiliate->referrals() + ->with('referredUser:id,name,email') + ->latest() + ->paginate(25); + + return Inertia::render('Account/Affiliate/Referrals', [ + 'referrals' => $referrals, + 'affiliate' => $affiliate, + ]); + } + + public function commissions(Request $request): Response + { + $affiliate = $request->user()->affiliate; + + if (! $affiliate) { + return redirect()->route('account.affiliate.dashboard'); + } + + $commissions = $affiliate->commissions() + ->latest() + ->paginate(25); + + return Inertia::render('Account/Affiliate/Commissions', [ + 'commissions' => $commissions, + 'affiliate' => $affiliate, + ]); + } + + public function payouts(Request $request): Response + { + $affiliate = $request->user()->affiliate; + + if (! $affiliate) { + return redirect()->route('account.affiliate.dashboard'); + } + + $payouts = $affiliate->payouts() + ->latest() + ->paginate(25); + + return Inertia::render('Account/Affiliate/Payouts', [ + 'payouts' => $payouts, + 'affiliate' => $affiliate, + ]); + } + + public function requestPayout(RequestAffiliatePayoutRequest $request): RedirectResponse + { + $affiliate = $request->user()->affiliate; + + if (! $affiliate || $affiliate->status !== 'active') { + return back()->with('error', 'You do not have an active affiliate account.'); + } + + try { + $this->affiliateService->requestPayout( + $affiliate, + $request->validated('method'), + ); + + return back()->with('success', 'Payout request submitted successfully.'); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage()); + } + } + + public function apply(Request $request): RedirectResponse + { + $user = $request->user(); + + if ($user->affiliate) { + return back()->with('error', 'You already have an affiliate account.'); + } + + $this->affiliateService->apply($user); + + return back()->with('success', 'Your affiliate application has been submitted for review.'); + } +} diff --git a/website/app/Http/Controllers/Account/BillingController.php b/website/app/Http/Controllers/Account/BillingController.php index 032f1b4..f492d14 100644 --- a/website/app/Http/Controllers/Account/BillingController.php +++ b/website/app/Http/Controllers/Account/BillingController.php @@ -149,6 +149,20 @@ class BillingController extends Controller ]); } + public function credits(Request $request): Response + { + $user = $request->user(); + + $credits = $user->accountCredits() + ->latest() + ->paginate(20); + + return Inertia::render('Billing/Credits', [ + 'credits' => $credits, + 'creditBalance' => (float) $user->credit_balance, + ]); + } + public function upcomingRenewals(Request $request): Response { $user = $request->user(); diff --git a/website/app/Http/Controllers/Account/CartController.php b/website/app/Http/Controllers/Account/CartController.php new file mode 100644 index 0000000..9f97c48 --- /dev/null +++ b/website/app/Http/Controllers/Account/CartController.php @@ -0,0 +1,126 @@ +user(); + + $items = $this->cartService->getItems($user); + $total = $this->cartService->getTotal($user); + + // Compute per-item prices + $itemsWithPrices = $items->map(function (CartItem $item): array { + $unitPrice = $this->cartService->getItemPrice($item); + + return [ + 'id' => $item->id, + 'plan_id' => $item->plan_id, + 'billing_cycle' => $item->billing_cycle, + 'quantity' => $item->quantity, + 'config_selections' => $item->config_selections, + 'coupon_code' => $item->coupon_code, + 'unit_price' => number_format($unitPrice, 2, '.', ''), + 'line_total' => number_format($unitPrice * $item->quantity, 2, '.', ''), + 'plan' => $item->plan ? [ + 'id' => $item->plan->id, + 'name' => $item->plan->name, + 'service_type' => $item->plan->service_type, + 'description' => $item->plan->description, + 'features' => $item->plan->features, + ] : null, + ]; + }); + + return Inertia::render('Cart/Index', [ + 'items' => $itemsWithPrices, + 'total' => number_format($total, 2, '.', ''), + 'itemCount' => $this->cartService->getItemCount($user), + ]); + } + + public function add(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'plan_id' => ['required', 'exists:plans,id'], + 'billing_cycle' => ['required', 'string', 'in:monthly,quarterly,semi_annual,annual'], + 'quantity' => ['sometimes', 'integer', 'min:1', 'max:10'], + 'config_selections' => ['nullable', 'array'], + 'coupon_code' => ['nullable', 'string', 'max:50'], + ]); + + /** @var \App\Models\User $user */ + $user = $request->user(); + $plan = Plan::findOrFail($validated['plan_id']); + + if (! $plan->isAvailable()) { + return redirect()->back()->with('error', 'This plan is not available.'); + } + + $this->cartService->addItem( + userOrSession: $user, + plan: $plan, + cycle: $validated['billing_cycle'], + qty: $validated['quantity'] ?? 1, + config: $validated['config_selections'] ?? null, + couponCode: $validated['coupon_code'] ?? null, + ); + + return redirect()->route('account.cart.index') + ->with('success', "{$plan->name} has been added to your cart."); + } + + public function update(Request $request, CartItem $cartItem): RedirectResponse + { + $validated = $request->validate([ + 'quantity' => ['required', 'integer', 'min:1', 'max:10'], + ]); + + /** @var \App\Models\User $user */ + $user = $request->user(); + + // Verify ownership + if ($cartItem->user_id !== $user->id) { + abort(403); + } + + $this->cartService->updateQuantity($cartItem->id, $validated['quantity']); + + return redirect()->route('account.cart.index') + ->with('success', 'Cart updated.'); + } + + public function remove(Request $request, CartItem $cartItem): RedirectResponse + { + /** @var \App\Models\User $user */ + $user = $request->user(); + + // Verify ownership + if ($cartItem->user_id !== $user->id) { + abort(403); + } + + $this->cartService->removeItem($cartItem->id); + + return redirect()->route('account.cart.index') + ->with('success', 'Item removed from cart.'); + } +} diff --git a/website/app/Http/Controllers/Account/Dedicated/DedicatedPanelController.php b/website/app/Http/Controllers/Account/Dedicated/DedicatedPanelController.php new file mode 100644 index 0000000..f1afb4b --- /dev/null +++ b/website/app/Http/Controllers/Account/Dedicated/DedicatedPanelController.php @@ -0,0 +1,194 @@ +authorize($request, $service); + + $hardwareInfo = $this->safeApiCall(function () use ($service): array { + return $this->fetchHardwareInfo($service); + }); + + return Inertia::render('Services/Dedicated/Dashboard', [ + 'service' => $service->load('plan', 'subscription'), + 'hardwareInfo' => $hardwareInfo ?? [ + 'cpu' => null, + 'memory' => null, + 'disks' => [], + 'network_ports' => [], + ], + ]); + } + + public function console(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $consoleUrl = $this->safeApiCall(function () use ($service): ?string { + return $this->fetchConsoleUrl($service); + }); + + return Inertia::render('Services/Dedicated/Console', [ + 'service' => $service->load('plan'), + 'consoleUrl' => $consoleUrl, + ]); + } + + public function bandwidth(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $bandwidthData = $this->safeApiCall(function () use ($service): array { + return $this->fetchBandwidthData($service); + }); + + return Inertia::render('Services/Dedicated/Bandwidth', [ + 'service' => $service->load('plan'), + 'bandwidthData' => $bandwidthData ?? [], + ]); + } + + public function network(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $networkData = $this->safeApiCall(function () use ($service): array { + return $this->fetchNetworkData($service); + }); + + return Inertia::render('Services/Dedicated/Network', [ + 'service' => $service->load('plan'), + 'networkData' => $networkData ?? [ + 'ips' => [], + 'vlans' => [], + ], + ]); + } + + public function reinstall(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $templates = $this->safeApiCall(function () use ($service): array { + return $this->fetchTemplates($service); + }); + + return Inertia::render('Services/Dedicated/Reinstall', [ + 'service' => $service->load('plan'), + 'templates' => $templates ?? [], + ]); + } + + public function settings(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + return Inertia::render('Services/Dedicated/Settings', [ + 'service' => $service->load('plan'), + ]); + } + + private function authorize(Request $request, Service $service): void + { + abort_unless($service->user_id === $request->user()->id, 403); + abort_unless($service->service_type === 'dedicated', 404); + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T|null + */ + private function safeApiCall(callable $callback): mixed + { + try { + return $callback(); + } catch (\Exception $e) { + Log::warning('Dedicated Panel API call failed', [ + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * @return array + */ + private function fetchHardwareInfo(Service $service): array + { + // SynergyCP API: GET /api/v1/servers/{id}/hardware + return [ + 'cpu' => null, + 'memory' => null, + 'disks' => [], + 'network_ports' => [], + ]; + } + + private function fetchConsoleUrl(Service $service): ?string + { + // SynergyCP API: GET /api/v1/servers/{id}/ipmi/console + return null; + } + + /** + * @return array> + */ + private function fetchBandwidthData(Service $service): array + { + // SynergyCP API: GET /api/v1/servers/{id}/bandwidth + return []; + } + + /** + * @return array + */ + private function fetchNetworkData(Service $service): array + { + $ips = []; + + if ($service->ipv4_address) { + $ips[] = [ + 'address' => $service->ipv4_address, + 'type' => 'IPv4', + 'primary' => true, + ]; + } + + if ($service->ipv6_address) { + $ips[] = [ + 'address' => $service->ipv6_address, + 'type' => 'IPv6', + 'primary' => false, + ]; + } + + return [ + 'ips' => $ips, + 'vlans' => [], + ]; + } + + /** + * @return array> + */ + private function fetchTemplates(Service $service): array + { + // SynergyCP API: GET /api/v1/servers/{id}/templates + return []; + } +} diff --git a/website/app/Http/Controllers/Account/Game/GamePanelController.php b/website/app/Http/Controllers/Account/Game/GamePanelController.php new file mode 100644 index 0000000..5abf871 --- /dev/null +++ b/website/app/Http/Controllers/Account/Game/GamePanelController.php @@ -0,0 +1,238 @@ +authorize($request, $service); + + $activities = ProvisioningLog::where('service_id', $service->id) + ->orderByDesc('created_at') + ->limit(10) + ->get() + ->map(fn (ProvisioningLog $log): array => [ + 'action' => $log->action, + 'timestamp' => $log->created_at->toISOString(), + 'details' => $log->status.($log->error_message ? ': '.$log->error_message : ''), + ]) + ->toArray(); + + return Inertia::render('Services/Game/Dashboard', [ + 'service' => $service->load('plan', 'subscription'), + 'recentActivity' => $activities, + ]); + } + + public function console(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $consoleUrl = $this->safeApiCall(function () use ($service): ?string { + return $this->fetchConsoleUrl($service); + }); + + return Inertia::render('Services/Game/Console', [ + 'service' => $service->load('plan'), + 'consoleUrl' => $consoleUrl, + ]); + } + + public function files(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $directory = $request->query('path', '/'); + + $fileList = $this->safeApiCall(function () use ($service, $directory): array { + return $this->fetchFiles($service, (string) $directory); + }); + + return Inertia::render('Services/Game/Files', [ + 'service' => $service->load('plan'), + 'files' => $fileList ?? [], + 'currentPath' => $directory, + ]); + } + + public function databases(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $databaseList = $this->safeApiCall(function () use ($service): array { + return $this->fetchDatabases($service); + }); + + return Inertia::render('Services/Game/Databases', [ + 'service' => $service->load('plan'), + 'databases' => $databaseList ?? [], + ]); + } + + public function backups(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $backupList = $this->safeApiCall(function () use ($service): array { + return $this->fetchBackups($service); + }); + + return Inertia::render('Services/Game/Backups', [ + 'service' => $service->load('plan'), + 'backups' => $backupList ?? [], + ]); + } + + public function schedules(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $scheduleList = $this->safeApiCall(function () use ($service): array { + return $this->fetchSchedules($service); + }); + + return Inertia::render('Services/Game/Schedules', [ + 'service' => $service->load('plan'), + 'schedules' => $scheduleList ?? [], + ]); + } + + public function startup(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $startupConfig = $this->safeApiCall(function () use ($service): array { + return $this->fetchStartupConfig($service); + }); + + return Inertia::render('Services/Game/Startup', [ + 'service' => $service->load('plan'), + 'startupConfig' => $startupConfig ?? [ + 'startup_command' => '', + 'variables' => [], + ], + ]); + } + + public function settings(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + return Inertia::render('Services/Game/Settings', [ + 'service' => $service->load('plan'), + ]); + } + + public function subUsers(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $users = $this->safeApiCall(function () use ($service): array { + return $this->fetchSubUsers($service); + }); + + return Inertia::render('Services/Game/SubUsers', [ + 'service' => $service->load('plan'), + 'subUsers' => $users ?? [], + ]); + } + + private function authorize(Request $request, Service $service): void + { + abort_unless($service->user_id === $request->user()->id, 403); + abort_unless($service->service_type === 'game', 404); + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T|null + */ + private function safeApiCall(callable $callback): mixed + { + try { + return $callback(); + } catch (\Exception $e) { + Log::warning('Game Panel API call failed', [ + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + private function fetchConsoleUrl(Service $service): ?string + { + // Pterodactyl API: GET /api/client/servers/{id}/websocket + return null; + } + + /** + * @return array> + */ + private function fetchFiles(Service $service, string $directory): array + { + // Pterodactyl API: GET /api/client/servers/{id}/files/list?directory={path} + return []; + } + + /** + * @return array> + */ + private function fetchDatabases(Service $service): array + { + // Pterodactyl API: GET /api/client/servers/{id}/databases + return []; + } + + /** + * @return array> + */ + private function fetchBackups(Service $service): array + { + // Pterodactyl API: GET /api/client/servers/{id}/backups + return []; + } + + /** + * @return array> + */ + private function fetchSchedules(Service $service): array + { + // Pterodactyl API: GET /api/client/servers/{id}/schedules + return []; + } + + /** + * @return array + */ + private function fetchStartupConfig(Service $service): array + { + // Pterodactyl API: GET /api/client/servers/{id}/startup + return [ + 'startup_command' => '', + 'variables' => [], + ]; + } + + /** + * @return array> + */ + private function fetchSubUsers(Service $service): array + { + // Pterodactyl API: GET /api/client/servers/{id}/users + return []; + } +} diff --git a/website/app/Http/Controllers/Account/Hosting/HostingPanelController.php b/website/app/Http/Controllers/Account/Hosting/HostingPanelController.php new file mode 100644 index 0000000..1d0318b --- /dev/null +++ b/website/app/Http/Controllers/Account/Hosting/HostingPanelController.php @@ -0,0 +1,285 @@ +authorize($request, $service); + + $usage = $this->safeApiCall(function () use ($service): array { + return $this->fetchUsage($service); + }); + + return Inertia::render('Services/Hosting/Dashboard', [ + 'service' => $service->load('plan', 'subscription'), + 'usage' => $usage ?? [ + 'disk_used' => 0, + 'disk_limit' => 0, + 'bandwidth_used' => 0, + 'bandwidth_limit' => 0, + 'email_accounts' => 0, + 'databases' => 0, + 'domains' => 0, + 'subdomains' => 0, + ], + ]); + } + + public function files(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $path = (string) $request->query('path', '/'); + + $fileList = $this->safeApiCall(function () use ($service, $path): array { + return $this->fetchFiles($service, $path); + }); + + return Inertia::render('Services/Hosting/Files', [ + 'service' => $service->load('plan'), + 'files' => $fileList ?? [], + 'currentPath' => $path, + ]); + } + + public function databases(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $databaseList = $this->safeApiCall(function () use ($service): array { + return $this->fetchDatabases($service); + }); + + return Inertia::render('Services/Hosting/Databases', [ + 'service' => $service->load('plan'), + 'databases' => $databaseList ?? [], + ]); + } + + public function email(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $emailAccounts = $this->safeApiCall(function () use ($service): array { + return $this->fetchEmailAccounts($service); + }); + + return Inertia::render('Services/Hosting/Email', [ + 'service' => $service->load('plan'), + 'emailAccounts' => $emailAccounts ?? [], + ]); + } + + public function domains(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $domainList = $this->safeApiCall(function () use ($service): array { + return $this->fetchDomains($service); + }); + + return Inertia::render('Services/Hosting/Domains', [ + 'service' => $service->load('plan'), + 'domains' => $domainList ?? [], + ]); + } + + public function dns(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $records = $this->safeApiCall(function () use ($service): array { + return $this->fetchDnsRecords($service); + }); + + return Inertia::render('Services/Hosting/Dns', [ + 'service' => $service->load('plan'), + 'records' => $records ?? [], + ]); + } + + public function ssl(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $certificates = $this->safeApiCall(function () use ($service): array { + return $this->fetchSslCertificates($service); + }); + + return Inertia::render('Services/Hosting/Ssl', [ + 'service' => $service->load('plan'), + 'certificates' => $certificates ?? [], + ]); + } + + public function php(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $phpConfig = $this->safeApiCall(function () use ($service): array { + return $this->fetchPhpConfig($service); + }); + + return Inertia::render('Services/Hosting/Php', [ + 'service' => $service->load('plan'), + 'phpConfig' => $phpConfig ?? [ + 'version' => '8.3', + 'available_versions' => ['8.1', '8.2', '8.3'], + 'settings' => [], + ], + ]); + } + + public function cron(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $cronJobs = $this->safeApiCall(function () use ($service): array { + return $this->fetchCronJobs($service); + }); + + return Inertia::render('Services/Hosting/Cron', [ + 'service' => $service->load('plan'), + 'cronJobs' => $cronJobs ?? [], + ]); + } + + public function settings(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + return Inertia::render('Services/Hosting/Settings', [ + 'service' => $service->load('plan'), + ]); + } + + private function authorize(Request $request, Service $service): void + { + abort_unless($service->user_id === $request->user()->id, 403); + abort_unless($service->service_type === 'hosting', 404); + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T|null + */ + private function safeApiCall(callable $callback): mixed + { + try { + return $callback(); + } catch (\Exception $e) { + Log::warning('Hosting Panel API call failed', [ + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * @return array + */ + private function fetchUsage(Service $service): array + { + // Enhance API: GET /api/v1/websites/{id}/usage + return [ + 'disk_used' => 0, + 'disk_limit' => 0, + 'bandwidth_used' => 0, + 'bandwidth_limit' => 0, + 'email_accounts' => 0, + 'databases' => 0, + 'domains' => 0, + 'subdomains' => 0, + ]; + } + + /** + * @return array> + */ + private function fetchFiles(Service $service, string $path): array + { + // Enhance API: GET /api/v1/websites/{id}/files?path={path} + return []; + } + + /** + * @return array> + */ + private function fetchDatabases(Service $service): array + { + // Enhance API: GET /api/v1/websites/{id}/databases + return []; + } + + /** + * @return array> + */ + private function fetchEmailAccounts(Service $service): array + { + // Enhance API: GET /api/v1/websites/{id}/email-accounts + return []; + } + + /** + * @return array> + */ + private function fetchDomains(Service $service): array + { + // Enhance API: GET /api/v1/websites/{id}/domains + return []; + } + + /** + * @return array> + */ + private function fetchDnsRecords(Service $service): array + { + // Enhance API: GET /api/v1/websites/{id}/dns + return []; + } + + /** + * @return array> + */ + private function fetchSslCertificates(Service $service): array + { + // Enhance API: GET /api/v1/websites/{id}/ssl + return []; + } + + /** + * @return array + */ + private function fetchPhpConfig(Service $service): array + { + // Enhance API: GET /api/v1/websites/{id}/php + return [ + 'version' => '8.3', + 'available_versions' => ['8.1', '8.2', '8.3'], + 'settings' => [], + ]; + } + + /** + * @return array> + */ + private function fetchCronJobs(Service $service): array + { + // Enhance API: GET /api/v1/websites/{id}/cron + return []; + } +} diff --git a/website/app/Http/Controllers/Account/Vps/VpsPanelController.php b/website/app/Http/Controllers/Account/Vps/VpsPanelController.php new file mode 100644 index 0000000..92930f6 --- /dev/null +++ b/website/app/Http/Controllers/Account/Vps/VpsPanelController.php @@ -0,0 +1,501 @@ +authorize($request, $service); + + $status = $this->safeApiCall(fn () => $this->virtfusion->getStatus($service)); + + $activities = ProvisioningLog::where('service_id', $service->id) + ->orderByDesc('created_at') + ->limit(10) + ->get() + ->map(fn (ProvisioningLog $log): array => [ + 'action' => $log->action, + 'timestamp' => $log->created_at->toISOString(), + 'details' => $log->status.($log->error_message ? ': '.$log->error_message : ''), + ]) + ->toArray(); + + return Inertia::render('Services/Vps/Dashboard', [ + 'service' => $service->load('plan', 'subscription'), + 'serverStatus' => $status, + 'recentActivity' => $activities, + ]); + } + + public function console(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $vncUrl = $this->safeApiCall(fn (): ?string => $this->virtfusion->getVncUrl($service)); + + return Inertia::render('Services/Vps/Console', [ + 'service' => $service->load('plan'), + 'vncUrl' => $vncUrl, + ]); + } + + public function graphs(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + // Attempt to fetch resource data from VirtFusion API + $resourceData = $this->safeApiCall(function () use ($service): array { + return $this->fetchResourceGraphs($service); + }); + + return Inertia::render('Services/Vps/Graphs', [ + 'service' => $service->load('plan'), + 'resourceData' => $resourceData ?? $this->emptyResourceData(), + ]); + } + + public function firewall(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $rules = $this->safeApiCall(function () use ($service): array { + return $this->fetchFirewallRules($service); + }); + + return Inertia::render('Services/Vps/Firewall', [ + 'service' => $service->load('plan'), + 'rules' => $rules ?? [], + ]); + } + + public function storeFirewallRule(Request $request, Service $service): RedirectResponse + { + $this->authorize($request, $service); + + $validated = $request->validate([ + 'protocol' => ['required', 'string', 'in:tcp,udp,icmp'], + 'port' => ['required_unless:protocol,icmp', 'nullable', 'string', 'max:11'], + 'source' => ['required', 'string', 'max:45'], + 'action' => ['required', 'string', 'in:accept,drop,reject'], + ]); + + try { + $this->createFirewallRule($service, $validated); + $this->logAudit($request, $service, 'firewall_add', true, $validated); + + return redirect()->back()->with('success', 'Firewall rule created successfully.'); + } catch (\Exception $e) { + Log::error('VPS firewall rule creation failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'Failed to create firewall rule.'); + } + } + + public function destroyFirewallRule(Request $request, Service $service, int $ruleId): RedirectResponse + { + $this->authorize($request, $service); + + try { + $this->deleteFirewallRule($service, $ruleId); + $this->logAudit($request, $service, 'firewall_remove', true, ['rule_id' => $ruleId]); + + return redirect()->back()->with('success', 'Firewall rule deleted successfully.'); + } catch (\Exception $e) { + Log::error('VPS firewall rule deletion failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'Failed to delete firewall rule.'); + } + } + + public function snapshots(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $snapshotList = $this->safeApiCall(function () use ($service): array { + return $this->fetchSnapshots($service); + }); + + return Inertia::render('Services/Vps/Snapshots', [ + 'service' => $service->load('plan'), + 'snapshots' => $snapshotList ?? [], + ]); + } + + public function createSnapshot(Request $request, Service $service): RedirectResponse + { + $this->authorize($request, $service); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + ]); + + try { + $this->storeSnapshot($service, $validated['name']); + $this->logAudit($request, $service, 'snapshot_create', true, $validated); + + return redirect()->back()->with('success', 'Snapshot creation initiated.'); + } catch (\Exception $e) { + Log::error('VPS snapshot creation failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'Failed to create snapshot.'); + } + } + + public function restoreSnapshot(Request $request, Service $service, int $snapshotId): RedirectResponse + { + $this->authorize($request, $service); + + try { + $this->doRestoreSnapshot($service, $snapshotId); + $this->logAudit($request, $service, 'snapshot_restore', true, ['snapshot_id' => $snapshotId]); + + return redirect()->back()->with('success', 'Snapshot restore initiated.'); + } catch (\Exception $e) { + Log::error('VPS snapshot restore failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'Failed to restore snapshot.'); + } + } + + public function deleteSnapshot(Request $request, Service $service, int $snapshotId): RedirectResponse + { + $this->authorize($request, $service); + + try { + $this->doDeleteSnapshot($service, $snapshotId); + $this->logAudit($request, $service, 'snapshot_delete', true, ['snapshot_id' => $snapshotId]); + + return redirect()->back()->with('success', 'Snapshot deleted successfully.'); + } catch (\Exception $e) { + Log::error('VPS snapshot deletion failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'Failed to delete snapshot.'); + } + } + + public function backups(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $backupList = $this->safeApiCall(function () use ($service): array { + return $this->fetchBackups($service); + }); + + return Inertia::render('Services/Vps/Backups', [ + 'service' => $service->load('plan'), + 'backups' => $backupList ?? [], + ]); + } + + public function network(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $networkData = $this->safeApiCall(function () use ($service): array { + return $this->fetchNetworkData($service); + }); + + return Inertia::render('Services/Vps/Network', [ + 'service' => $service->load('plan'), + 'networkData' => $networkData ?? [ + 'ips' => [], + 'bandwidth' => [], + ], + ]); + } + + public function updateRdns(Request $request, Service $service): RedirectResponse + { + $this->authorize($request, $service); + + $validated = $request->validate([ + 'ip' => ['required', 'string', 'ip'], + 'rdns' => ['required', 'string', 'max:255'], + ]); + + try { + $this->doUpdateRdns($service, $validated['ip'], $validated['rdns']); + $this->logAudit($request, $service, 'rdns_update', true, $validated); + + return redirect()->back()->with('success', 'Reverse DNS updated successfully.'); + } catch (\Exception $e) { + Log::error('VPS rDNS update failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'Failed to update reverse DNS.'); + } + } + + public function settings(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + return Inertia::render('Services/Vps/Settings', [ + 'service' => $service->load('plan'), + ]); + } + + public function updateSettings(Request $request, Service $service): RedirectResponse + { + $this->authorize($request, $service); + + $validated = $request->validate([ + 'hostname' => ['sometimes', 'string', 'max:255'], + 'boot_order' => ['sometimes', 'string', 'in:disk,cdrom,network'], + ]); + + try { + $this->doUpdateSettings($service, $validated); + $this->logAudit($request, $service, 'settings_update', true, $validated); + + return redirect()->back()->with('success', 'Settings updated successfully.'); + } catch (\Exception $e) { + Log::error('VPS settings update failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', 'Failed to update settings.'); + } + } + + public function activity(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $activities = ProvisioningLog::where('service_id', $service->id) + ->orderByDesc('created_at') + ->limit(50) + ->get() + ->map(fn (ProvisioningLog $log): array => [ + 'action' => $log->action, + 'timestamp' => $log->created_at->toISOString(), + 'details' => $log->status.($log->error_message ? ': '.$log->error_message : ''), + ]) + ->toArray(); + + return Inertia::render('Services/Vps/Activity', [ + 'service' => $service->load('plan'), + 'activities' => $activities, + ]); + } + + public function rebuild(Request $request, Service $service): Response + { + $this->authorize($request, $service); + + $templates = $this->safeApiCall(fn (): array => $this->virtfusion->getTemplates($service)); + + return Inertia::render('Services/Vps/Rebuild', [ + 'service' => $service->load('plan'), + 'templates' => $templates ?? [], + ]); + } + + private function authorize(Request $request, Service $service): void + { + abort_unless($service->user_id === $request->user()->id, 403); + abort_unless($service->service_type === 'vps', 404); + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T|null + */ + private function safeApiCall(callable $callback): mixed + { + try { + return $callback(); + } catch (\Exception $e) { + Log::warning('VPS Panel API call failed', [ + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * @return array + */ + private function fetchResourceGraphs(Service $service): array + { + // VirtFusion API: GET /servers/{id}/resources + $response = app(VirtFusionService::class); + + return $this->emptyResourceData(); + } + + /** + * @return array>> + */ + private function emptyResourceData(): array + { + return [ + 'cpu' => [], + 'memory' => [], + 'disk' => [], + 'network' => [], + ]; + } + + /** + * @return array> + */ + private function fetchFirewallRules(Service $service): array + { + // VirtFusion API: GET /servers/{id}/firewall + return []; + } + + /** + * @param array $data + */ + private function createFirewallRule(Service $service, array $data): void + { + // VirtFusion API: POST /servers/{id}/firewall + } + + private function deleteFirewallRule(Service $service, int $ruleId): void + { + // VirtFusion API: DELETE /servers/{id}/firewall/{ruleId} + } + + /** + * @return array> + */ + private function fetchSnapshots(Service $service): array + { + // VirtFusion API: GET /servers/{id}/snapshots + return []; + } + + private function storeSnapshot(Service $service, string $name): void + { + // VirtFusion API: POST /servers/{id}/snapshots + } + + private function doRestoreSnapshot(Service $service, int $snapshotId): void + { + // VirtFusion API: POST /servers/{id}/snapshots/{snapshotId}/restore + } + + private function doDeleteSnapshot(Service $service, int $snapshotId): void + { + // VirtFusion API: DELETE /servers/{id}/snapshots/{snapshotId} + } + + /** + * @return array + */ + private function fetchBackups(Service $service): array + { + // VirtFusion API: GET /servers/{id}/backups + return []; + } + + /** + * @return array + */ + private function fetchNetworkData(Service $service): array + { + $ips = []; + + if ($service->ipv4_address) { + $ips[] = [ + 'address' => $service->ipv4_address, + 'type' => 'IPv4', + 'primary' => true, + 'rdns' => null, + ]; + } + + if ($service->ipv6_address) { + $ips[] = [ + 'address' => $service->ipv6_address, + 'type' => 'IPv6', + 'primary' => false, + 'rdns' => null, + ]; + } + + return [ + 'ips' => $ips, + 'bandwidth' => [], + ]; + } + + private function doUpdateRdns(Service $service, string $ip, string $rdns): void + { + // VirtFusion API: PUT /servers/{id}/rdns + } + + /** + * @param array $data + */ + private function doUpdateSettings(Service $service, array $data): void + { + if (isset($data['hostname'])) { + $service->update(['hostname' => $data['hostname']]); + } + + // VirtFusion API: PUT /servers/{id}/settings + } + + /** + * @param array $changes + */ + private function logAudit(Request $request, Service $service, string $action, bool $success, array $changes = []): void + { + AuditLog::create([ + 'user_id' => $request->user()->id, + 'action' => 'vps_'.$action, + 'resource_type' => 'service', + 'resource_id' => $service->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => array_merge($changes, [ + 'success' => $success, + 'service_id' => $service->id, + 'platform' => $service->platform, + ]), + ]); + } +} diff --git a/website/app/Http/Controllers/Admin/AffiliateController.php b/website/app/Http/Controllers/Admin/AffiliateController.php new file mode 100644 index 0000000..c402c97 --- /dev/null +++ b/website/app/Http/Controllers/Admin/AffiliateController.php @@ -0,0 +1,192 @@ +with('user:id,name,email') + ->withCount('referrals', 'commissions'); + + if ($request->filled('status') && $request->input('status') !== 'all') { + $query->where('status', $request->input('status')); + } + + if ($request->filled('search')) { + $search = $request->input('search'); + $query->whereHas('user', function ($q) use ($search): void { + $q->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + } + + $affiliates = $query->latest()->paginate(25); + + return Inertia::render('Admin/Affiliates/Index', [ + 'affiliates' => $affiliates, + 'filters' => [ + 'status' => $request->input('status', 'all'), + 'search' => $request->input('search', ''), + ], + ]); + } + + public function show(Affiliate $affiliate): Response + { + $affiliate->load('user:id,name,email'); + $affiliate->loadCount('referrals', 'commissions', 'payouts'); + + $referrals = $affiliate->referrals() + ->with('referredUser:id,name,email') + ->latest() + ->paginate(10, ['*'], 'referrals_page'); + + $commissions = $affiliate->commissions() + ->with('referral.referredUser:id,name,email') + ->latest() + ->paginate(10, ['*'], 'commissions_page'); + + $payouts = $affiliate->payouts() + ->latest() + ->paginate(10, ['*'], 'payouts_page'); + + return Inertia::render('Admin/Affiliates/Show', [ + 'affiliate' => $affiliate, + 'referrals' => $referrals, + 'commissions' => $commissions, + 'payouts' => $payouts, + ]); + } + + public function approve(Request $request, Affiliate $affiliate): RedirectResponse + { + $this->affiliateService->approve($affiliate); + + AuditLog::create([ + 'user_id' => $affiliate->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'affiliate_approved', + 'resource_type' => 'Affiliate', + 'resource_id' => $affiliate->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + return back()->with('success', 'Affiliate approved successfully.'); + } + + public function suspend(Request $request, Affiliate $affiliate): RedirectResponse + { + $affiliate->update(['status' => 'suspended']); + + AuditLog::create([ + 'user_id' => $affiliate->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'affiliate_suspended', + 'resource_type' => 'Affiliate', + 'resource_id' => $affiliate->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + return back()->with('success', 'Affiliate suspended.'); + } + + public function commissions(Request $request): Response + { + $commissions = AffiliateCommission::query() + ->where('status', 'pending') + ->with([ + 'affiliate.user:id,name,email', + 'referral.referredUser:id,name,email', + ]) + ->latest() + ->paginate(25); + + return Inertia::render('Admin/Affiliates/Commissions', [ + 'commissions' => $commissions, + ]); + } + + public function approveCommission(Request $request, AffiliateCommission $commission): RedirectResponse + { + $this->affiliateService->approveCommission($commission); + + AuditLog::create([ + 'user_id' => $commission->affiliate->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'affiliate_commission_approved', + 'resource_type' => 'AffiliateCommission', + 'resource_id' => $commission->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['amount' => (float) $commission->amount], + ]); + + return back()->with('success', 'Commission approved successfully.'); + } + + public function payouts(Request $request): Response + { + $payouts = AffiliatePayout::query() + ->whereIn('status', ['pending', 'processing']) + ->with('affiliate.user:id,name,email') + ->latest() + ->paginate(25); + + return Inertia::render('Admin/Affiliates/Payouts', [ + 'payouts' => $payouts, + ]); + } + + public function processPayout(Request $request, AffiliatePayout $payout): RedirectResponse + { + $this->affiliateService->processPayout($payout); + + AuditLog::create([ + 'user_id' => $payout->affiliate->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'affiliate_payout_processed', + 'resource_type' => 'AffiliatePayout', + 'resource_id' => $payout->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['amount' => (float) $payout->amount, 'method' => $payout->method], + ]); + + return back()->with('success', 'Payout processed successfully.'); + } + + public function settings(): Response + { + return Inertia::render('Admin/Affiliates/Settings', [ + 'settings' => [ + 'default_commission_type' => config('affiliate.default_commission_type'), + 'default_commission_rate' => config('affiliate.default_commission_rate'), + 'default_recurring_commissions' => config('affiliate.default_recurring_commissions'), + 'default_minimum_payout' => config('affiliate.default_minimum_payout'), + 'cookie_lifetime_days' => config('affiliate.cookie_lifetime_days'), + 'auto_approve' => config('affiliate.auto_approve'), + ], + ]); + } +} diff --git a/website/app/Http/Controllers/Admin/CannedResponseController.php b/website/app/Http/Controllers/Admin/CannedResponseController.php new file mode 100644 index 0000000..07b7fb2 --- /dev/null +++ b/website/app/Http/Controllers/Admin/CannedResponseController.php @@ -0,0 +1,122 @@ +with('creator:id,name'); + + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search): void { + $q->where('title', 'like', "%{$search}%") + ->orWhere('content', 'like', "%{$search}%"); + }); + } + + if ($category = $request->input('category')) { + $query->where('category', $category); + } + + $cannedResponses = $query->latest() + ->paginate(25) + ->withQueryString(); + + $categories = CannedResponse::query() + ->whereNotNull('category') + ->distinct() + ->pluck('category') + ->sort() + ->values(); + + return Inertia::render('Admin/Tickets/CannedResponses/Index', [ + 'cannedResponses' => $cannedResponses, + 'categories' => $categories, + 'filters' => [ + 'search' => $request->input('search', ''), + 'category' => $request->input('category', ''), + ], + ]); + } + + public function create(): Response + { + $categories = CannedResponse::query() + ->whereNotNull('category') + ->distinct() + ->pluck('category') + ->sort() + ->values(); + + return Inertia::render('Admin/Tickets/CannedResponses/Create', [ + 'categories' => $categories, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'content' => ['required', 'string', 'max:10000'], + 'category' => ['nullable', 'string', 'max:100'], + 'is_shared' => ['boolean'], + ]); + + CannedResponse::query()->create([ + ...$validated, + 'created_by' => $request->user()->id, + ]); + + return redirect()->route('admin.canned-responses.index') + ->with('success', 'Canned response created successfully.'); + } + + public function edit(CannedResponse $cannedResponse): Response + { + $categories = CannedResponse::query() + ->whereNotNull('category') + ->distinct() + ->pluck('category') + ->sort() + ->values(); + + return Inertia::render('Admin/Tickets/CannedResponses/Edit', [ + 'cannedResponse' => $cannedResponse, + 'categories' => $categories, + ]); + } + + public function update(Request $request, CannedResponse $cannedResponse): RedirectResponse + { + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'content' => ['required', 'string', 'max:10000'], + 'category' => ['nullable', 'string', 'max:100'], + 'is_shared' => ['boolean'], + ]); + + $cannedResponse->update($validated); + + return redirect()->route('admin.canned-responses.index') + ->with('success', 'Canned response updated successfully.'); + } + + public function destroy(CannedResponse $cannedResponse): RedirectResponse + { + $cannedResponse->delete(); + + return redirect()->route('admin.canned-responses.index') + ->with('success', 'Canned response deleted successfully.'); + } +} diff --git a/website/app/Http/Controllers/Admin/CreditNoteController.php b/website/app/Http/Controllers/Admin/CreditNoteController.php new file mode 100644 index 0000000..b9347c8 --- /dev/null +++ b/website/app/Http/Controllers/Admin/CreditNoteController.php @@ -0,0 +1,152 @@ +with('user:id,name,email'); + + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search): void { + $q->where('number', 'like', "%{$search}%") + ->orWhereHas('user', function ($uq) use ($search): void { + $uq->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + }); + } + + if ($status = $request->input('status')) { + $query->where('status', $status); + } + + $creditNotes = $query->latest()->paginate(15)->withQueryString(); + + return Inertia::render('Admin/CreditNotes/Index', [ + 'creditNotes' => $creditNotes, + 'filters' => [ + 'search' => $request->input('search', ''), + 'status' => $request->input('status', ''), + ], + ]); + } + + public function create(): Response + { + $customers = User::query() + ->select('id', 'name', 'email', 'credit_balance') + ->orderBy('name') + ->get(); + + $invoices = Invoice::query() + ->select('id', 'number', 'user_id', 'total', 'status') + ->whereIn('status', ['paid', 'pending', 'overdue']) + ->latest() + ->limit(200) + ->get(); + + return Inertia::render('Admin/CreditNotes/Create', [ + 'customers' => $customers, + 'invoices' => $invoices, + ]); + } + + public function store(StoreCreditNoteRequest $request): RedirectResponse + { + $validated = $request->validated(); + + $user = User::findOrFail($validated['user_id']); + $invoice = isset($validated['invoice_id']) ? Invoice::find($validated['invoice_id']) : null; + + $creditNote = $this->creditService->issueCreditNote( + user: $user, + amount: (float) $validated['amount'], + reason: $validated['reason'] ?? null, + invoice: $invoice, + creator: $request->user(), + ); + + AuditLog::create([ + 'user_id' => $user->id, + 'admin_id' => $request->user()?->id, + 'action' => 'issue_credit_note', + 'resource_type' => 'credit_note', + 'resource_id' => $creditNote->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => [ + 'amount' => $validated['amount'], + 'reason' => $validated['reason'] ?? null, + ], + ]); + + return redirect()->route('credit-notes.show', $creditNote) + ->with('success', "Credit note {$creditNote->number} has been issued."); + } + + public function show(CreditNote $creditNote): Response + { + $creditNote->load([ + 'user:id,name,email,credit_balance', + 'invoice:id,number,total,status', + 'creator:id,name,email', + 'accountCredits', + ]); + + return Inertia::render('Admin/CreditNotes/Show', [ + 'creditNote' => $creditNote, + ]); + } + + public function download(CreditNote $creditNote): \Symfony\Component\HttpFoundation\Response + { + $creditNote->load(['user', 'invoice']); + + $pdf = Pdf::loadView('pdf.credit-note', ['creditNote' => $creditNote]); + + return $pdf->download("credit-note-{$creditNote->number}.pdf"); + } + + public function void(Request $request, CreditNote $creditNote): RedirectResponse + { + if ($creditNote->status === 'voided') { + return redirect()->back()->with('error', 'This credit note is already voided.'); + } + + $this->creditService->voidCreditNote($creditNote); + + AuditLog::create([ + 'user_id' => $creditNote->user_id, + 'admin_id' => $request->user()?->id, + 'action' => 'void_credit_note', + 'resource_type' => 'credit_note', + 'resource_id' => $creditNote->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + return redirect()->back()->with('success', "Credit note {$creditNote->number} has been voided."); + } +} diff --git a/website/app/Http/Controllers/Admin/CurrencyController.php b/website/app/Http/Controllers/Admin/CurrencyController.php new file mode 100644 index 0000000..edb0673 --- /dev/null +++ b/website/app/Http/Controllers/Admin/CurrencyController.php @@ -0,0 +1,144 @@ +orderBy('code')->get(); + + return Inertia::render('Admin/Currencies/Index', [ + 'currencies' => $currencies, + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'code' => ['required', 'string', 'size:3', 'unique:currencies,code'], + 'symbol' => ['required', 'string', 'max:5'], + 'name' => ['required', 'string', 'max:255'], + 'decimal_places' => ['required', 'integer', 'min:0', 'max:4'], + 'exchange_rate' => ['required', 'numeric', 'min:0.000001'], + 'is_base' => ['boolean'], + 'is_enabled' => ['boolean'], + ]); + + $validated['code'] = strtoupper($validated['code']); + + // If setting as base currency, unset other base currencies + if ($validated['is_base'] ?? false) { + Currency::query()->where('is_base', true)->update(['is_base' => false]); + $validated['exchange_rate'] = 1.000000; + } + + $currency = Currency::create($validated); + + Cache::forget('currencies:enabled'); + Cache::forget('currencies:base'); + + AuditLog::create([ + 'admin_id' => $request->user()?->id, + 'action' => 'create_currency', + 'resource_type' => 'currency', + 'resource_id' => $currency->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => $validated, + ]); + + return redirect()->route('admin.currencies.index') + ->with('success', "Currency {$currency->code} has been created."); + } + + public function update(Request $request, Currency $currency): RedirectResponse + { + $validated = $request->validate([ + 'symbol' => ['required', 'string', 'max:5'], + 'name' => ['required', 'string', 'max:255'], + 'decimal_places' => ['required', 'integer', 'min:0', 'max:4'], + 'exchange_rate' => ['required', 'numeric', 'min:0.000001'], + 'is_base' => ['boolean'], + 'is_enabled' => ['boolean'], + ]); + + // If setting as base currency, unset other base currencies + if ($validated['is_base'] ?? false) { + Currency::query()->where('is_base', true)->where('id', '!=', $currency->id)->update(['is_base' => false]); + $validated['exchange_rate'] = 1.000000; + } + + $currency->update($validated); + + Cache::forget('currencies:enabled'); + Cache::forget('currencies:base'); + + AuditLog::create([ + 'admin_id' => $request->user()?->id, + 'action' => 'update_currency', + 'resource_type' => 'currency', + 'resource_id' => $currency->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => $validated, + ]); + + return redirect()->route('admin.currencies.index') + ->with('success', "Currency {$currency->code} has been updated."); + } + + public function destroy(Request $request, Currency $currency): RedirectResponse + { + if ($currency->is_base) { + return redirect()->back()->with('error', 'Cannot delete the base currency.'); + } + + $code = $currency->code; + $currency->delete(); + + Cache::forget('currencies:enabled'); + Cache::forget('currencies:base'); + + AuditLog::create([ + 'admin_id' => $request->user()?->id, + 'action' => 'delete_currency', + 'resource_type' => 'currency', + 'resource_id' => 0, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['code' => $code], + ]); + + return redirect()->route('admin.currencies.index') + ->with('success', "Currency {$code} has been deleted."); + } + + public function syncRates(Request $request): RedirectResponse + { + Artisan::call('currencies:sync-rates'); + + AuditLog::create([ + 'admin_id' => $request->user()?->id, + 'action' => 'sync_exchange_rates', + 'resource_type' => 'currency', + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + return redirect()->route('admin.currencies.index') + ->with('success', 'Exchange rates have been synced.'); + } +} diff --git a/website/app/Http/Controllers/Admin/DashboardController.php b/website/app/Http/Controllers/Admin/DashboardController.php index 648f93a..63aa2fe 100644 --- a/website/app/Http/Controllers/Admin/DashboardController.php +++ b/website/app/Http/Controllers/Admin/DashboardController.php @@ -25,7 +25,7 @@ class DashboardController extends Controller ->where('created_at', '>=', now()->startOfMonth()) ->count(); - // MRR: sum of plan prices normalized to monthly + // MRR: sum of recurring_amount (with plan_prices fallback) normalized to monthly $mrr = $this->calculateMrr(); // Previous month MRR for month-over-month change @@ -36,6 +36,12 @@ class DashboardController extends Controller $arr = $mrr * 12; + // Invoice-based MRR: total paid in last 30 days + $invoiceMrr = (float) Invoice::query() + ->where('status', 'paid') + ->where('paid_at', '>=', now()->subDays(30)) + ->sum('total'); + $activeServices = Service::query() ->where('status', 'active') ->count(); @@ -107,6 +113,7 @@ class DashboardController extends Controller 'totalCustomers', 'newCustomersThisMonth', 'mrr', + 'invoiceMrr', 'mrrChangePercent', 'arr', 'activeServices', @@ -131,16 +138,27 @@ class DashboardController extends Controller return (float) (Subscription::query() ->where('subscriptions.stripe_status', 'active') ->whereNotNull('subscriptions.plan_id') - ->join('plan_prices', function ($join): void { + ->leftJoin('plan_prices', function ($join): void { $join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id') ->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle'); }) - ->selectRaw('SUM(CASE subscriptions.billing_cycle - WHEN "monthly" THEN plan_prices.price - WHEN "quarterly" THEN plan_prices.price / 3 - WHEN "semi_annual" THEN plan_prices.price / 6 - WHEN "annual" THEN plan_prices.price / 12 - ELSE plan_prices.price + ->selectRaw('SUM(CASE + WHEN subscriptions.recurring_amount IS NOT NULL THEN + CASE subscriptions.billing_cycle + WHEN "monthly" THEN subscriptions.recurring_amount + WHEN "quarterly" THEN subscriptions.recurring_amount / 3 + WHEN "semi_annual" THEN subscriptions.recurring_amount / 6 + WHEN "annual" THEN subscriptions.recurring_amount / 12 + ELSE subscriptions.recurring_amount + END + ELSE + CASE subscriptions.billing_cycle + WHEN "monthly" THEN plan_prices.price + WHEN "quarterly" THEN plan_prices.price / 3 + WHEN "semi_annual" THEN plan_prices.price / 6 + WHEN "annual" THEN plan_prices.price / 12 + ELSE plan_prices.price + END END) as mrr') ->value('mrr') ?? 0); } @@ -157,16 +175,27 @@ class DashboardController extends Controller $query->whereNull('subscriptions.cancelled_at') ->orWhere('subscriptions.cancelled_at', '>', $lastMonthEnd); }) - ->join('plan_prices', function ($join): void { + ->leftJoin('plan_prices', function ($join): void { $join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id') ->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle'); }) - ->selectRaw('SUM(CASE subscriptions.billing_cycle - WHEN "monthly" THEN plan_prices.price - WHEN "quarterly" THEN plan_prices.price / 3 - WHEN "semi_annual" THEN plan_prices.price / 6 - WHEN "annual" THEN plan_prices.price / 12 - ELSE plan_prices.price + ->selectRaw('SUM(CASE + WHEN subscriptions.recurring_amount IS NOT NULL THEN + CASE subscriptions.billing_cycle + WHEN "monthly" THEN subscriptions.recurring_amount + WHEN "quarterly" THEN subscriptions.recurring_amount / 3 + WHEN "semi_annual" THEN subscriptions.recurring_amount / 6 + WHEN "annual" THEN subscriptions.recurring_amount / 12 + ELSE subscriptions.recurring_amount + END + ELSE + CASE subscriptions.billing_cycle + WHEN "monthly" THEN plan_prices.price + WHEN "quarterly" THEN plan_prices.price / 3 + WHEN "semi_annual" THEN plan_prices.price / 6 + WHEN "annual" THEN plan_prices.price / 12 + ELSE plan_prices.price + END END) as mrr') ->value('mrr') ?? 0); } diff --git a/website/app/Http/Controllers/Admin/DebitNoteController.php b/website/app/Http/Controllers/Admin/DebitNoteController.php new file mode 100644 index 0000000..c5ce60d --- /dev/null +++ b/website/app/Http/Controllers/Admin/DebitNoteController.php @@ -0,0 +1,152 @@ +with('user:id,name,email'); + + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search): void { + $q->where('number', 'like', "%{$search}%") + ->orWhereHas('user', function ($uq) use ($search): void { + $uq->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + }); + } + + if ($status = $request->input('status')) { + $query->where('status', $status); + } + + $debitNotes = $query->latest()->paginate(15)->withQueryString(); + + return Inertia::render('Admin/DebitNotes/Index', [ + 'debitNotes' => $debitNotes, + 'filters' => [ + 'search' => $request->input('search', ''), + 'status' => $request->input('status', ''), + ], + ]); + } + + public function create(): Response + { + $customers = User::query() + ->select('id', 'name', 'email', 'credit_balance') + ->orderBy('name') + ->get(); + + $invoices = Invoice::query() + ->select('id', 'number', 'user_id', 'total', 'status') + ->latest() + ->limit(200) + ->get(); + + return Inertia::render('Admin/DebitNotes/Create', [ + 'customers' => $customers, + 'invoices' => $invoices, + ]); + } + + public function store(StoreDebitNoteRequest $request): RedirectResponse + { + $validated = $request->validated(); + + $user = User::findOrFail($validated['user_id']); + $invoice = isset($validated['invoice_id']) ? Invoice::find($validated['invoice_id']) : null; + + $debitNote = $this->creditService->issueDebitNote( + user: $user, + amount: (float) $validated['amount'], + reasonType: $validated['reason_type'], + reason: $validated['reason'] ?? null, + invoice: $invoice, + creator: $request->user(), + ); + + AuditLog::create([ + 'user_id' => $user->id, + 'admin_id' => $request->user()?->id, + 'action' => 'issue_debit_note', + 'resource_type' => 'debit_note', + 'resource_id' => $debitNote->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => [ + 'amount' => $validated['amount'], + 'reason_type' => $validated['reason_type'], + 'reason' => $validated['reason'] ?? null, + ], + ]); + + return redirect()->route('debit-notes.show', $debitNote) + ->with('success', "Debit note {$debitNote->number} has been issued."); + } + + public function show(DebitNote $debitNote): Response + { + $debitNote->load([ + 'user:id,name,email,credit_balance', + 'invoice:id,number,total,status', + 'creator:id,name,email', + ]); + + return Inertia::render('Admin/DebitNotes/Show', [ + 'debitNote' => $debitNote, + ]); + } + + public function download(DebitNote $debitNote): \Symfony\Component\HttpFoundation\Response + { + $debitNote->load(['user', 'invoice']); + + $pdf = Pdf::loadView('pdf.debit-note', ['debitNote' => $debitNote]); + + return $pdf->download("debit-note-{$debitNote->number}.pdf"); + } + + public function void(Request $request, DebitNote $debitNote): RedirectResponse + { + if ($debitNote->status === 'voided') { + return redirect()->back()->with('error', 'This debit note is already voided.'); + } + + $this->creditService->voidDebitNote($debitNote); + + AuditLog::create([ + 'user_id' => $debitNote->user_id, + 'admin_id' => $request->user()?->id, + 'action' => 'void_debit_note', + 'resource_type' => 'debit_note', + 'resource_id' => $debitNote->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + ]); + + return redirect()->back()->with('success', "Debit note {$debitNote->number} has been voided."); + } +} diff --git a/website/app/Http/Controllers/Admin/FraudQueueController.php b/website/app/Http/Controllers/Admin/FraudQueueController.php new file mode 100644 index 0000000..a7a8858 --- /dev/null +++ b/website/app/Http/Controllers/Admin/FraudQueueController.php @@ -0,0 +1,109 @@ +with(['user:id,name,email', 'order:id,order_number,total,status']) + ->whereNull('reviewed_at'); + + if ($request->filled('risk_level') && $request->input('risk_level') !== 'all') { + $query->where('risk_level', $request->input('risk_level')); + } + + if ($request->filled('action') && $request->input('action') !== 'all') { + $query->where('auto_action', $request->input('action')); + } + + $assessments = $query->latest()->paginate(25); + + return Inertia::render('Admin/FraudQueue/Index', [ + 'assessments' => $assessments, + 'filters' => [ + 'risk_level' => $request->input('risk_level', 'all'), + 'action' => $request->input('action', 'all'), + ], + ]); + } + + public function show(OrderRiskAssessment $assessment): Response + { + $assessment->load([ + 'user:id,name,email,status,created_at', + 'order:id,order_number,total,status,currency,created_at', + 'reviewer:id,name', + ]); + + return Inertia::render('Admin/FraudQueue/Show', [ + 'assessment' => $assessment, + ]); + } + + public function approve(Request $request, OrderRiskAssessment $assessment): RedirectResponse + { + $assessment->update([ + 'reviewed_by' => $request->user()->id, + 'reviewed_at' => now(), + 'auto_action' => 'approve', + ]); + + if ($assessment->order) { + $assessment->order->update(['status' => 'processing']); + } + + AuditLog::create([ + 'user_id' => $assessment->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'fraud_assessment_approved', + 'resource_type' => 'OrderRiskAssessment', + 'resource_id' => $assessment->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['risk_score' => $assessment->risk_score, 'risk_level' => $assessment->risk_level], + ]); + + return back()->with('success', 'Order approved and released from fraud queue.'); + } + + public function reject(Request $request, OrderRiskAssessment $assessment): RedirectResponse + { + $assessment->update([ + 'reviewed_by' => $request->user()->id, + 'reviewed_at' => now(), + 'auto_action' => 'reject', + ]); + + if ($assessment->order) { + $assessment->order->update([ + 'status' => 'cancelled', + 'cancelled_at' => now(), + ]); + } + + AuditLog::create([ + 'user_id' => $assessment->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'fraud_assessment_rejected', + 'resource_type' => 'OrderRiskAssessment', + 'resource_id' => $assessment->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['risk_score' => $assessment->risk_score, 'risk_level' => $assessment->risk_level], + ]); + + return back()->with('success', 'Order rejected and cancelled.'); + } +} diff --git a/website/app/Http/Controllers/Admin/KnowledgeBaseArticleController.php b/website/app/Http/Controllers/Admin/KnowledgeBaseArticleController.php new file mode 100644 index 0000000..7b26b4d --- /dev/null +++ b/website/app/Http/Controllers/Admin/KnowledgeBaseArticleController.php @@ -0,0 +1,165 @@ +with(['category:id,name', 'author:id,name']); + + if ($request->filled('search')) { + $search = $request->input('search'); + $query->where(function ($q) use ($search): void { + $q->where('title', 'like', '%'.$search.'%') + ->orWhere('excerpt', 'like', '%'.$search.'%'); + }); + } + + if ($request->filled('category') && $request->input('category') !== 'all') { + $query->where('category_id', $request->input('category')); + } + + if ($request->filled('status') && $request->input('status') !== 'all') { + $query->where('status', $request->input('status')); + } + + $articles = $query + ->orderByDesc('updated_at') + ->paginate(25); + + $articles->getCollection()->transform(function (KnowledgeBaseArticle $article) { + $article->setAttribute('helpful_percentage', $article->helpfulPercentage()); + + return $article; + }); + + $categories = KnowledgeBaseCategory::query() + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Admin/KnowledgeBase/Articles/Index', [ + 'articles' => $articles, + 'categories' => $categories, + 'filters' => [ + 'search' => $request->input('search', ''), + 'category' => $request->input('category', 'all'), + 'status' => $request->input('status', 'all'), + ], + ]); + } + + public function create(): Response + { + $categories = KnowledgeBaseCategory::query() + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Admin/KnowledgeBase/Articles/Create', [ + 'categories' => $categories, + ]); + } + + public function store(StoreKbArticleRequest $request): RedirectResponse + { + $article = KnowledgeBaseArticle::query()->create([ + 'title' => $request->validated('title'), + 'slug' => $request->validated('slug'), + 'category_id' => $request->validated('category_id'), + 'author_id' => $request->user()->id, + 'content' => $request->validated('content'), + 'excerpt' => $request->validated('excerpt'), + 'status' => $request->validated('status'), + 'is_featured' => $request->boolean('is_featured', false), + 'published_at' => $request->validated('status') === 'published' ? now() : null, + ]); + + $article->revisions()->create([ + 'content' => $request->validated('content'), + 'edited_by' => $request->user()->id, + ]); + + return redirect() + ->route('admin.kb.articles.index') + ->with('success', 'Article created successfully.'); + } + + public function show(KnowledgeBaseArticle $article): Response + { + $article->load([ + 'category:id,name,slug', + 'author:id,name', + 'revisions' => fn ($q) => $q->with('editor:id,name')->orderByDesc('created_at'), + ]); + + $article->setAttribute('helpful_percentage', $article->helpfulPercentage()); + + return Inertia::render('Admin/KnowledgeBase/Articles/Show', [ + 'article' => $article, + ]); + } + + public function edit(KnowledgeBaseArticle $article): Response + { + $article->load([ + 'revisions' => fn ($q) => $q->with('editor:id,name')->orderByDesc('created_at'), + ]); + + $categories = KnowledgeBaseCategory::query() + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Admin/KnowledgeBase/Articles/Edit', [ + 'article' => $article, + 'categories' => $categories, + ]); + } + + public function update(UpdateKbArticleRequest $request, KnowledgeBaseArticle $article): RedirectResponse + { + $wasPublished = $article->status === 'published'; + $nowPublished = $request->validated('status') === 'published'; + + $article->update([ + 'title' => $request->validated('title'), + 'slug' => $request->validated('slug'), + 'category_id' => $request->validated('category_id'), + 'content' => $request->validated('content'), + 'excerpt' => $request->validated('excerpt'), + 'status' => $request->validated('status'), + 'is_featured' => $request->boolean('is_featured', false), + 'published_at' => (! $wasPublished && $nowPublished) ? now() : $article->published_at, + ]); + + $article->revisions()->create([ + 'content' => $request->validated('content'), + 'edited_by' => $request->user()->id, + ]); + + return redirect() + ->route('admin.kb.articles.index') + ->with('success', 'Article updated successfully.'); + } + + public function destroy(KnowledgeBaseArticle $article): RedirectResponse + { + $article->delete(); + + return redirect() + ->route('admin.kb.articles.index') + ->with('success', 'Article deleted successfully.'); + } +} diff --git a/website/app/Http/Controllers/Admin/KnowledgeBaseCategoryController.php b/website/app/Http/Controllers/Admin/KnowledgeBaseCategoryController.php new file mode 100644 index 0000000..38c9ec6 --- /dev/null +++ b/website/app/Http/Controllers/Admin/KnowledgeBaseCategoryController.php @@ -0,0 +1,107 @@ +withCount('articles') + ->with('parent:id,name') + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + return Inertia::render('Admin/KnowledgeBase/Categories/Index', [ + 'categories' => $categories, + ]); + } + + public function create(): Response + { + $parentCategories = KnowledgeBaseCategory::query() + ->root() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Admin/KnowledgeBase/Categories/Create', [ + 'parentCategories' => $parentCategories, + ]); + } + + public function store(StoreKbCategoryRequest $request): RedirectResponse + { + KnowledgeBaseCategory::query()->create([ + 'name' => $request->validated('name'), + 'slug' => $request->validated('slug'), + 'description' => $request->validated('description'), + 'icon' => $request->validated('icon'), + 'parent_id' => $request->validated('parent_id'), + 'sort_order' => $request->validated('sort_order', 0), + 'is_visible' => $request->boolean('is_visible', true), + ]); + + return redirect() + ->route('admin.kb.categories.index') + ->with('success', 'Category created successfully.'); + } + + public function edit(KnowledgeBaseCategory $category): Response + { + $parentCategories = KnowledgeBaseCategory::query() + ->root() + ->where('id', '!=', $category->id) + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Admin/KnowledgeBase/Categories/Edit', [ + 'category' => $category, + 'parentCategories' => $parentCategories, + ]); + } + + public function update(UpdateKbCategoryRequest $request, KnowledgeBaseCategory $category): RedirectResponse + { + $category->update([ + 'name' => $request->validated('name'), + 'slug' => $request->validated('slug'), + 'description' => $request->validated('description'), + 'icon' => $request->validated('icon'), + 'parent_id' => $request->validated('parent_id'), + 'sort_order' => $request->validated('sort_order', 0), + 'is_visible' => $request->boolean('is_visible', true), + ]); + + return redirect() + ->route('admin.kb.categories.index') + ->with('success', 'Category updated successfully.'); + } + + public function destroy(KnowledgeBaseCategory $category): RedirectResponse + { + if ($category->articles()->exists()) { + return redirect() + ->back() + ->with('error', 'Cannot delete a category that has articles. Move or delete the articles first.'); + } + + $category->delete(); + + return redirect() + ->route('admin.kb.categories.index') + ->with('success', 'Category deleted successfully.'); + } +} diff --git a/website/app/Http/Controllers/Admin/PlanController.php b/website/app/Http/Controllers/Admin/PlanController.php index e783acc..fd3a8e5 100644 --- a/website/app/Http/Controllers/Admin/PlanController.php +++ b/website/app/Http/Controllers/Admin/PlanController.php @@ -65,6 +65,10 @@ class PlanController extends Controller 'provisioning_config' => $request->validated('provisioning_config'), 'stock_quantity' => $request->validated('stock_quantity'), 'sort_order' => $request->validated('sort_order', 0), + 'days_to_suspend' => $request->validated('days_to_suspend'), + 'days_to_terminate' => $request->validated('days_to_terminate'), + 'auto_suspend_enabled' => $request->validated('auto_suspend_enabled', true), + 'auto_terminate_enabled' => $request->validated('auto_terminate_enabled', true), 'status' => 'active', ]); @@ -99,6 +103,10 @@ class PlanController extends Controller 'provisioning_config' => $request->validated('provisioning_config'), 'stock_quantity' => $request->validated('stock_quantity'), 'sort_order' => $request->validated('sort_order', 0), + 'days_to_suspend' => $request->validated('days_to_suspend'), + 'days_to_terminate' => $request->validated('days_to_terminate'), + 'auto_suspend_enabled' => $request->validated('auto_suspend_enabled', true), + 'auto_terminate_enabled' => $request->validated('auto_terminate_enabled', true), ]); return redirect() diff --git a/website/app/Http/Controllers/Admin/QuoteController.php b/website/app/Http/Controllers/Admin/QuoteController.php new file mode 100644 index 0000000..2574d6b --- /dev/null +++ b/website/app/Http/Controllers/Admin/QuoteController.php @@ -0,0 +1,234 @@ +with(['user:id,name,email', 'creator:id,name,email']); + + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search): void { + $q->where('number', 'like', "%{$search}%") + ->orWhere('prospect_email', 'like', "%{$search}%") + ->orWhere('prospect_name', 'like', "%{$search}%") + ->orWhereHas('user', function ($uq) use ($search): void { + $uq->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + }); + } + + if ($status = $request->input('status')) { + $query->where('status', $status); + } + + $quotes = $query->latest()->paginate(15)->withQueryString(); + + return Inertia::render('Admin/Quotes/Index', [ + 'quotes' => $quotes, + 'filters' => [ + 'search' => $request->input('search', ''), + 'status' => $request->input('status', ''), + ], + ]); + } + + public function create(): Response + { + $customers = User::query() + ->select('id', 'name', 'email') + ->orderBy('name') + ->get(); + + return Inertia::render('Admin/Quotes/Create', [ + 'customers' => $customers, + ]); + } + + public function store(StoreQuoteRequest $request): RedirectResponse + { + $validated = $request->validated(); + + $items = $validated['items']; + $subtotal = collect($items)->sum(fn (array $item): float => (float) $item['unit_price'] * (int) $item['quantity']); + $tax = (float) ($validated['tax'] ?? 0); + $total = $subtotal + $tax; + + $quote = Quote::create([ + 'user_id' => $validated['user_id'] ?? null, + 'prospect_email' => $validated['prospect_email'] ?? null, + 'prospect_name' => $validated['prospect_name'] ?? null, + 'number' => Quote::generateNumber(), + 'status' => 'draft', + 'items' => $items, + 'subtotal' => $subtotal, + 'tax' => $tax, + 'total' => $total, + 'currency' => $validated['currency'], + 'notes' => $validated['notes'] ?? null, + 'valid_until' => $validated['valid_until'] ?? null, + 'created_by' => $request->user()->id, + ]); + + AuditLog::create([ + 'admin_id' => $request->user()?->id, + 'action' => 'create_quote', + 'resource_type' => 'quote', + 'resource_id' => $quote->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['total' => $total, 'items_count' => count($items)], + ]); + + return redirect()->route('admin.quotes.show', $quote) + ->with('success', "Quote {$quote->number} has been created."); + } + + public function show(Quote $quote): Response + { + $quote->load(['user:id,name,email', 'creator:id,name,email']); + + return Inertia::render('Admin/Quotes/Show', [ + 'quote' => $quote, + ]); + } + + public function edit(Quote $quote): Response + { + $quote->load(['user:id,name,email']); + + $customers = User::query() + ->select('id', 'name', 'email') + ->orderBy('name') + ->get(); + + return Inertia::render('Admin/Quotes/Edit', [ + 'quote' => $quote, + 'customers' => $customers, + ]); + } + + public function update(StoreQuoteRequest $request, Quote $quote): RedirectResponse + { + if (in_array($quote->status, ['accepted', 'rejected'])) { + return redirect()->back()->with('error', 'Cannot edit a quote that has been accepted or rejected.'); + } + + $validated = $request->validated(); + + $items = $validated['items']; + $subtotal = collect($items)->sum(fn (array $item): float => (float) $item['unit_price'] * (int) $item['quantity']); + $tax = (float) ($validated['tax'] ?? 0); + $total = $subtotal + $tax; + + $quote->update([ + 'user_id' => $validated['user_id'] ?? null, + 'prospect_email' => $validated['prospect_email'] ?? null, + 'prospect_name' => $validated['prospect_name'] ?? null, + 'items' => $items, + 'subtotal' => $subtotal, + 'tax' => $tax, + 'total' => $total, + 'currency' => $validated['currency'], + 'notes' => $validated['notes'] ?? null, + 'valid_until' => $validated['valid_until'] ?? null, + ]); + + AuditLog::create([ + 'admin_id' => $request->user()?->id, + 'action' => 'update_quote', + 'resource_type' => 'quote', + 'resource_id' => $quote->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['total' => $total], + ]); + + return redirect()->route('admin.quotes.show', $quote) + ->with('success', "Quote {$quote->number} has been updated."); + } + + public function destroy(Request $request, Quote $quote): RedirectResponse + { + if (in_array($quote->status, ['accepted'])) { + return redirect()->back()->with('error', 'Cannot delete an accepted quote.'); + } + + $number = $quote->number; + $quoteId = $quote->id; + $quote->delete(); + + AuditLog::create([ + 'admin_id' => $request->user()?->id, + 'action' => 'delete_quote', + 'resource_type' => 'quote', + 'resource_id' => $quoteId, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['number' => $number], + ]); + + return redirect()->route('admin.quotes.index') + ->with('success', "Quote {$number} has been deleted."); + } + + public function send(Request $request, Quote $quote): RedirectResponse + { + $recipientEmail = $quote->getRecipientEmail(); + + if (! $recipientEmail) { + return redirect()->back()->with('error', 'No recipient email found for this quote.'); + } + + $signedUrl = \Illuminate\Support\Facades\URL::signedRoute('quotes.show', ['quote' => $quote->id]); + + if ($quote->user) { + $quote->user->notify(new QuoteSentNotification($quote, $signedUrl)); + } else { + Notification::route('mail', $recipientEmail) + ->notify(new QuoteSentNotification($quote, $signedUrl)); + } + + $quote->markSent(); + + AuditLog::create([ + 'admin_id' => $request->user()?->id, + 'action' => 'send_quote', + 'resource_type' => 'quote', + 'resource_id' => $quote->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['sent_to' => $recipientEmail], + ]); + + return redirect()->back() + ->with('success', "Quote {$quote->number} has been sent to {$recipientEmail}."); + } + + public function download(Quote $quote): \Symfony\Component\HttpFoundation\Response + { + $quote->load(['user', 'creator']); + + $pdf = Pdf::loadView('pdf.quote', ['quote' => $quote]); + + return $pdf->download("quote-{$quote->number}.pdf"); + } +} diff --git a/website/app/Http/Controllers/Admin/RoleController.php b/website/app/Http/Controllers/Admin/RoleController.php new file mode 100644 index 0000000..7740201 --- /dev/null +++ b/website/app/Http/Controllers/Admin/RoleController.php @@ -0,0 +1,192 @@ + + */ + private const PROTECTED_ROLES = ['admin', 'customer', 'super_admin']; + + public function index(): Response + { + $roles = Role::where('guard_name', 'web') + ->withCount(['permissions', 'users']) + ->orderBy('name') + ->get() + ->map(fn (Role $role): array => [ + 'id' => $role->id, + 'name' => $role->name, + 'permissions_count' => $role->permissions_count, + 'users_count' => $role->users_count, + 'is_protected' => in_array($role->name, self::PROTECTED_ROLES, true), + 'created_at' => $role->created_at?->toISOString(), + ]); + + return Inertia::render('Admin/Staff/Roles/Index', [ + 'roles' => $roles, + ]); + } + + public function create(): Response + { + $permissions = $this->groupedPermissions(); + + return Inertia::render('Admin/Staff/Roles/Create', [ + 'permissionGroups' => $permissions, + ]); + } + + public function store(StoreRoleRequest $request): RedirectResponse + { + $role = Role::create([ + 'name' => $request->validated('name'), + 'guard_name' => 'web', + ]); + + $role->syncPermissions($request->validated('permissions')); + + AuditLog::create([ + 'admin_id' => $request->user()->id, + 'action' => 'role_created', + 'resource_type' => 'role', + 'resource_id' => $role->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => [ + 'name' => $role->name, + 'permissions' => $request->validated('permissions'), + ], + ]); + + return redirect() + ->route('admin.roles.index') + ->with('success', "Role \"{$role->name}\" created successfully."); + } + + public function edit(Role $role): Response + { + $permissions = $this->groupedPermissions(); + $rolePermissions = $role->permissions->pluck('name')->toArray(); + $usersCount = $role->users()->count(); + + return Inertia::render('Admin/Staff/Roles/Edit', [ + 'role' => [ + 'id' => $role->id, + 'name' => $role->name, + 'is_protected' => in_array($role->name, self::PROTECTED_ROLES, true), + 'permissions' => $rolePermissions, + ], + 'permissionGroups' => $permissions, + 'usersCount' => $usersCount, + ]); + } + + public function update(UpdateRoleRequest $request, Role $role): RedirectResponse + { + $oldPermissions = $role->permissions->pluck('name')->toArray(); + + // Only update name if not a protected role + if (! in_array($role->name, self::PROTECTED_ROLES, true) && $request->has('name')) { + $role->update(['name' => $request->validated('name')]); + } + + $role->syncPermissions($request->validated('permissions')); + + AuditLog::create([ + 'admin_id' => $request->user()->id, + 'action' => 'role_updated', + 'resource_type' => 'role', + 'resource_id' => $role->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => [ + 'name' => $role->name, + 'old_permissions' => $oldPermissions, + 'new_permissions' => $request->validated('permissions'), + ], + ]); + + return redirect() + ->route('admin.roles.index') + ->with('success', "Role \"{$role->name}\" updated successfully."); + } + + public function destroy(Role $role): RedirectResponse + { + if (in_array($role->name, self::PROTECTED_ROLES, true)) { + return redirect() + ->route('admin.roles.index') + ->with('error', "Cannot delete the built-in \"{$role->name}\" role."); + } + + // Check if any users are assigned to this role + if ($role->users()->count() > 0) { + return redirect() + ->route('admin.roles.index') + ->with('error', "Cannot delete \"{$role->name}\" — it still has assigned users."); + } + + $roleName = $role->name; + + AuditLog::create([ + 'admin_id' => auth()->id(), + 'action' => 'role_deleted', + 'resource_type' => 'role', + 'resource_id' => $role->id, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'changes' => ['name' => $roleName], + ]); + + $role->delete(); + + return redirect() + ->route('admin.roles.index') + ->with('success', "Role \"{$roleName}\" deleted successfully."); + } + + /** + * Get all permissions grouped by category. + * + * @return array> + */ + private function groupedPermissions(): array + { + $permissions = Permission::where('guard_name', 'web') + ->orderBy('name') + ->pluck('name') + ->toArray(); + + $grouped = []; + + foreach ($permissions as $permission) { + if (str_contains($permission, '.')) { + $parts = explode('.', $permission, 2); + $grouped[$parts[0]][] = $permission; + } else { + $grouped['legacy'][] = $permission; + } + } + + // Sort groups alphabetically + ksort($grouped); + + return $grouped; + } +} diff --git a/website/app/Http/Controllers/Admin/StaffController.php b/website/app/Http/Controllers/Admin/StaffController.php new file mode 100644 index 0000000..dd4723c --- /dev/null +++ b/website/app/Http/Controllers/Admin/StaffController.php @@ -0,0 +1,99 @@ +whereNotIn('name', ['customer']) + ->pluck('name') + ->toArray(); + + $query = User::role($adminRoles) + ->with('roles'); + + // Search by name or email + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search): void { + $q->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + } + + // Filter by role + if ($role = $request->input('role')) { + $query->role($role); + } + + $staffMembers = $query + ->orderBy('name') + ->paginate(20) + ->withQueryString(); + + // Attach last login info + $staffMembers->getCollection()->transform(function (User $user): User { + $lastLogin = LoginHistory::query() + ->forUser($user->id) + ->where('success', true) + ->latest() + ->first(); + + $user->setAttribute('last_login_at', $lastLogin?->created_at); + $user->setAttribute('last_login_ip', $lastLogin?->ip_address); + $user->setAttribute('role_names', $user->roles->pluck('name')->toArray()); + + return $user; + }); + + $roles = Role::where('guard_name', 'web') + ->whereNotIn('name', ['customer']) + ->withCount('users') + ->get() + ->map(fn (Role $role): array => [ + 'name' => $role->name, + 'users_count' => $role->users_count, + ]); + + return Inertia::render('Admin/Staff/Index', [ + 'staff' => $staffMembers, + 'roles' => $roles, + 'filters' => [ + 'search' => $request->input('search', ''), + 'role' => $request->input('role', ''), + ], + ]); + } + + public function show(User $user): Response + { + $user->load(['roles.permissions']); + + $allPermissions = $user->getAllPermissions()->pluck('name')->sort()->values(); + $directPermissions = $user->getDirectPermissions()->pluck('name')->sort()->values(); + + $loginHistories = LoginHistory::query() + ->forUser($user->id) + ->latest() + ->limit(20) + ->get(); + + return Inertia::render('Admin/Staff/Show', [ + 'staffMember' => $user, + 'allPermissions' => $allPermissions, + 'directPermissions' => $directPermissions, + 'loginHistories' => $loginHistories, + ]); + } +} diff --git a/website/app/Http/Controllers/Admin/TicketController.php b/website/app/Http/Controllers/Admin/TicketController.php index ed87905..99efd4c 100644 --- a/website/app/Http/Controllers/Admin/TicketController.php +++ b/website/app/Http/Controllers/Admin/TicketController.php @@ -6,10 +6,15 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\AuditLog; +use App\Models\CannedResponse; +use App\Models\Department; use App\Models\SupportTicket; use App\Models\TicketReply; +use App\Models\TicketTag; +use App\Models\User; use App\Notifications\TicketStaffReplyNotification; use App\Notifications\TicketStatusChangedNotification; +use App\Services\Support\SlaService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -17,10 +22,14 @@ use Inertia\Response; class TicketController extends Controller { + public function __construct( + private readonly SlaService $slaService, + ) {} + public function index(Request $request): Response { $query = SupportTicket::query() - ->with('user:id,name,email'); + ->with(['user:id,name,email', 'assignee:id,name', 'tags', 'departmentRelation:id,name']); if ($search = $request->input('search')) { $query->where(function ($q) use ($search): void { @@ -44,17 +53,45 @@ class TicketController extends Controller $query->where('department', $department); } + if ($assignedTo = $request->input('assigned_to')) { + $query->where('assigned_to', $assignedTo); + } + + if ($tagIds = $request->input('tags')) { + $tagArray = is_array($tagIds) ? $tagIds : explode(',', $tagIds); + $query->whereHas('tags', function ($q) use ($tagArray): void { + $q->whereIn('ticket_tags.id', $tagArray); + }); + } + + if ($request->boolean('sla_breached')) { + $query->where(function ($q): void { + $q->where('sla_first_response_breached', true) + ->orWhere('sla_resolution_breached', true); + }); + } + $tickets = $query->latest('updated_at') ->paginate(25) ->withQueryString(); + $tags = TicketTag::query()->orderBy('name')->get(); + $staffUsers = User::role('admin')->select('id', 'name')->orderBy('name')->get(); + $departments = Department::query()->orderBy('sort_order')->get(['id', 'name', 'slug']); + return Inertia::render('Admin/Tickets/Index', [ 'tickets' => $tickets, + 'tags' => $tags, + 'staffUsers' => $staffUsers, + 'departments' => $departments, 'filters' => [ 'search' => $request->input('search', ''), 'status' => $request->input('status', ''), 'priority' => $request->input('priority', ''), 'department' => $request->input('department', ''), + 'assigned_to' => $request->input('assigned_to', ''), + 'tags' => $request->input('tags', ''), + 'sla_breached' => $request->boolean('sla_breached'), ], ]); } @@ -64,10 +101,30 @@ class TicketController extends Controller $ticket->load([ 'replies.user:id,name,email', 'user:id,name,email,status,company', + 'assignee:id,name,email', + 'tags', + 'departmentRelation:id,name,slug', + 'slaPolicy', + 'satisfactionRating', ]); + $cannedResponses = CannedResponse::query() + ->where('is_shared', true) + ->orderBy('title') + ->get(['id', 'title', 'content', 'category']); + + $tags = TicketTag::query()->orderBy('name')->get(); + + $staffUsers = User::role('admin')->select('id', 'name', 'email')->orderBy('name')->get(); + + $departments = Department::query()->orderBy('sort_order')->get(['id', 'name', 'slug']); + return Inertia::render('Admin/Tickets/Show', [ 'ticket' => $ticket, + 'cannedResponses' => $cannedResponses, + 'tags' => $tags, + 'staffUsers' => $staffUsers, + 'departments' => $departments, ]); } @@ -76,37 +133,66 @@ class TicketController extends Controller $validated = $request->validate([ 'body' => ['required', 'string', 'max:5000'], 'status' => ['nullable', 'in:open,in_progress,waiting,closed'], + 'is_internal' => ['boolean'], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['integer', 'exists:ticket_tags,id'], ]); + $isInternal = $validated['is_internal'] ?? false; + $reply = TicketReply::query()->create([ 'ticket_id' => $ticket->id, 'user_id' => $request->user()->id, 'body' => $validated['body'], 'is_staff_reply' => true, + 'is_internal' => $isInternal, ]); $updateData = ['last_reply_at' => now()]; if (! empty($validated['status'])) { $updateData['status'] = $validated['status']; + + if ($validated['status'] === 'closed') { + $updateData['resolved_at'] = now(); + } } $ticket->update($updateData); - // Notify the customer about the staff reply - $ticket->user?->notify(new TicketStaffReplyNotification($ticket, $reply)); + // Record first response for SLA tracking (only for non-internal replies) + if (! $isInternal) { + $this->slaService->recordFirstResponse($ticket); + } + + // Sync tags if provided + if (isset($validated['tags'])) { + $ticket->tags()->sync($validated['tags']); + } + + // Only notify customer for non-internal replies + if (! $isInternal) { + $ticket->user?->notify(new TicketStaffReplyNotification($ticket, $reply)); + } + + // Track canned response usage if applicable + if ($cannedResponseId = $request->input('canned_response_id')) { + CannedResponse::query() + ->where('id', $cannedResponseId) + ->increment('usage_count'); + } AuditLog::query()->create([ 'user_id' => $ticket->user_id, 'admin_id' => $request->user()->id, - 'action' => 'reply_ticket', + 'action' => $isInternal ? 'internal_note_ticket' : 'reply_ticket', 'resource_type' => 'support_ticket', 'resource_id' => $ticket->id, 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), ]); - return redirect()->back()->with('success', 'Reply sent successfully.'); + return redirect()->back()->with('success', $isInternal ? 'Internal note added.' : 'Reply sent successfully.'); } public function updateStatus(Request $request, SupportTicket $ticket): RedirectResponse @@ -117,7 +203,13 @@ class TicketController extends Controller $oldStatus = $ticket->status; - $ticket->update(['status' => $validated['status']]); + $updateData = ['status' => $validated['status']]; + + if ($validated['status'] === 'closed') { + $updateData['resolved_at'] = now(); + } + + $ticket->update($updateData); // Notify the customer about the status change $ticket->user?->notify(new TicketStatusChangedNotification($ticket, $oldStatus, $validated['status'])); @@ -135,4 +227,91 @@ class TicketController extends Controller return redirect()->back()->with('success', 'Ticket status updated.'); } + + public function assign(Request $request, SupportTicket $ticket): RedirectResponse + { + $validated = $request->validate([ + 'assigned_to' => ['nullable', 'exists:users,id'], + ]); + + $ticket->update([ + 'assigned_to' => $validated['assigned_to'], + ]); + + AuditLog::query()->create([ + 'user_id' => $ticket->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'assign_ticket', + 'resource_type' => 'support_ticket', + 'resource_id' => $ticket->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'details' => json_encode(['assigned_to' => $validated['assigned_to']]), + ]); + + return redirect()->back()->with('success', 'Ticket assigned successfully.'); + } + + public function merge(Request $request, SupportTicket $ticket): RedirectResponse + { + $validated = $request->validate([ + 'target_ticket_id' => ['required', 'exists:support_tickets,id', 'different:ticket.id'], + ]); + + $targetTicket = SupportTicket::query()->findOrFail($validated['target_ticket_id']); + + // Move all replies from source to target + $ticket->replies()->update(['ticket_id' => $targetTicket->id]); + + // Move tags + $sourceTags = $ticket->tags()->pluck('ticket_tags.id')->toArray(); + $existingTargetTags = $targetTicket->tags()->pluck('ticket_tags.id')->toArray(); + $newTags = array_diff($sourceTags, $existingTargetTags); + + if (! empty($newTags)) { + $targetTicket->tags()->attach($newTags); + } + + // Mark source ticket as merged and closed + $ticket->update([ + 'merged_into_ticket_id' => $targetTicket->id, + 'status' => 'closed', + 'resolved_at' => now(), + ]); + + // Add a system note on the target ticket + TicketReply::query()->create([ + 'ticket_id' => $targetTicket->id, + 'user_id' => $request->user()->id, + 'body' => "Ticket #{$ticket->id} ({$ticket->ticket_reference}) was merged into this ticket.", + 'is_staff_reply' => true, + 'is_internal' => true, + ]); + + AuditLog::query()->create([ + 'user_id' => $ticket->user_id, + 'admin_id' => $request->user()->id, + 'action' => 'merge_ticket', + 'resource_type' => 'support_ticket', + 'resource_id' => $ticket->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'details' => json_encode(['merged_into' => $targetTicket->id]), + ]); + + return redirect()->route('admin.tickets.show', $targetTicket) + ->with('success', "Ticket #{$ticket->id} merged into #{$targetTicket->id}."); + } + + public function updateTags(Request $request, SupportTicket $ticket): RedirectResponse + { + $validated = $request->validate([ + 'tags' => ['array'], + 'tags.*' => ['integer', 'exists:ticket_tags,id'], + ]); + + $ticket->tags()->sync($validated['tags'] ?? []); + + return redirect()->back()->with('success', 'Tags updated successfully.'); + } } diff --git a/website/app/Http/Controllers/Admin/TicketSettingsController.php b/website/app/Http/Controllers/Admin/TicketSettingsController.php new file mode 100644 index 0000000..5f50356 --- /dev/null +++ b/website/app/Http/Controllers/Admin/TicketSettingsController.php @@ -0,0 +1,246 @@ +with(['slaPolicy', 'autoAssignee:id,name,email']) + ->orderBy('sort_order') + ->get(); + + $slaPolicies = SlaPolicy::query()->orderBy('name')->get(); + + $businessHours = SlaBusinessHours::query() + ->orderBy('day_of_week') + ->get(); + + $tags = TicketTag::query()->orderBy('name')->get(); + + $customFields = TicketCustomField::query() + ->with('department:id,name') + ->orderBy('sort_order') + ->get(); + + $staffUsers = User::role('admin')->select('id', 'name', 'email')->orderBy('name')->get(); + + return Inertia::render('Admin/Tickets/Settings', [ + 'departments' => $departments, + 'slaPolicies' => $slaPolicies, + 'businessHours' => $businessHours, + 'tags' => $tags, + 'customFields' => $customFields, + 'staffUsers' => $staffUsers, + ]); + } + + // --- Departments --- + + public function storeDepartment(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'unique:departments,slug'], + 'email' => ['nullable', 'email', 'max:255'], + 'auto_assign_to' => ['nullable', 'exists:users,id'], + 'sla_policy_id' => ['nullable', 'exists:sla_policies,id'], + 'sort_order' => ['integer'], + ]); + + $validated['slug'] = Str::slug($validated['slug']); + + Department::query()->create($validated); + + return redirect()->back()->with('success', 'Department created successfully.'); + } + + public function updateDepartment(Request $request, Department $department): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('departments', 'slug')->ignore($department->id)], + 'email' => ['nullable', 'email', 'max:255'], + 'auto_assign_to' => ['nullable', 'exists:users,id'], + 'sla_policy_id' => ['nullable', 'exists:sla_policies,id'], + 'sort_order' => ['integer'], + ]); + + $validated['slug'] = Str::slug($validated['slug']); + + $department->update($validated); + + return redirect()->back()->with('success', 'Department updated successfully.'); + } + + public function destroyDepartment(Department $department): RedirectResponse + { + if ($department->tickets()->exists()) { + return redirect()->back()->with('error', 'Cannot delete department with existing tickets.'); + } + + $department->delete(); + + return redirect()->back()->with('success', 'Department deleted successfully.'); + } + + // --- SLA Policies --- + + public function storeSlaPolicy(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'priority' => ['required', 'string', 'in:low,medium,high,urgent'], + 'first_response_hours' => ['required', 'integer', 'min:1'], + 'resolution_hours' => ['required', 'integer', 'min:1'], + 'business_hours_only' => ['boolean'], + ]); + + SlaPolicy::query()->create($validated); + + return redirect()->back()->with('success', 'SLA policy created successfully.'); + } + + public function updateSlaPolicy(Request $request, SlaPolicy $slaPolicy): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'priority' => ['required', 'string', 'in:low,medium,high,urgent'], + 'first_response_hours' => ['required', 'integer', 'min:1'], + 'resolution_hours' => ['required', 'integer', 'min:1'], + 'business_hours_only' => ['boolean'], + ]); + + $slaPolicy->update($validated); + + return redirect()->back()->with('success', 'SLA policy updated successfully.'); + } + + public function destroySlaPolicy(SlaPolicy $slaPolicy): RedirectResponse + { + if ($slaPolicy->departments()->exists()) { + return redirect()->back()->with('error', 'Cannot delete SLA policy assigned to departments.'); + } + + $slaPolicy->delete(); + + return redirect()->back()->with('success', 'SLA policy deleted successfully.'); + } + + // --- Business Hours --- + + public function updateBusinessHours(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'hours' => ['required', 'array'], + 'hours.*.day_of_week' => ['required', 'integer', 'between:0,6'], + 'hours.*.start_time' => ['required', 'date_format:H:i'], + 'hours.*.end_time' => ['required', 'date_format:H:i', 'after:hours.*.start_time'], + 'hours.*.is_holiday' => ['boolean'], + ]); + + // Replace all business hours with the new set + SlaBusinessHours::query()->delete(); + + foreach ($validated['hours'] as $hour) { + SlaBusinessHours::query()->create($hour); + } + + return redirect()->back()->with('success', 'Business hours updated successfully.'); + } + + // --- Tags --- + + public function storeTag(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'color' => ['required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]); + + TicketTag::query()->create($validated); + + return redirect()->back()->with('success', 'Tag created successfully.'); + } + + public function updateTag(Request $request, TicketTag $ticketTag): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'color' => ['required', 'string', 'size:7', 'regex:/^#[0-9A-Fa-f]{6}$/'], + ]); + + $ticketTag->update($validated); + + return redirect()->back()->with('success', 'Tag updated successfully.'); + } + + public function destroyTag(TicketTag $ticketTag): RedirectResponse + { + $ticketTag->tickets()->detach(); + $ticketTag->delete(); + + return redirect()->back()->with('success', 'Tag deleted successfully.'); + } + + // --- Custom Fields --- + + public function storeCustomField(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'in:text,select,checkbox,number'], + 'options' => ['nullable', 'array'], + 'options.*' => ['string', 'max:255'], + 'is_required' => ['boolean'], + 'department_id' => ['nullable', 'exists:departments,id'], + 'sort_order' => ['integer'], + ]); + + TicketCustomField::query()->create($validated); + + return redirect()->back()->with('success', 'Custom field created successfully.'); + } + + public function updateCustomField(Request $request, TicketCustomField $ticketCustomField): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'in:text,select,checkbox,number'], + 'options' => ['nullable', 'array'], + 'options.*' => ['string', 'max:255'], + 'is_required' => ['boolean'], + 'department_id' => ['nullable', 'exists:departments,id'], + 'sort_order' => ['integer'], + ]); + + $ticketCustomField->update($validated); + + return redirect()->back()->with('success', 'Custom field updated successfully.'); + } + + public function destroyCustomField(TicketCustomField $ticketCustomField): RedirectResponse + { + $ticketCustomField->values()->delete(); + $ticketCustomField->delete(); + + return redirect()->back()->with('success', 'Custom field deleted successfully.'); + } +} diff --git a/website/app/Http/Controllers/Api/ServerHunterController.php b/website/app/Http/Controllers/Api/ServerHunterController.php new file mode 100644 index 0000000..1ee22f2 --- /dev/null +++ b/website/app/Http/Controllers/Api/ServerHunterController.php @@ -0,0 +1,21 @@ +json($this->serverHunterService->buildFeed()); + } +} diff --git a/website/app/Http/Controllers/Api/V1/Admin/AdminAnalyticsController.php b/website/app/Http/Controllers/Api/V1/Admin/AdminAnalyticsController.php index 099013c..b8aaea2 100644 --- a/website/app/Http/Controllers/Api/V1/Admin/AdminAnalyticsController.php +++ b/website/app/Http/Controllers/Api/V1/Admin/AdminAnalyticsController.php @@ -24,22 +24,33 @@ class AdminAnalyticsController extends Controller { $totalCustomers = User::role('customer')->count(); - // MRR: sum of plan prices normalized to monthly - $mrr = (float) Subscription::query() + // MRR: prefer recurring_amount, fall back to plan_prices + $mrr = (float) (Subscription::query() ->where('subscriptions.stripe_status', 'active') ->whereNotNull('subscriptions.plan_id') - ->join('plan_prices', function ($join): void { + ->leftJoin('plan_prices', function ($join): void { $join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id') ->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle'); }) - ->selectRaw('SUM(CASE subscriptions.billing_cycle - WHEN "monthly" THEN plan_prices.price - WHEN "quarterly" THEN plan_prices.price / 3 - WHEN "semi_annual" THEN plan_prices.price / 6 - WHEN "annual" THEN plan_prices.price / 12 - ELSE plan_prices.price + ->selectRaw('SUM(CASE + WHEN subscriptions.recurring_amount IS NOT NULL THEN + CASE subscriptions.billing_cycle + WHEN "monthly" THEN subscriptions.recurring_amount + WHEN "quarterly" THEN subscriptions.recurring_amount / 3 + WHEN "semi_annual" THEN subscriptions.recurring_amount / 6 + WHEN "annual" THEN subscriptions.recurring_amount / 12 + ELSE subscriptions.recurring_amount + END + ELSE + CASE subscriptions.billing_cycle + WHEN "monthly" THEN plan_prices.price + WHEN "quarterly" THEN plan_prices.price / 3 + WHEN "semi_annual" THEN plan_prices.price / 6 + WHEN "annual" THEN plan_prices.price / 12 + ELSE plan_prices.price + END END) as mrr') - ->value('mrr') ?? 0; + ->value('mrr') ?? 0); // ARR (Annual Recurring Revenue) $arr = $mrr * 12; diff --git a/website/app/Http/Controllers/Marketing/KnowledgeBaseController.php b/website/app/Http/Controllers/Marketing/KnowledgeBaseController.php new file mode 100644 index 0000000..10e7e19 --- /dev/null +++ b/website/app/Http/Controllers/Marketing/KnowledgeBaseController.php @@ -0,0 +1,188 @@ +visible() + ->root() + ->withCount(['articles' => fn ($q) => $q->published()]) + ->with(['children' => fn ($q) => $q->visible()->withCount(['articles' => fn ($q2) => $q2->published()])]) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + $featuredArticles = KnowledgeBaseArticle::query() + ->published() + ->featured() + ->with('category:id,name,slug') + ->orderByDesc('published_at') + ->limit(6) + ->get(); + + return Inertia::render('Marketing/KnowledgeBase/Index', [ + 'categories' => $categories, + 'featuredArticles' => $featuredArticles, + ]); + } + + public function category(KnowledgeBaseCategory $category): Response + { + $category->loadCount(['articles' => fn ($q) => $q->published()]); + + $articles = KnowledgeBaseArticle::query() + ->where('category_id', $category->id) + ->published() + ->orderByDesc('published_at') + ->paginate(15); + + $subcategories = $category->children() + ->visible() + ->withCount(['articles' => fn ($q) => $q->published()]) + ->orderBy('sort_order') + ->get(); + + $breadcrumbs = $category->getAncestors()->map(fn (KnowledgeBaseCategory $ancestor) => [ + 'name' => $ancestor->name, + 'slug' => $ancestor->slug, + ])->push([ + 'name' => $category->name, + 'slug' => $category->slug, + ])->all(); + + return Inertia::render('Marketing/KnowledgeBase/Category', [ + 'category' => $category, + 'articles' => $articles, + 'subcategories' => $subcategories, + 'breadcrumbs' => $breadcrumbs, + ]); + } + + public function article(KnowledgeBaseCategory $category, KnowledgeBaseArticle $article): Response + { + if ($article->status !== 'published') { + abort(404); + } + + $article->increment('view_count'); + + $article->load(['author:id,name', 'category:id,name,slug']); + + $breadcrumbs = $category->getAncestors()->map(fn (KnowledgeBaseCategory $ancestor) => [ + 'name' => $ancestor->name, + 'slug' => $ancestor->slug, + ])->push([ + 'name' => $category->name, + 'slug' => $category->slug, + ])->all(); + + $relatedArticles = KnowledgeBaseArticle::query() + ->where('category_id', $category->id) + ->where('id', '!=', $article->id) + ->published() + ->orderByDesc('view_count') + ->limit(5) + ->get(['id', 'title', 'slug', 'excerpt', 'view_count', 'published_at']); + + $htmlContent = str($article->content)->markdown()->toString(); + + return Inertia::render('Marketing/KnowledgeBase/Article', [ + 'article' => $article, + 'htmlContent' => $htmlContent, + 'breadcrumbs' => $breadcrumbs, + 'relatedArticles' => $relatedArticles, + ]); + } + + public function search(Request $request): JsonResponse + { + $request->validate([ + 'q' => ['required', 'string', 'min:2', 'max:100'], + ]); + + $query = $request->input('q'); + + $articles = KnowledgeBaseArticle::query() + ->published() + ->search($query) + ->with('category:id,name,slug') + ->limit(20) + ->get(['id', 'title', 'slug', 'excerpt', 'category_id', 'view_count', 'published_at']); + + return response()->json([ + 'articles' => $articles, + ]); + } + + public function vote(Request $request, KnowledgeBaseArticle $article): JsonResponse + { + $request->validate([ + 'is_helpful' => ['required', 'boolean'], + ]); + + $isHelpful = $request->boolean('is_helpful'); + $userId = $request->user()?->id; + $ipAddress = $request->ip(); + + $existingVote = ArticleVote::query() + ->where('article_id', $article->id) + ->when($userId, fn ($q) => $q->where('user_id', $userId)) + ->when(! $userId, fn ($q) => $q->whereNull('user_id')->where('ip_address', $ipAddress)) + ->first(); + + if ($existingVote) { + if ($existingVote->is_helpful === $isHelpful) { + return response()->json([ + 'message' => 'You have already voted.', + 'helpful_count' => $article->helpful_count, + 'not_helpful_count' => $article->not_helpful_count, + ]); + } + + $existingVote->update(['is_helpful' => $isHelpful]); + + if ($isHelpful) { + $article->increment('helpful_count'); + $article->decrement('not_helpful_count'); + } else { + $article->decrement('helpful_count'); + $article->increment('not_helpful_count'); + } + } else { + ArticleVote::query()->create([ + 'article_id' => $article->id, + 'user_id' => $userId, + 'is_helpful' => $isHelpful, + 'ip_address' => $ipAddress, + ]); + + if ($isHelpful) { + $article->increment('helpful_count'); + } else { + $article->increment('not_helpful_count'); + } + } + + $article->refresh(); + + return response()->json([ + 'message' => 'Thank you for your feedback!', + 'helpful_count' => $article->helpful_count, + 'not_helpful_count' => $article->not_helpful_count, + ]); + } +} diff --git a/website/app/Http/Controllers/Marketing/QuoteAcceptController.php b/website/app/Http/Controllers/Marketing/QuoteAcceptController.php new file mode 100644 index 0000000..77230e2 --- /dev/null +++ b/website/app/Http/Controllers/Marketing/QuoteAcceptController.php @@ -0,0 +1,50 @@ +load(['user:id,name,email', 'creator:id,name,email']); + + return Inertia::render('Quotes/Show', [ + 'quote' => $quote, + 'isExpired' => $quote->isExpired(), + ]); + } + + public function accept(Quote $quote): RedirectResponse + { + if ($quote->isExpired()) { + return redirect()->back()->with('error', 'This quote has expired.'); + } + + if ($quote->status !== 'sent') { + return redirect()->back()->with('error', 'This quote cannot be accepted.'); + } + + $quote->markAccepted(); + + return redirect()->back()->with('success', 'Quote accepted! We will be in touch shortly.'); + } + + public function reject(Quote $quote): RedirectResponse + { + if (! in_array($quote->status, ['sent', 'draft'])) { + return redirect()->back()->with('error', 'This quote cannot be rejected.'); + } + + $quote->update(['status' => 'rejected']); + + return redirect()->back()->with('success', 'Quote has been declined.'); + } +} diff --git a/website/app/Http/Middleware/AllowServerHunterSpider.php b/website/app/Http/Middleware/AllowServerHunterSpider.php new file mode 100644 index 0000000..57b376e --- /dev/null +++ b/website/app/Http/Middleware/AllowServerHunterSpider.php @@ -0,0 +1,33 @@ +serverHunterService->getSpiderIps(); + + if (empty($allowedIps)) { + // If we couldn't fetch the IP list, deny all to be safe + abort(403, 'ServerHunter spider IP list unavailable.'); + } + + if (! in_array($request->ip(), $allowedIps, true)) { + abort(403, 'Access denied.'); + } + + return $next($request); + } +} diff --git a/website/app/Http/Middleware/TrackAffiliateReferral.php b/website/app/Http/Middleware/TrackAffiliateReferral.php new file mode 100644 index 0000000..519f305 --- /dev/null +++ b/website/app/Http/Middleware/TrackAffiliateReferral.php @@ -0,0 +1,42 @@ +query('ref'); + + if (is_string($refCode) && $refCode !== '') { + $affiliate = Affiliate::where('referral_code', $refCode) + ->where('status', 'active') + ->first(); + + if ($affiliate) { + $response = $next($request); + + $cookieLifetime = (int) config('affiliate.cookie_lifetime_days', 30) * 60 * 24; + + if ($response instanceof \Illuminate\Http\Response || $response instanceof \Illuminate\Http\RedirectResponse) { + $response->withCookie(cookie( + 'affiliate_id', + (string) $affiliate->id, + $cookieLifetime, + )); + } + + return $response; + } + } + + return $next($request); + } +} diff --git a/website/app/Http/Requests/Admin/StoreCreditNoteRequest.php b/website/app/Http/Requests/Admin/StoreCreditNoteRequest.php new file mode 100644 index 0000000..33e9833 --- /dev/null +++ b/website/app/Http/Requests/Admin/StoreCreditNoteRequest.php @@ -0,0 +1,38 @@ +> */ + public function rules(): array + { + return [ + 'user_id' => ['required', 'exists:users,id'], + 'amount' => ['required', 'numeric', 'min:0.01'], + 'reason' => ['nullable', 'string', 'max:2000'], + 'invoice_id' => ['nullable', 'exists:invoices,id'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'user_id.required' => 'Please select a customer.', + 'user_id.exists' => 'The selected customer does not exist.', + 'amount.required' => 'Amount is required.', + 'amount.min' => 'Amount must be at least $0.01.', + 'invoice_id.exists' => 'The selected invoice does not exist.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/StoreDebitNoteRequest.php b/website/app/Http/Requests/Admin/StoreDebitNoteRequest.php new file mode 100644 index 0000000..21190f3 --- /dev/null +++ b/website/app/Http/Requests/Admin/StoreDebitNoteRequest.php @@ -0,0 +1,42 @@ +> */ + public function rules(): array + { + return [ + 'user_id' => ['required', 'exists:users,id'], + 'amount' => ['required', 'numeric', 'min:0.01'], + 'reason_type' => ['required', Rule::in(['late_fee', 'adjustment', 'underpayment'])], + 'reason' => ['nullable', 'string', 'max:2000'], + 'invoice_id' => ['nullable', 'exists:invoices,id'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'user_id.required' => 'Please select a customer.', + 'user_id.exists' => 'The selected customer does not exist.', + 'amount.required' => 'Amount is required.', + 'amount.min' => 'Amount must be at least $0.01.', + 'reason_type.required' => 'Please select a reason type.', + 'reason_type.in' => 'Invalid reason type selected.', + 'invoice_id.exists' => 'The selected invoice does not exist.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/StoreKbArticleRequest.php b/website/app/Http/Requests/Admin/StoreKbArticleRequest.php new file mode 100644 index 0000000..0670967 --- /dev/null +++ b/website/app/Http/Requests/Admin/StoreKbArticleRequest.php @@ -0,0 +1,44 @@ +> */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('knowledge_base_articles', 'slug')], + 'category_id' => ['required', 'integer', Rule::exists('knowledge_base_categories', 'id')], + 'content' => ['required', 'string'], + 'excerpt' => ['nullable', 'string', 'max:1000'], + 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], + 'is_featured' => ['boolean'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'title.required' => 'Article title is required.', + 'slug.required' => 'Article slug is required.', + 'slug.unique' => 'This slug is already in use.', + 'category_id.required' => 'Please select a category.', + 'category_id.exists' => 'The selected category does not exist.', + 'content.required' => 'Article content is required.', + 'status.in' => 'Status must be draft, published, or archived.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/StoreKbCategoryRequest.php b/website/app/Http/Requests/Admin/StoreKbCategoryRequest.php new file mode 100644 index 0000000..f5b176b --- /dev/null +++ b/website/app/Http/Requests/Admin/StoreKbCategoryRequest.php @@ -0,0 +1,41 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('knowledge_base_categories', 'slug')], + 'description' => ['nullable', 'string', 'max:5000'], + 'icon' => ['nullable', 'string', 'max:255'], + 'parent_id' => ['nullable', 'integer', Rule::exists('knowledge_base_categories', 'id')], + 'sort_order' => ['integer', 'min:0'], + 'is_visible' => ['boolean'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'name.required' => 'Category name is required.', + 'slug.required' => 'Category slug is required.', + 'slug.unique' => 'This slug is already in use.', + 'parent_id.exists' => 'The selected parent category does not exist.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/StoreQuoteRequest.php b/website/app/Http/Requests/Admin/StoreQuoteRequest.php new file mode 100644 index 0000000..59a155e --- /dev/null +++ b/website/app/Http/Requests/Admin/StoreQuoteRequest.php @@ -0,0 +1,46 @@ +> */ + public function rules(): array + { + return [ + 'user_id' => ['nullable', 'exists:users,id'], + 'prospect_email' => ['nullable', 'email', 'max:255', 'required_without:user_id'], + 'prospect_name' => ['nullable', 'string', 'max:255'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.description' => ['required', 'string', 'max:500'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'items.*.unit_price' => ['required', 'numeric', 'min:0'], + 'tax' => ['nullable', 'numeric', 'min:0'], + 'currency' => ['required', 'string', 'size:3'], + 'notes' => ['nullable', 'string', 'max:5000'], + 'valid_until' => ['nullable', 'date', 'after:today'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'prospect_email.required_without' => 'Please select a customer or enter a prospect email.', + 'items.required' => 'At least one line item is required.', + 'items.min' => 'At least one line item is required.', + 'items.*.description.required' => 'Each item must have a description.', + 'items.*.quantity.required' => 'Each item must have a quantity.', + 'items.*.unit_price.required' => 'Each item must have a unit price.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/StoreRoleRequest.php b/website/app/Http/Requests/Admin/StoreRoleRequest.php new file mode 100644 index 0000000..02fe47d --- /dev/null +++ b/website/app/Http/Requests/Admin/StoreRoleRequest.php @@ -0,0 +1,47 @@ +> */ + public function rules(): array + { + $validPermissions = Permission::where('guard_name', 'web') + ->pluck('name') + ->toArray(); + + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + 'regex:/^[a-z][a-z0-9_]*$/', + Rule::unique('roles', 'name')->where('guard_name', 'web'), + ], + 'permissions' => ['required', 'array', 'min:1'], + 'permissions.*' => ['required', 'string', Rule::in($validPermissions)], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'name.regex' => 'Role name must be lowercase, start with a letter, and contain only letters, numbers, and underscores.', + 'permissions.required' => 'At least one permission must be selected.', + 'permissions.min' => 'At least one permission must be selected.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/UpdateCustomerRequest.php b/website/app/Http/Requests/Admin/UpdateCustomerRequest.php index cb6cc56..7ade898 100644 --- a/website/app/Http/Requests/Admin/UpdateCustomerRequest.php +++ b/website/app/Http/Requests/Admin/UpdateCustomerRequest.php @@ -24,6 +24,8 @@ class UpdateCustomerRequest extends FormRequest 'company' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:active,suspended,banned'], 'admin_notes' => ['nullable', 'string', 'max:10000'], + 'override_days_to_suspend' => ['nullable', 'integer', 'min:1'], + 'override_days_to_terminate' => ['nullable', 'integer', 'min:1'], ]; } } diff --git a/website/app/Http/Requests/Admin/UpdateKbArticleRequest.php b/website/app/Http/Requests/Admin/UpdateKbArticleRequest.php new file mode 100644 index 0000000..b70391a --- /dev/null +++ b/website/app/Http/Requests/Admin/UpdateKbArticleRequest.php @@ -0,0 +1,46 @@ +> */ + public function rules(): array + { + $articleId = $this->route('article')?->id; + + return [ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('knowledge_base_articles', 'slug')->ignore($articleId)], + 'category_id' => ['required', 'integer', Rule::exists('knowledge_base_categories', 'id')], + 'content' => ['required', 'string'], + 'excerpt' => ['nullable', 'string', 'max:1000'], + 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], + 'is_featured' => ['boolean'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'title.required' => 'Article title is required.', + 'slug.required' => 'Article slug is required.', + 'slug.unique' => 'This slug is already in use.', + 'category_id.required' => 'Please select a category.', + 'category_id.exists' => 'The selected category does not exist.', + 'content.required' => 'Article content is required.', + 'status.in' => 'Status must be draft, published, or archived.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/UpdateKbCategoryRequest.php b/website/app/Http/Requests/Admin/UpdateKbCategoryRequest.php new file mode 100644 index 0000000..9bb710a --- /dev/null +++ b/website/app/Http/Requests/Admin/UpdateKbCategoryRequest.php @@ -0,0 +1,43 @@ +> */ + public function rules(): array + { + $categoryId = $this->route('category')?->id; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('knowledge_base_categories', 'slug')->ignore($categoryId)], + 'description' => ['nullable', 'string', 'max:5000'], + 'icon' => ['nullable', 'string', 'max:255'], + 'parent_id' => ['nullable', 'integer', Rule::exists('knowledge_base_categories', 'id')], + 'sort_order' => ['integer', 'min:0'], + 'is_visible' => ['boolean'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'name.required' => 'Category name is required.', + 'slug.required' => 'Category slug is required.', + 'slug.unique' => 'This slug is already in use.', + 'parent_id.exists' => 'The selected parent category does not exist.', + ]; + } +} diff --git a/website/app/Http/Requests/Admin/UpdateRoleRequest.php b/website/app/Http/Requests/Admin/UpdateRoleRequest.php new file mode 100644 index 0000000..df78dff --- /dev/null +++ b/website/app/Http/Requests/Admin/UpdateRoleRequest.php @@ -0,0 +1,49 @@ +> */ + public function rules(): array + { + $validPermissions = Permission::where('guard_name', 'web') + ->pluck('name') + ->toArray(); + + return [ + 'name' => [ + 'sometimes', + 'string', + 'max:255', + 'regex:/^[a-z][a-z0-9_]*$/', + Rule::unique('roles', 'name') + ->where('guard_name', 'web') + ->ignore($this->route('role')), + ], + 'permissions' => ['required', 'array', 'min:1'], + 'permissions.*' => ['required', 'string', Rule::in($validPermissions)], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'name.regex' => 'Role name must be lowercase, start with a letter, and contain only letters, numbers, and underscores.', + 'permissions.required' => 'At least one permission must be selected.', + 'permissions.min' => 'At least one permission must be selected.', + ]; + } +} diff --git a/website/app/Http/Requests/RequestAffiliatePayoutRequest.php b/website/app/Http/Requests/RequestAffiliatePayoutRequest.php new file mode 100644 index 0000000..12ed214 --- /dev/null +++ b/website/app/Http/Requests/RequestAffiliatePayoutRequest.php @@ -0,0 +1,33 @@ +> */ + public function rules(): array + { + return [ + 'method' => ['required', Rule::in(['credit', 'paypal', 'bank_transfer'])], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'method.required' => 'Payout method is required.', + 'method.in' => 'Payout method must be credit, paypal, or bank_transfer.', + ]; + } +} diff --git a/website/app/Http/Requests/StorePlanRequest.php b/website/app/Http/Requests/StorePlanRequest.php index e9a1019..de7b84a 100644 --- a/website/app/Http/Requests/StorePlanRequest.php +++ b/website/app/Http/Requests/StorePlanRequest.php @@ -38,6 +38,10 @@ class StorePlanRequest extends FormRequest 'provisioning_config.hypervisor_id' => ['nullable', 'integer'], 'stock_quantity' => ['nullable', 'integer', 'min:0'], 'sort_order' => ['integer', 'min:0'], + 'days_to_suspend' => ['nullable', 'integer', 'min:1'], + 'days_to_terminate' => ['nullable', 'integer', 'min:1'], + 'auto_suspend_enabled' => ['boolean'], + 'auto_terminate_enabled' => ['boolean'], ]; } diff --git a/website/app/Models/AccountCredit.php b/website/app/Models/AccountCredit.php new file mode 100644 index 0000000..692c61c --- /dev/null +++ b/website/app/Models/AccountCredit.php @@ -0,0 +1,48 @@ + 'decimal:2', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function referenceable(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'reference_type', 'reference_id'); + } +} diff --git a/website/app/Models/Affiliate.php b/website/app/Models/Affiliate.php new file mode 100644 index 0000000..e95c3b2 --- /dev/null +++ b/website/app/Models/Affiliate.php @@ -0,0 +1,79 @@ + 'decimal:2', + 'minimum_payout' => 'decimal:2', + 'total_earned' => 'decimal:2', + 'total_paid' => 'decimal:2', + 'pending_balance' => 'decimal:2', + 'recurring_commissions' => 'boolean', + 'approved_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function referrals(): HasMany + { + return $this->hasMany(AffiliateReferral::class); + } + + public function commissions(): HasMany + { + return $this->hasMany(AffiliateCommission::class); + } + + public function payouts(): HasMany + { + return $this->hasMany(AffiliatePayout::class); + } + + public static function generateReferralCode(): string + { + do { + $code = Str::lower(Str::random(8)); + } while (self::where('referral_code', $code)->exists()); + + return $code; + } + + public function referralUrl(): string + { + $marketingDomain = config('app.domains.marketing'); + + return 'https://'.$marketingDomain.'?ref='.$this->referral_code; + } +} diff --git a/website/app/Models/AffiliateCommission.php b/website/app/Models/AffiliateCommission.php new file mode 100644 index 0000000..eca70bc --- /dev/null +++ b/website/app/Models/AffiliateCommission.php @@ -0,0 +1,50 @@ + 'decimal:2', + 'approved_at' => 'datetime', + 'paid_at' => 'datetime', + ]; + } + + public function affiliate(): BelongsTo + { + return $this->belongsTo(Affiliate::class); + } + + public function referral(): BelongsTo + { + return $this->belongsTo(AffiliateReferral::class, 'referral_id'); + } + + public function paymentTransaction(): BelongsTo + { + return $this->belongsTo(PaymentTransaction::class); + } +} diff --git a/website/app/Models/AffiliatePayout.php b/website/app/Models/AffiliatePayout.php new file mode 100644 index 0000000..c611765 --- /dev/null +++ b/website/app/Models/AffiliatePayout.php @@ -0,0 +1,37 @@ + 'decimal:2', + 'processed_at' => 'datetime', + ]; + } + + public function affiliate(): BelongsTo + { + return $this->belongsTo(Affiliate::class); + } +} diff --git a/website/app/Models/AffiliateReferral.php b/website/app/Models/AffiliateReferral.php new file mode 100644 index 0000000..7831b03 --- /dev/null +++ b/website/app/Models/AffiliateReferral.php @@ -0,0 +1,47 @@ + 'datetime', + ]; + } + + public function affiliate(): BelongsTo + { + return $this->belongsTo(Affiliate::class); + } + + public function referredUser(): BelongsTo + { + return $this->belongsTo(User::class, 'referred_user_id'); + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(Subscription::class); + } +} diff --git a/website/app/Models/ArticleRevision.php b/website/app/Models/ArticleRevision.php new file mode 100644 index 0000000..c9fd24a --- /dev/null +++ b/website/app/Models/ArticleRevision.php @@ -0,0 +1,27 @@ +belongsTo(KnowledgeBaseArticle::class, 'article_id'); + } + + public function editor(): BelongsTo + { + return $this->belongsTo(User::class, 'edited_by'); + } +} diff --git a/website/app/Models/ArticleVote.php b/website/app/Models/ArticleVote.php new file mode 100644 index 0000000..568da41 --- /dev/null +++ b/website/app/Models/ArticleVote.php @@ -0,0 +1,36 @@ + */ + protected function casts(): array + { + return [ + 'is_helpful' => 'boolean', + ]; + } + + public function article(): BelongsTo + { + return $this->belongsTo(KnowledgeBaseArticle::class, 'article_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/website/app/Models/CannedResponse.php b/website/app/Models/CannedResponse.php new file mode 100644 index 0000000..2fb7ff7 --- /dev/null +++ b/website/app/Models/CannedResponse.php @@ -0,0 +1,36 @@ + */ + protected function casts(): array + { + return [ + 'is_shared' => 'boolean', + ]; + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/website/app/Models/CartItem.php b/website/app/Models/CartItem.php new file mode 100644 index 0000000..0c29e27 --- /dev/null +++ b/website/app/Models/CartItem.php @@ -0,0 +1,44 @@ + 'integer', + 'config_selections' => 'array', + 'provisioning_config' => 'array', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } +} diff --git a/website/app/Models/CreditNote.php b/website/app/Models/CreditNote.php new file mode 100644 index 0000000..9226843 --- /dev/null +++ b/website/app/Models/CreditNote.php @@ -0,0 +1,61 @@ + 'decimal:2', + 'issued_at' => 'datetime', + ]; + } + + public static function generateNumber(): string + { + return 'CN-'.strtoupper(Str::random(6)); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function accountCredits(): MorphMany + { + return $this->morphMany(AccountCredit::class, 'referenceable', 'reference_type', 'reference_id'); + } +} diff --git a/website/app/Models/Currency.php b/website/app/Models/Currency.php new file mode 100644 index 0000000..3a7beea --- /dev/null +++ b/website/app/Models/Currency.php @@ -0,0 +1,46 @@ + 'integer', + 'exchange_rate' => 'decimal:6', + 'is_base' => 'boolean', + 'is_enabled' => 'boolean', + 'last_synced_at' => 'datetime', + ]; + } + + public function scopeEnabled(Builder $query): Builder + { + return $query->where('is_enabled', true); + } + + public function scopeBase(Builder $query): Builder + { + return $query->where('is_base', true); + } +} diff --git a/website/app/Models/DebitNote.php b/website/app/Models/DebitNote.php new file mode 100644 index 0000000..e5503cc --- /dev/null +++ b/website/app/Models/DebitNote.php @@ -0,0 +1,56 @@ + 'decimal:2', + 'issued_at' => 'datetime', + ]; + } + + public static function generateNumber(): string + { + return 'DN-'.strtoupper(Str::random(6)); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/website/app/Models/Department.php b/website/app/Models/Department.php new file mode 100644 index 0000000..a537a53 --- /dev/null +++ b/website/app/Models/Department.php @@ -0,0 +1,39 @@ +hasMany(SupportTicket::class); + } + + public function slaPolicy(): BelongsTo + { + return $this->belongsTo(SlaPolicy::class); + } + + public function autoAssignee(): BelongsTo + { + return $this->belongsTo(User::class, 'auto_assign_to'); + } +} diff --git a/website/app/Models/KnowledgeBaseArticle.php b/website/app/Models/KnowledgeBaseArticle.php new file mode 100644 index 0000000..55924e3 --- /dev/null +++ b/website/app/Models/KnowledgeBaseArticle.php @@ -0,0 +1,93 @@ + */ + protected function casts(): array + { + return [ + 'is_featured' => 'boolean', + 'view_count' => 'integer', + 'helpful_count' => 'integer', + 'not_helpful_count' => 'integer', + 'published_at' => 'datetime', + ]; + } + + public function category(): BelongsTo + { + return $this->belongsTo(KnowledgeBaseCategory::class, 'category_id'); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } + + public function revisions(): HasMany + { + return $this->hasMany(ArticleRevision::class, 'article_id'); + } + + public function votes(): HasMany + { + return $this->hasMany(ArticleVote::class, 'article_id'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', 'published') + ->where('published_at', '<=', now()); + } + + public function scopeFeatured(Builder $query): Builder + { + return $query->where('is_featured', true); + } + + public function scopeSearch(Builder $query, string $search): Builder + { + return $query->whereRaw( + 'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)', + [$search.'*'] + ); + } + + public function helpfulPercentage(): float + { + $total = $this->helpful_count + $this->not_helpful_count; + + if ($total === 0) { + return 0.0; + } + + return round(($this->helpful_count / $total) * 100, 1); + } +} diff --git a/website/app/Models/KnowledgeBaseCategory.php b/website/app/Models/KnowledgeBaseCategory.php new file mode 100644 index 0000000..9407a7e --- /dev/null +++ b/website/app/Models/KnowledgeBaseCategory.php @@ -0,0 +1,75 @@ + */ + protected function casts(): array + { + return [ + 'sort_order' => 'integer', + 'is_visible' => 'boolean', + ]; + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + public function articles(): HasMany + { + return $this->hasMany(KnowledgeBaseArticle::class, 'category_id'); + } + + public function scopeVisible(Builder $query): Builder + { + return $query->where('is_visible', true); + } + + public function scopeRoot(Builder $query): Builder + { + return $query->whereNull('parent_id'); + } + + /** @return Collection */ + public function getAncestors(): Collection + { + $ancestors = new Collection; + $current = $this->parent; + + while ($current) { + $ancestors->prepend($current); + $current = $current->parent; + } + + return $ancestors; + } +} diff --git a/website/app/Models/OrderRiskAssessment.php b/website/app/Models/OrderRiskAssessment.php new file mode 100644 index 0000000..b729702 --- /dev/null +++ b/website/app/Models/OrderRiskAssessment.php @@ -0,0 +1,49 @@ + 'array', + 'risk_score' => 'integer', + 'reviewed_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } +} diff --git a/website/app/Models/Plan.php b/website/app/Models/Plan.php index 73d4786..1ddef24 100644 --- a/website/app/Models/Plan.php +++ b/website/app/Models/Plan.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Services\Billing\CurrencyService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -30,6 +31,10 @@ class Plan extends Model 'stock_quantity', 'status', 'sort_order', + 'days_to_suspend', + 'days_to_terminate', + 'auto_suspend_enabled', + 'auto_terminate_enabled', ]; protected function casts(): array @@ -40,6 +45,10 @@ class Plan extends Model 'provisioning_config' => 'array', 'stock_quantity' => 'integer', 'sort_order' => 'integer', + 'days_to_suspend' => 'integer', + 'days_to_terminate' => 'integer', + 'auto_suspend_enabled' => 'boolean', + 'auto_terminate_enabled' => 'boolean', ]; } @@ -81,6 +90,18 @@ class Plan extends Model return true; } + public function priceInCurrency(string $currency): float + { + if (strtoupper($currency) === strtoupper($this->currency ?? 'USD')) { + return (float) $this->price; + } + + /** @var CurrencyService $currencyService */ + $currencyService = app(CurrencyService::class); + + return $currencyService->convert((float) $this->price, $this->currency ?? 'USD', $currency); + } + public function scopePublic(Builder $query): Builder { return $query->whereNotIn('status', ['hidden', 'internal', 'inactive']); diff --git a/website/app/Models/Quote.php b/website/app/Models/Quote.php new file mode 100644 index 0000000..b7ec37f --- /dev/null +++ b/website/app/Models/Quote.php @@ -0,0 +1,96 @@ + 'array', + 'subtotal' => 'decimal:2', + 'tax' => 'decimal:2', + 'total' => 'decimal:2', + 'valid_until' => 'date', + 'accepted_at' => 'datetime', + 'sent_at' => 'datetime', + ]; + } + + public static function generateNumber(): string + { + return 'QT-'.strtoupper(Str::random(6)); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function isExpired(): bool + { + if (! $this->valid_until) { + return false; + } + + return $this->valid_until->isPast(); + } + + public function markAccepted(): void + { + $this->update([ + 'status' => 'accepted', + 'accepted_at' => now(), + ]); + } + + public function markSent(): void + { + $this->update([ + 'status' => 'sent', + 'sent_at' => now(), + ]); + } + + public function getRecipientEmail(): ?string + { + return $this->user?->email ?? $this->prospect_email; + } + + public function getRecipientName(): ?string + { + return $this->user?->name ?? $this->prospect_name; + } +} diff --git a/website/app/Models/SlaBusinessHours.php b/website/app/Models/SlaBusinessHours.php new file mode 100644 index 0000000..7a70dfa --- /dev/null +++ b/website/app/Models/SlaBusinessHours.php @@ -0,0 +1,25 @@ + */ + protected function casts(): array + { + return [ + 'is_holiday' => 'boolean', + ]; + } +} diff --git a/website/app/Models/SlaPolicy.php b/website/app/Models/SlaPolicy.php new file mode 100644 index 0000000..0abed52 --- /dev/null +++ b/website/app/Models/SlaPolicy.php @@ -0,0 +1,35 @@ + */ + protected function casts(): array + { + return [ + 'business_hours_only' => 'boolean', + ]; + } + + public function departments(): HasMany + { + return $this->hasMany(Department::class); + } +} diff --git a/website/app/Models/SupportTicket.php b/website/app/Models/SupportTicket.php index 6730dad..34f6b4d 100644 --- a/website/app/Models/SupportTicket.php +++ b/website/app/Models/SupportTicket.php @@ -8,7 +8,9 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; class SupportTicket extends Model { @@ -22,6 +24,16 @@ class SupportTicket extends Model 'status', 'priority', 'department', + 'department_id', + 'assigned_to', + 'sla_policy_id', + 'first_response_due_at', + 'resolution_due_at', + 'first_responded_at', + 'resolved_at', + 'sla_first_response_breached', + 'sla_resolution_breached', + 'merged_into_ticket_id', 'last_reply_at', ]; @@ -30,6 +42,12 @@ class SupportTicket extends Model { return [ 'last_reply_at' => 'datetime', + 'first_response_due_at' => 'datetime', + 'resolution_due_at' => 'datetime', + 'first_responded_at' => 'datetime', + 'resolved_at' => 'datetime', + 'sla_first_response_breached' => 'boolean', + 'sla_resolution_breached' => 'boolean', ]; } @@ -61,4 +79,39 @@ class SupportTicket extends Model { return $this->hasMany(TicketReply::class, 'ticket_id'); } + + public function departmentRelation(): BelongsTo + { + return $this->belongsTo(Department::class, 'department_id'); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function slaPolicy(): BelongsTo + { + return $this->belongsTo(SlaPolicy::class); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(TicketTag::class, 'support_ticket_tag', 'ticket_id', 'tag_id'); + } + + public function customFieldValues(): HasMany + { + return $this->hasMany(TicketCustomFieldValue::class, 'ticket_id'); + } + + public function satisfactionRating(): HasOne + { + return $this->hasOne(TicketSatisfactionRating::class, 'ticket_id'); + } + + public function mergedInto(): BelongsTo + { + return $this->belongsTo(self::class, 'merged_into_ticket_id'); + } } diff --git a/website/app/Models/TicketCustomField.php b/website/app/Models/TicketCustomField.php new file mode 100644 index 0000000..448a472 --- /dev/null +++ b/website/app/Models/TicketCustomField.php @@ -0,0 +1,40 @@ + */ + protected function casts(): array + { + return [ + 'options' => 'array', + 'is_required' => 'boolean', + ]; + } + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class); + } + + public function values(): HasMany + { + return $this->hasMany(TicketCustomFieldValue::class, 'custom_field_id'); + } +} diff --git a/website/app/Models/TicketCustomFieldValue.php b/website/app/Models/TicketCustomFieldValue.php new file mode 100644 index 0000000..cf9e570 --- /dev/null +++ b/website/app/Models/TicketCustomFieldValue.php @@ -0,0 +1,27 @@ +belongsTo(SupportTicket::class, 'ticket_id'); + } + + public function customField(): BelongsTo + { + return $this->belongsTo(TicketCustomField::class, 'custom_field_id'); + } +} diff --git a/website/app/Models/TicketReply.php b/website/app/Models/TicketReply.php index d8e900f..ae46604 100644 --- a/website/app/Models/TicketReply.php +++ b/website/app/Models/TicketReply.php @@ -20,6 +20,7 @@ class TicketReply extends Model 'message_id', 'from_email', 'via_email', + 'is_internal', ]; /** @return array */ @@ -28,6 +29,7 @@ class TicketReply extends Model return [ 'is_staff_reply' => 'boolean', 'via_email' => 'boolean', + 'is_internal' => 'boolean', ]; } diff --git a/website/app/Models/TicketSatisfactionRating.php b/website/app/Models/TicketSatisfactionRating.php new file mode 100644 index 0000000..1616a55 --- /dev/null +++ b/website/app/Models/TicketSatisfactionRating.php @@ -0,0 +1,28 @@ +belongsTo(SupportTicket::class, 'ticket_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/website/app/Models/TicketTag.php b/website/app/Models/TicketTag.php new file mode 100644 index 0000000..3d52485 --- /dev/null +++ b/website/app/Models/TicketTag.php @@ -0,0 +1,24 @@ +belongsToMany(SupportTicket::class, 'support_ticket_tag', 'tag_id', 'ticket_id'); + } +} diff --git a/website/app/Models/User.php b/website/app/Models/User.php index b9faa25..7d81427 100644 --- a/website/app/Models/User.php +++ b/website/app/Models/User.php @@ -30,6 +30,10 @@ class User extends Authenticatable implements MustVerifyEmail 'company', 'admin_notes', 'virtfusion_user_id', + 'override_days_to_suspend', + 'override_days_to_terminate', + 'credit_balance', + 'currency', ]; /** @var list */ @@ -47,6 +51,9 @@ class User extends Authenticatable implements MustVerifyEmail 'email_verified_at' => 'datetime', 'password' => 'hashed', 'passkey_credentials' => 'json', + 'override_days_to_suspend' => 'integer', + 'override_days_to_terminate' => 'integer', + 'credit_balance' => 'decimal:2', ]; } @@ -101,9 +108,44 @@ class User extends Authenticatable implements MustVerifyEmail return $this->hasMany(TrustedDevice::class); } + public function accountCredits(): HasMany + { + return $this->hasMany(AccountCredit::class); + } + + public function creditNotes(): HasMany + { + return $this->hasMany(CreditNote::class); + } + + public function debitNotes(): HasMany + { + return $this->hasMany(DebitNote::class); + } + + public function cartItems(): HasMany + { + return $this->hasMany(CartItem::class); + } + + public function quotes(): HasMany + { + return $this->hasMany(Quote::class); + } + + public function affiliate(): HasOne + { + return $this->hasOne(Affiliate::class); + } + public function isAdmin(): bool { - return $this->hasRole('admin'); + return $this->hasRole(['admin', 'super_admin', 'billing_admin', 'support_agent', 'support_lead', 'readonly_admin']); + } + + public function isSuperAdmin(): bool + { + return $this->hasRole(['admin', 'super_admin']); } public function isCustomer(): bool diff --git a/website/app/Notifications/QuoteSentNotification.php b/website/app/Notifications/QuoteSentNotification.php new file mode 100644 index 0000000..ca776dd --- /dev/null +++ b/website/app/Notifications/QuoteSentNotification.php @@ -0,0 +1,56 @@ + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $total = number_format((float) $this->quote->total, 2); + $currency = strtoupper($this->quote->currency); + $recipientName = $this->quote->getRecipientName() ?? 'there'; + $validUntil = $this->quote->valid_until?->format('M d, Y'); + + $message = (new MailMessage) + ->subject("Quote {$this->quote->number} from EZSCALE") + ->greeting("Hello {$recipientName}!") + ->line("We've prepared a quote for you.") + ->line("**Quote #:** {$this->quote->number}") + ->line("**Total:** {$currency} {$total}"); + + if ($validUntil) { + $message->line("**Valid Until:** {$validUntil}"); + } + + if ($this->quote->notes) { + $message->line("**Notes:** {$this->quote->notes}"); + } + + $message->action('View Quote', $this->signedUrl) + ->line('You can accept or decline this quote using the link above.') + ->line('Thank you for considering EZSCALE!'); + + return $message; + } +} diff --git a/website/app/Notifications/ServiceSuspendedNotification.php b/website/app/Notifications/ServiceSuspendedNotification.php new file mode 100644 index 0000000..736d463 --- /dev/null +++ b/website/app/Notifications/ServiceSuspendedNotification.php @@ -0,0 +1,64 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $billingUrl = 'https://'.config('app.domains.account').'/billing'; + + $mail = (new MailMessage) + ->subject('Service Suspended - Payment Required') + ->greeting("Hello {$notifiable->name},") + ->line('Your service has been suspended due to an overdue payment.'); + + if ($this->service->hostname) { + $mail->line("Hostname: **{$this->service->hostname}**"); + } + + if ($this->service->ipv4_address) { + $mail->line("IP Address: **{$this->service->ipv4_address}**"); + } + + $mail->line("Service Type: **{$this->service->service_type}**"); + + return $mail + ->action('Pay Now', $billingUrl) + ->line('Your service will be reactivated automatically once payment is received.') + ->line('If the balance remains unpaid, the service will be terminated permanently.'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'service_suspended', + 'service_id' => $this->service->id, + 'service_type' => $this->service->service_type, + 'hostname' => $this->service->hostname, + 'ip_address' => $this->service->ipv4_address, + 'message' => "Your {$this->service->service_type} service has been suspended due to overdue payment.", + ]; + } +} diff --git a/website/app/Notifications/ServiceSuspensionWarningNotification.php b/website/app/Notifications/ServiceSuspensionWarningNotification.php new file mode 100644 index 0000000..c2ed55d --- /dev/null +++ b/website/app/Notifications/ServiceSuspensionWarningNotification.php @@ -0,0 +1,64 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $billingUrl = 'https://'.config('app.domains.account').'/billing'; + + $mail = (new MailMessage) + ->subject('Action Required: Service Suspension Warning') + ->greeting("Hello {$notifiable->name},") + ->line('Your service is at risk of suspension due to an overdue payment.') + ->line('**If payment is not received within 24 hours, your service will be suspended.**'); + + if ($this->service->hostname) { + $mail->line("Hostname: **{$this->service->hostname}**"); + } + + if ($this->service->ipv4_address) { + $mail->line("IP Address: **{$this->service->ipv4_address}**"); + } + + $mail->line("Service Type: **{$this->service->service_type}**"); + + return $mail + ->action('Update Payment Method', $billingUrl) + ->line('Please update your payment method or pay the outstanding invoice to avoid service interruption.'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'service_suspension_warning', + 'service_id' => $this->service->id, + 'service_type' => $this->service->service_type, + 'hostname' => $this->service->hostname, + 'ip_address' => $this->service->ipv4_address, + 'message' => "Your {$this->service->service_type} service will be suspended in 24 hours due to overdue payment.", + ]; + } +} diff --git a/website/app/Notifications/ServiceTerminatedNotification.php b/website/app/Notifications/ServiceTerminatedNotification.php new file mode 100644 index 0000000..dab41e4 --- /dev/null +++ b/website/app/Notifications/ServiceTerminatedNotification.php @@ -0,0 +1,64 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $billingUrl = 'https://'.config('app.domains.account').'/billing'; + + $mail = (new MailMessage) + ->subject('Service Terminated') + ->greeting("Hello {$notifiable->name},") + ->line('Your service has been permanently terminated due to prolonged non-payment.'); + + if ($this->service->hostname) { + $mail->line("Hostname: **{$this->service->hostname}**"); + } + + if ($this->service->ipv4_address) { + $mail->line("IP Address: **{$this->service->ipv4_address}**"); + } + + $mail->line("Service Type: **{$this->service->service_type}**"); + + return $mail + ->action('View Account', $billingUrl) + ->line('All data associated with this service has been permanently removed.') + ->line('If you wish to start a new service, please visit our plans page.'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'service_terminated', + 'service_id' => $this->service->id, + 'service_type' => $this->service->service_type, + 'hostname' => $this->service->hostname, + 'ip_address' => $this->service->ipv4_address, + 'message' => "Your {$this->service->service_type} service has been permanently terminated.", + ]; + } +} diff --git a/website/app/Notifications/ServiceTerminationWarningNotification.php b/website/app/Notifications/ServiceTerminationWarningNotification.php new file mode 100644 index 0000000..9b8a7f4 --- /dev/null +++ b/website/app/Notifications/ServiceTerminationWarningNotification.php @@ -0,0 +1,64 @@ + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $billingUrl = 'https://'.config('app.domains.account').'/billing'; + + $mail = (new MailMessage) + ->subject('Urgent: Service Termination Warning') + ->greeting("Hello {$notifiable->name},") + ->line('Your suspended service is scheduled for **permanent termination in 7 days**.') + ->line('Once terminated, all data associated with this service will be permanently deleted and cannot be recovered.'); + + if ($this->service->hostname) { + $mail->line("Hostname: **{$this->service->hostname}**"); + } + + if ($this->service->ipv4_address) { + $mail->line("IP Address: **{$this->service->ipv4_address}**"); + } + + $mail->line("Service Type: **{$this->service->service_type}**"); + + return $mail + ->action('Pay Now to Reactivate', $billingUrl) + ->line('Please pay the outstanding balance immediately to prevent permanent data loss.'); + } + + /** @return array */ + public function toArray(object $notifiable): array + { + return [ + 'type' => 'service_termination_warning', + 'service_id' => $this->service->id, + 'service_type' => $this->service->service_type, + 'hostname' => $this->service->hostname, + 'ip_address' => $this->service->ipv4_address, + 'message' => "Your {$this->service->service_type} service will be terminated in 7 days. Pay now to prevent data loss.", + ]; + } +} diff --git a/website/app/Services/AffiliateService.php b/website/app/Services/AffiliateService.php new file mode 100644 index 0000000..db4e17d --- /dev/null +++ b/website/app/Services/AffiliateService.php @@ -0,0 +1,138 @@ + $user->id, + 'referral_code' => Affiliate::generateReferralCode(), + 'status' => 'pending', + 'commission_type' => config('affiliate.default_commission_type', 'percentage'), + 'commission_rate' => config('affiliate.default_commission_rate', 10.00), + 'recurring_commissions' => config('affiliate.default_recurring_commissions', false), + 'minimum_payout' => config('affiliate.default_minimum_payout', 50.00), + ]); + } + + public function approve(Affiliate $affiliate): void + { + $affiliate->update([ + 'status' => 'active', + 'approved_at' => now(), + ]); + } + + public function recordReferral( + Affiliate $affiliate, + User $referredUser, + ?string $ip = null, + ?string $url = null, + ): AffiliateReferral { + return AffiliateReferral::create([ + 'affiliate_id' => $affiliate->id, + 'referred_user_id' => $referredUser->id, + 'status' => 'pending', + 'signup_ip' => $ip, + 'referral_url' => $url, + ]); + } + + public function recordCommission( + AffiliateReferral $referral, + PaymentTransaction $transaction, + string $type, + ): AffiliateCommission { + $affiliate = $referral->affiliate; + + $amount = $affiliate->commission_type === 'percentage' + ? round((float) $transaction->amount * ((float) $affiliate->commission_rate / 100), 2) + : (float) $affiliate->commission_rate; + + $commission = AffiliateCommission::create([ + 'affiliate_id' => $affiliate->id, + 'referral_id' => $referral->id, + 'payment_transaction_id' => $transaction->id, + 'amount' => $amount, + 'currency' => $transaction->currency ?? 'USD', + 'type' => $type, + 'status' => 'pending', + ]); + + $affiliate->increment('pending_balance', $amount); + + return $commission; + } + + public function approveCommission(AffiliateCommission $commission): void + { + DB::transaction(function () use ($commission): void { + $commission->update([ + 'status' => 'approved', + 'approved_at' => now(), + ]); + + $affiliate = $commission->affiliate; + $affiliate->increment('total_earned', (float) $commission->amount); + }); + } + + public function requestPayout(Affiliate $affiliate, string $method): AffiliatePayout + { + $amount = (float) $affiliate->pending_balance; + + if ($amount < (float) $affiliate->minimum_payout) { + throw new \InvalidArgumentException( + "Pending balance ({$amount}) is below minimum payout threshold ({$affiliate->minimum_payout})." + ); + } + + return DB::transaction(function () use ($affiliate, $method, $amount): AffiliatePayout { + $payout = AffiliatePayout::create([ + 'affiliate_id' => $affiliate->id, + 'amount' => $amount, + 'currency' => 'USD', + 'method' => $method, + 'status' => 'pending', + ]); + + $affiliate->decrement('pending_balance', $amount); + + return $payout; + }); + } + + public function processPayout(AffiliatePayout $payout): void + { + DB::transaction(function () use ($payout): void { + $payout->update([ + 'status' => 'completed', + 'processed_at' => now(), + 'reference' => 'PAY-'.strtoupper(bin2hex(random_bytes(6))), + ]); + + $affiliate = $payout->affiliate; + $affiliate->increment('total_paid', (float) $payout->amount); + + // Mark associated pending commissions as paid + $affiliate->commissions() + ->where('status', 'approved') + ->update([ + 'status' => 'paid', + 'paid_at' => now(), + ]); + }); + } +} diff --git a/website/app/Services/Billing/CartService.php b/website/app/Services/Billing/CartService.php new file mode 100644 index 0000000..a8c5012 --- /dev/null +++ b/website/app/Services/Billing/CartService.php @@ -0,0 +1,187 @@ +resolveOwnerAttributes($userOrSession); + + // Check if the same plan + cycle already exists in the cart + $existing = CartItem::query() + ->where($attributes) + ->where('plan_id', $plan->id) + ->where('billing_cycle', $cycle) + ->first(); + + if ($existing) { + $existing->update(['quantity' => $existing->quantity + $qty]); + + return $existing; + } + + return CartItem::create(array_merge($attributes, [ + 'plan_id' => $plan->id, + 'billing_cycle' => $cycle, + 'quantity' => $qty, + 'config_selections' => $config, + 'provisioning_config' => $provisioningConfig, + 'coupon_code' => $couponCode, + ])); + } + + /** + * Remove an item from the cart. + */ + public function removeItem(int $cartItemId): void + { + CartItem::query()->where('id', $cartItemId)->delete(); + } + + /** + * Update the quantity of a cart item. + */ + public function updateQuantity(int $cartItemId, int $qty): void + { + if ($qty <= 0) { + $this->removeItem($cartItemId); + + return; + } + + CartItem::query()->where('id', $cartItemId)->update(['quantity' => $qty]); + } + + /** + * Get all cart items for a user or session. + * + * @return Collection + */ + public function getItems(User|string $userOrSession): Collection + { + $attributes = $this->resolveOwnerAttributes($userOrSession); + + return CartItem::query() + ->where($attributes) + ->with('plan.prices') + ->orderBy('created_at') + ->get(); + } + + /** + * Get the total price of all cart items. + */ + public function getTotal(User|string $userOrSession): float + { + $items = $this->getItems($userOrSession); + $total = 0.0; + + foreach ($items as $item) { + $price = $this->getItemPrice($item); + $total += $price * $item->quantity; + } + + return round($total, 2); + } + + /** + * Get the number of items in the cart. + */ + public function getItemCount(User|string $userOrSession): int + { + $attributes = $this->resolveOwnerAttributes($userOrSession); + + return (int) CartItem::query()->where($attributes)->sum('quantity'); + } + + /** + * Merge session cart items into a user's cart on login. + */ + public function mergeSessionToUser(string $sessionId, User $user): void + { + $sessionItems = CartItem::query() + ->where('session_id', $sessionId) + ->whereNull('user_id') + ->get(); + + foreach ($sessionItems as $sessionItem) { + $existing = CartItem::query() + ->where('user_id', $user->id) + ->where('plan_id', $sessionItem->plan_id) + ->where('billing_cycle', $sessionItem->billing_cycle) + ->first(); + + if ($existing) { + $existing->update(['quantity' => $existing->quantity + $sessionItem->quantity]); + $sessionItem->delete(); + } else { + $sessionItem->update([ + 'user_id' => $user->id, + 'session_id' => null, + ]); + } + } + } + + /** + * Clear all cart items for a user or session. + */ + public function clear(User|string $userOrSession): void + { + $attributes = $this->resolveOwnerAttributes($userOrSession); + + CartItem::query()->where($attributes)->delete(); + } + + /** + * Get the price for a single cart item based on its billing cycle. + */ + public function getItemPrice(CartItem $item): float + { + $plan = $item->plan; + + if (! $plan) { + return 0.0; + } + + $planPrice = $plan->priceForCycle($item->billing_cycle); + + if ($planPrice) { + return (float) $planPrice->price; + } + + return (float) $plan->price; + } + + /** + * Resolve owner attributes for querying (user_id or session_id). + * + * @return array + */ + private function resolveOwnerAttributes(User|string $userOrSession): array + { + if ($userOrSession instanceof User) { + return ['user_id' => $userOrSession->id]; + } + + return ['session_id' => $userOrSession]; + } +} diff --git a/website/app/Services/Billing/CreditService.php b/website/app/Services/Billing/CreditService.php new file mode 100644 index 0000000..35e2000 --- /dev/null +++ b/website/app/Services/Billing/CreditService.php @@ -0,0 +1,273 @@ + $user->id, + 'invoice_id' => $invoice?->id, + 'number' => CreditNote::generateNumber(), + 'amount' => $amount, + 'currency' => 'USD', + 'reason' => $reason, + 'status' => 'issued', + 'issued_at' => now(), + 'created_by' => $creator?->id, + ]); + + AccountCredit::create([ + 'user_id' => $user->id, + 'amount' => $amount, + 'currency' => 'USD', + 'type' => 'credit_note', + 'description' => "Credit note {$creditNote->number}".($reason ? ": {$reason}" : ''), + 'reference_type' => CreditNote::class, + 'reference_id' => $creditNote->id, + 'created_by' => $creator?->id, + ]); + + $user->increment('credit_balance', $amount); + + return $creditNote; + }); + } + + /** + * Issue a debit note to reduce the customer's credit balance. + * If balance is insufficient, reduces to 0 (does not go negative). + */ + public function issueDebitNote( + User $user, + float $amount, + string $reasonType, + ?string $reason = null, + ?Invoice $invoice = null, + ?User $creator = null, + ): DebitNote { + return DB::transaction(function () use ($user, $amount, $reasonType, $reason, $invoice, $creator): DebitNote { + $debitNote = DebitNote::create([ + 'user_id' => $user->id, + 'invoice_id' => $invoice?->id, + 'number' => DebitNote::generateNumber(), + 'amount' => $amount, + 'currency' => 'USD', + 'reason_type' => $reasonType, + 'reason' => $reason, + 'status' => 'issued', + 'issued_at' => now(), + 'created_by' => $creator?->id, + ]); + + // Deduct from balance, but don't go below 0 + $user->refresh(); + $deduction = min((float) $user->credit_balance, $amount); + + if ($deduction > 0) { + $user->decrement('credit_balance', $deduction); + + AccountCredit::create([ + 'user_id' => $user->id, + 'amount' => -$deduction, + 'currency' => 'USD', + 'type' => 'admin_adjustment', + 'description' => "Debit note {$debitNote->number}".($reason ? ": {$reason}" : ''), + 'reference_type' => DebitNote::class, + 'reference_id' => $debitNote->id, + 'created_by' => $creator?->id, + ]); + } + + return $debitNote; + }); + } + + /** + * Manually adjust a customer's credit balance (admin adjustment). + */ + public function adjustBalance( + User $user, + float $amount, + string $description, + ?User $creator = null, + ): AccountCredit { + return DB::transaction(function () use ($user, $amount, $description, $creator): AccountCredit { + $credit = AccountCredit::create([ + 'user_id' => $user->id, + 'amount' => $amount, + 'currency' => 'USD', + 'type' => 'admin_adjustment', + 'description' => $description, + 'created_by' => $creator?->id, + ]); + + if ($amount >= 0) { + $user->increment('credit_balance', $amount); + } else { + $user->decrement('credit_balance', abs($amount)); + } + + return $credit; + }); + } + + /** + * Apply account credits to an invoice during checkout/payment. + * Returns the amount of credit applied. + */ + public function applyCreditsToInvoice(User $user, Invoice $invoice): float + { + return DB::transaction(function () use ($user, $invoice): float { + $user->refresh(); + $balance = (float) $user->credit_balance; + + if ($balance <= 0) { + return 0.0; + } + + $invoiceTotal = (float) $invoice->total; + + if ($invoiceTotal <= 0) { + return 0.0; + } + + $amountToApply = min($balance, $invoiceTotal); + + // Create a payment transaction recording the credit application + PaymentTransaction::create([ + 'user_id' => $user->id, + 'invoice_id' => $invoice->id, + 'gateway' => 'credit', + 'gateway_transaction_id' => null, + 'amount' => $amountToApply, + 'currency' => $invoice->currency ?? 'USD', + 'status' => 'succeeded', + 'payment_method' => 'account_credit', + 'description' => "Account credit applied to invoice {$invoice->number}", + ]); + + AccountCredit::create([ + 'user_id' => $user->id, + 'amount' => -$amountToApply, + 'currency' => 'USD', + 'type' => 'admin_adjustment', + 'description' => "Applied to invoice {$invoice->number}", + 'reference_type' => Invoice::class, + 'reference_id' => $invoice->id, + ]); + + $user->decrement('credit_balance', $amountToApply); + + // If credit covers the full invoice, mark it as paid + if ($amountToApply >= $invoiceTotal) { + $invoice->update([ + 'status' => 'paid', + 'paid_at' => now(), + ]); + } + + return $amountToApply; + }); + } + + /** + * Refund a payment transaction to account credit instead of the original payment method. + */ + public function refundToCredit( + User $user, + PaymentTransaction $transaction, + float $amount, + ): AccountCredit { + return DB::transaction(function () use ($user, $transaction, $amount): AccountCredit { + $credit = AccountCredit::create([ + 'user_id' => $user->id, + 'amount' => $amount, + 'currency' => $transaction->currency ?? 'USD', + 'type' => 'refund', + 'description' => "Refund from transaction #{$transaction->id}", + 'reference_type' => PaymentTransaction::class, + 'reference_id' => $transaction->id, + ]); + + $user->increment('credit_balance', $amount); + + return $credit; + }); + } + + /** + * Void a credit note: reverse the credit and reduce the user's balance. + */ + public function voidCreditNote(CreditNote $creditNote): void + { + DB::transaction(function () use ($creditNote): void { + $creditNote->update(['status' => 'voided']); + + $user = $creditNote->user; + $amount = (float) $creditNote->amount; + + // Deduct the amount back (to 0 minimum) + $deduction = min((float) $user->credit_balance, $amount); + + if ($deduction > 0) { + $user->decrement('credit_balance', $deduction); + } + + AccountCredit::create([ + 'user_id' => $user->id, + 'amount' => -$amount, + 'currency' => 'USD', + 'type' => 'admin_adjustment', + 'description' => "Voided credit note {$creditNote->number}", + 'reference_type' => CreditNote::class, + 'reference_id' => $creditNote->id, + ]); + }); + } + + /** + * Void a debit note: restore the debited amount to the user's balance. + */ + public function voidDebitNote(DebitNote $debitNote): void + { + DB::transaction(function () use ($debitNote): void { + $debitNote->update(['status' => 'voided']); + + $user = $debitNote->user; + $amount = (float) $debitNote->amount; + + $user->increment('credit_balance', $amount); + + AccountCredit::create([ + 'user_id' => $user->id, + 'amount' => $amount, + 'currency' => 'USD', + 'type' => 'admin_adjustment', + 'description' => "Voided debit note {$debitNote->number}", + 'reference_type' => DebitNote::class, + 'reference_id' => $debitNote->id, + ]); + }); + } +} diff --git a/website/app/Services/Billing/CurrencyService.php b/website/app/Services/Billing/CurrencyService.php new file mode 100644 index 0000000..c0bd35e --- /dev/null +++ b/website/app/Services/Billing/CurrencyService.php @@ -0,0 +1,84 @@ +getEnabledCurrencies()->keyBy('code'); + + $fromCurrency = $currencies->get($from); + $toCurrency = $currencies->get($to); + + if (! $fromCurrency || ! $toCurrency) { + return $amount; + } + + // Convert to base currency first, then to target + $fromRate = (float) $fromCurrency->exchange_rate; + $toRate = (float) $toCurrency->exchange_rate; + + if ($fromRate <= 0) { + return $amount; + } + + $amountInBase = $amount / $fromRate; + + return round($amountInBase * $toRate, (int) $toCurrency->decimal_places); + } + + /** + * Get all enabled currencies, cached for 1 hour. + * + * @return Collection + */ + public function getEnabledCurrencies(): Collection + { + return Cache::remember('currencies:enabled', 3600, function (): Collection { + return Currency::query()->enabled()->orderBy('code')->get(); + }); + } + + /** + * Get the base currency. + */ + public function getBaseCurrency(): Currency + { + return Cache::remember('currencies:base', 3600, function (): Currency { + return Currency::query()->base()->firstOrFail(); + }); + } + + /** + * Format a price with the currency symbol. + */ + public function formatPrice(float $amount, string $currencyCode): string + { + $currency = $this->getEnabledCurrencies()->firstWhere('code', strtoupper($currencyCode)); + + if (! $currency) { + return number_format($amount, 2).' '.$currencyCode; + } + + $formatted = number_format($amount, (int) $currency->decimal_places); + + return $currency->symbol.$formatted; + } +} diff --git a/website/app/Services/Billing/DunningService.php b/website/app/Services/Billing/DunningService.php index 828902d..b23d6d0 100644 --- a/website/app/Services/Billing/DunningService.php +++ b/website/app/Services/Billing/DunningService.php @@ -4,31 +4,55 @@ declare(strict_types=1); namespace App\Services\Billing; +use App\Events\ServiceSuspended; +use App\Events\ServiceSuspending; +use App\Events\ServiceTerminated; +use App\Events\ServiceTerminating; +use App\Events\ServiceUnsuspended; +use App\Events\ServiceUnsuspending; use App\Models\Service; use App\Models\User; +use App\Notifications\ServiceSuspendedNotification; +use App\Notifications\ServiceSuspensionWarningNotification; +use App\Notifications\ServiceTerminatedNotification; +use App\Notifications\ServiceTerminationWarningNotification; +use App\Services\Provisioning\ProvisioningFactory; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Subscription; class DunningService { + public function __construct( + private ProvisioningFactory $provisioningFactory, + ) {} + /** * Suspend services for subscriptions that are past due beyond the grace period. + * Uses per-plan and per-customer threshold overrides. */ public function suspendOverdueSubscriptions(): int { - $graceDays = config('billing.suspension.days_past_due_to_suspend'); - $cutoff = now()->subDays($graceDays); - $subscriptions = Subscription::query() ->where('stripe_status', 'past_due') - ->where('updated_at', '<=', $cutoff) + ->with('user') ->get(); $count = 0; foreach ($subscriptions as $subscription) { - $this->suspendServicesForSubscription($subscription); - $count++; + $effectiveDays = $this->getEffectiveSuspendDays($subscription); + + if ($effectiveDays === null) { + // Auto-suspend disabled for this plan + continue; + } + + $cutoff = now()->subDays($effectiveDays); + + if ($subscription->updated_at->lte($cutoff)) { + $this->suspendServicesForSubscription($subscription); + $count++; + } } if ($count > 0) { @@ -40,28 +64,56 @@ class DunningService /** * Terminate services for subscriptions that have been suspended too long. + * Uses per-plan and per-customer threshold overrides. */ public function terminateLongSuspendedSubscriptions(): int { - $terminateDays = config('billing.suspension.days_suspended_to_terminate'); - $cutoff = now()->subDays($terminateDays); - $services = Service::query() ->where('status', 'suspended') - ->where('suspended_at', '<=', $cutoff) + ->whereNotNull('suspended_at') + ->with(['user', 'plan']) ->get(); $count = 0; foreach ($services as $service) { - $service->update([ - 'status' => 'terminated', - 'terminated_at' => now(), - 'auto_renew' => false, - ]); - $count++; + $effectiveDays = $this->getEffectiveTerminateDays($service); - Log::info("Dunning: terminated service #{$service->id} for user #{$service->user_id}."); + if ($effectiveDays === null) { + // Auto-terminate disabled for this plan + continue; + } + + $cutoff = now()->subDays($effectiveDays); + + if ($service->suspended_at->lte($cutoff)) { + $user = $service->user; + + ServiceTerminating::dispatch($user, $service); + + $service->update([ + 'status' => 'terminated', + 'terminated_at' => now(), + 'auto_renew' => false, + ]); + + try { + $this->provisioningFactory->make($service->service_type)->terminate($service); + } catch (\Throwable $e) { + Log::error("Dunning: failed to terminate service #{$service->id} on platform", [ + 'service_id' => $service->id, + 'service_type' => $service->service_type, + 'error' => $e->getMessage(), + ]); + } + + ServiceTerminated::dispatch($user, $service); + $user->notify(new ServiceTerminatedNotification($service)); + + $count++; + + Log::info("Dunning: terminated service #{$service->id} for user #{$service->user_id}."); + } } return $count; @@ -69,30 +121,229 @@ class DunningService /** * Suspend all services tied to a specific subscription. + * Calls provisioning API for each service and fires events. */ public function suspendServicesForSubscription(Subscription $subscription): void { - Service::query() + $services = Service::query() ->where('subscription_id', $subscription->id) ->where('status', 'active') - ->update([ + ->with(['user', 'plan']) + ->get(); + + foreach ($services as $service) { + $user = $service->user; + + ServiceSuspending::dispatch($user, $service); + + $service->update([ 'status' => 'suspended', 'suspended_at' => now(), ]); + + try { + $this->provisioningFactory->make($service->service_type)->suspend($service); + } catch (\Throwable $e) { + Log::error("Dunning: failed to suspend service #{$service->id} on platform", [ + 'service_id' => $service->id, + 'service_type' => $service->service_type, + 'error' => $e->getMessage(), + ]); + } + + ServiceSuspended::dispatch($user, $service); + $user->notify(new ServiceSuspendedNotification($service)); + } } /** * Reactivate services when a past-due subscription becomes active again. + * Calls provisioning API for each service and fires events. */ public function reactivateServicesForSubscription(Subscription $subscription): void { - Service::query() + $services = Service::query() ->where('subscription_id', $subscription->id) ->where('status', 'suspended') - ->update([ + ->with('user') + ->get(); + + foreach ($services as $service) { + $user = $service->user; + + ServiceUnsuspending::dispatch($user, $service); + + $service->update([ 'status' => 'active', 'suspended_at' => null, ]); + + try { + $this->provisioningFactory->make($service->service_type)->unsuspend($service); + } catch (\Throwable $e) { + Log::error("Dunning: failed to unsuspend service #{$service->id} on platform", [ + 'service_id' => $service->id, + 'service_type' => $service->service_type, + 'error' => $e->getMessage(), + ]); + } + + ServiceUnsuspended::dispatch($user, $service); + } + } + + /** + * Send suspension warning notifications to users whose services will be suspended soon. + * Warns users whose subscriptions are past due and within the warning window. + */ + public function sendSuspensionWarnings(): int + { + $warningDays = config('billing.suspension.warning_days_before_suspend'); + + $subscriptions = Subscription::query() + ->where('stripe_status', 'past_due') + ->with('user') + ->get(); + + $count = 0; + + foreach ($subscriptions as $subscription) { + $effectiveDays = $this->getEffectiveSuspendDays($subscription); + + if ($effectiveDays === null) { + continue; + } + + $daysPastDue = (int) now()->diffInDays($subscription->updated_at); + $daysUntilSuspend = $effectiveDays - $daysPastDue; + + // Send warning if within the warning window (e.g., 1 day before suspension) + if ($daysUntilSuspend <= $warningDays && $daysUntilSuspend > 0) { + $services = Service::query() + ->where('subscription_id', $subscription->id) + ->where('status', 'active') + ->get(); + + $user = $subscription->user; + + foreach ($services as $service) { + $user->notify(new ServiceSuspensionWarningNotification($service)); + $count++; + } + } + } + + if ($count > 0) { + Log::info("Dunning: sent {$count} suspension warning notification(s)."); + } + + return $count; + } + + /** + * Send termination warning notifications to users whose services will be terminated soon. + * Warns users whose services are suspended and within the warning window. + */ + public function sendTerminationWarnings(): int + { + $warningDays = config('billing.suspension.warning_days_before_terminate'); + + $services = Service::query() + ->where('status', 'suspended') + ->whereNotNull('suspended_at') + ->with(['user', 'plan']) + ->get(); + + $count = 0; + + foreach ($services as $service) { + $effectiveDays = $this->getEffectiveTerminateDays($service); + + if ($effectiveDays === null) { + continue; + } + + $daysSuspended = (int) now()->diffInDays($service->suspended_at); + $daysUntilTerminate = $effectiveDays - $daysSuspended; + + // Send warning if within the warning window (e.g., 7 days before termination) + if ($daysUntilTerminate <= $warningDays && $daysUntilTerminate > 0) { + $service->user->notify(new ServiceTerminationWarningNotification($service)); + $count++; + } + } + + if ($count > 0) { + Log::info("Dunning: sent {$count} termination warning notification(s)."); + } + + return $count; + } + + /** + * Get the effective number of days past due before suspension. + * Resolution order: Customer override -> Plan setting -> Global config. + * + * Returns null if auto-suspend is disabled for the plan. + */ + public function getEffectiveSuspendDays(Subscription $subscription): ?int + { + // Load the plan via any service linked to this subscription + $service = Service::query() + ->where('subscription_id', $subscription->id) + ->with('plan') + ->first(); + + $plan = $service?->plan; + + // Check if auto-suspend is disabled at the plan level + if ($plan && ! $plan->auto_suspend_enabled) { + return null; + } + + // Customer override takes priority + $user = $subscription->user; + if ($user && $user->override_days_to_suspend !== null) { + return $user->override_days_to_suspend; + } + + // Plan-level setting + if ($plan && $plan->days_to_suspend !== null) { + return $plan->days_to_suspend; + } + + // Global config fallback + return (int) config('billing.suspension.days_past_due_to_suspend'); + } + + /** + * Get the effective number of days suspended before termination. + * Resolution order: Customer override -> Plan setting -> Global config. + * + * Returns null if auto-terminate is disabled for the plan. + */ + public function getEffectiveTerminateDays(Service $service): ?int + { + $plan = $service->plan; + + // Check if auto-terminate is disabled at the plan level + if ($plan && ! $plan->auto_terminate_enabled) { + return null; + } + + // Customer override takes priority + $user = $service->user; + if ($user && $user->override_days_to_terminate !== null) { + return $user->override_days_to_terminate; + } + + // Plan-level setting + if ($plan && $plan->days_to_terminate !== null) { + return $plan->days_to_terminate; + } + + // Global config fallback + return (int) config('billing.suspension.days_suspended_to_terminate'); } /** diff --git a/website/app/Services/FraudDetectionService.php b/website/app/Services/FraudDetectionService.php new file mode 100644 index 0000000..fda4834 --- /dev/null +++ b/website/app/Services/FraudDetectionService.php @@ -0,0 +1,251 @@ + + */ + private const WEIGHTS = [ + 'disposable_email' => 25, + 'velocity' => 20, + 'ip_country_mismatch' => 20, + 'email_domain_age' => 15, + 'previous_flags' => 20, + ]; + + public function assess(User $user, ?Order $order = null): OrderRiskAssessment + { + if (! config('fraud.enabled', true)) { + return OrderRiskAssessment::create([ + 'order_id' => $order?->id, + 'user_id' => $user->id, + 'risk_score' => 0, + 'risk_level' => 'low', + 'checks' => [], + 'auto_action' => 'approve', + ]); + } + + $ip = request()->ip() ?? '127.0.0.1'; + $billingCountry = $user->profile?->billing_country; + + $checks = [ + 'disposable_email' => $this->checkDisposableEmail($user->email), + 'velocity' => $this->checkVelocity($user), + 'ip_country_mismatch' => $this->checkIpCountryMismatch($ip, $billingCountry), + 'email_domain_age' => $this->checkEmailDomainAge($user->email), + 'previous_flags' => $this->checkPreviousFraudFlags($user), + ]; + + $totalScore = 0; + foreach ($checks as $name => $check) { + $weight = self::WEIGHTS[$name] ?? 0; + $totalScore += (int) round($check['score'] * ($weight / 100)); + } + + $totalScore = min(100, max(0, $totalScore)); + + $thresholds = config('fraud.thresholds', []); + $autoApproveBelow = (int) ($thresholds['auto_approve_below'] ?? 30); + $autoHoldAbove = (int) ($thresholds['auto_hold_above'] ?? 60); + $autoRejectAbove = (int) ($thresholds['auto_reject_above'] ?? 85); + + $autoAction = match (true) { + $totalScore >= $autoRejectAbove => 'reject', + $totalScore >= $autoHoldAbove => 'hold', + default => 'approve', + }; + + $riskLevel = match (true) { + $totalScore >= $autoRejectAbove => 'critical', + $totalScore >= $autoHoldAbove => 'high', + $totalScore >= $autoApproveBelow => 'medium', + default => 'low', + }; + + return OrderRiskAssessment::create([ + 'order_id' => $order?->id, + 'user_id' => $user->id, + 'risk_score' => $totalScore, + 'risk_level' => $riskLevel, + 'checks' => $checks, + 'auto_action' => $autoAction, + ]); + } + + /** + * Check if the email domain is a known disposable email provider. + * + * @return array{score: int, details: string} + */ + public function checkDisposableEmail(string $email): array + { + $domain = strtolower(substr($email, strrpos($email, '@') + 1)); + $disposableDomains = config('fraud.disposable_email_domains', []); + + if (in_array($domain, $disposableDomains, true)) { + return [ + 'score' => 100, + 'details' => "Disposable email domain detected: {$domain}", + ]; + } + + return [ + 'score' => 0, + 'details' => 'Email domain is legitimate', + ]; + } + + /** + * Check signup velocity from the same IP in the last 24 hours. + * + * @return array{score: int, details: string} + */ + public function checkVelocity(User $user): array + { + $ip = request()->ip() ?? '127.0.0.1'; + + $recentSignups = LoginHistory::where('ip_address', $ip) + ->where('created_at', '>=', now()->subDay()) + ->where('success', true) + ->distinct('user_id') + ->count('user_id'); + + if ($recentSignups >= 5) { + return [ + 'score' => 100, + 'details' => "{$recentSignups} distinct user logins from IP {$ip} in 24h", + ]; + } + + if ($recentSignups >= 3) { + return [ + 'score' => 60, + 'details' => "{$recentSignups} distinct user logins from IP {$ip} in 24h", + ]; + } + + return [ + 'score' => 0, + 'details' => 'Normal signup velocity', + ]; + } + + /** + * Check if the IP geolocation matches the billing country. + * + * @return array{score: int, details: string} + */ + public function checkIpCountryMismatch(string $ip, ?string $billingCountry): array + { + if ($billingCountry === null) { + return [ + 'score' => 10, + 'details' => 'No billing country set for comparison', + ]; + } + + try { + $location = geoip($ip); + $ipCountry = $location->iso_code ?? null; + + if ($ipCountry === null) { + return [ + 'score' => 10, + 'details' => 'Unable to determine IP country', + ]; + } + + if (strtoupper($ipCountry) !== strtoupper($billingCountry)) { + return [ + 'score' => 80, + 'details' => "IP country ({$ipCountry}) does not match billing country ({$billingCountry})", + ]; + } + + return [ + 'score' => 0, + 'details' => 'IP country matches billing country', + ]; + } catch (\Throwable) { + return [ + 'score' => 0, + 'details' => 'GeoIP lookup unavailable, skipping check', + ]; + } + } + + /** + * Check if the email domain has valid MX records. + * + * @return array{score: int, details: string} + */ + public function checkEmailDomainAge(string $email): array + { + $domain = substr($email, strrpos($email, '@') + 1); + + try { + $mxRecords = []; + $hasMx = @getmxrr($domain, $mxRecords); + + if (! $hasMx || empty($mxRecords)) { + return [ + 'score' => 80, + 'details' => "No MX records found for domain: {$domain}", + ]; + } + + return [ + 'score' => 0, + 'details' => "Email domain {$domain} has valid MX records", + ]; + } catch (\Throwable) { + return [ + 'score' => 0, + 'details' => 'DNS lookup unavailable, skipping check', + ]; + } + } + + /** + * Check if the user has previous fraud flags or risk assessments. + * + * @return array{score: int, details: string} + */ + public function checkPreviousFraudFlags(User $user): array + { + $previousFlags = OrderRiskAssessment::where('user_id', $user->id) + ->whereIn('risk_level', ['high', 'critical']) + ->count(); + + if ($previousFlags >= 3) { + return [ + 'score' => 100, + 'details' => "{$previousFlags} previous high/critical risk assessments", + ]; + } + + if ($previousFlags >= 1) { + return [ + 'score' => 50, + 'details' => "{$previousFlags} previous high/critical risk assessment(s)", + ]; + } + + return [ + 'score' => 0, + 'details' => 'No previous fraud flags', + ]; + } +} diff --git a/website/app/Services/Reports/FinancialReportService.php b/website/app/Services/Reports/FinancialReportService.php index b774459..421e8f0 100644 --- a/website/app/Services/Reports/FinancialReportService.php +++ b/website/app/Services/Reports/FinancialReportService.php @@ -412,16 +412,27 @@ class FinancialReportService $query->whereNull('subscriptions.cancelled_at') ->orWhere('subscriptions.cancelled_at', '>', $date->endOfDay()); }) - ->join('plan_prices', function ($join): void { + ->leftJoin('plan_prices', function ($join): void { $join->on('subscriptions.plan_id', '=', 'plan_prices.plan_id') ->on('subscriptions.billing_cycle', '=', 'plan_prices.billing_cycle'); }) - ->selectRaw('SUM(CASE subscriptions.billing_cycle - WHEN "monthly" THEN plan_prices.price - WHEN "quarterly" THEN plan_prices.price / 3 - WHEN "semi_annual" THEN plan_prices.price / 6 - WHEN "annual" THEN plan_prices.price / 12 - ELSE plan_prices.price + ->selectRaw('SUM(CASE + WHEN subscriptions.recurring_amount IS NOT NULL THEN + CASE subscriptions.billing_cycle + WHEN "monthly" THEN subscriptions.recurring_amount + WHEN "quarterly" THEN subscriptions.recurring_amount / 3 + WHEN "semi_annual" THEN subscriptions.recurring_amount / 6 + WHEN "annual" THEN subscriptions.recurring_amount / 12 + ELSE subscriptions.recurring_amount + END + ELSE + CASE subscriptions.billing_cycle + WHEN "monthly" THEN plan_prices.price + WHEN "quarterly" THEN plan_prices.price / 3 + WHEN "semi_annual" THEN plan_prices.price / 6 + WHEN "annual" THEN plan_prices.price / 12 + ELSE plan_prices.price + END END) as mrr') ->value('mrr') ?? 0); } diff --git a/website/app/Services/ServerHunterService.php b/website/app/Services/ServerHunterService.php new file mode 100644 index 0000000..3a37638 --- /dev/null +++ b/website/app/Services/ServerHunterService.php @@ -0,0 +1,262 @@ +>} + */ + public function buildFeed(): array + { + $plans = Plan::query() + ->whereIn('service_type', ['vps', 'dedicated']) + ->where('status', 'active') + ->with('prices') + ->orderBy('service_type') + ->orderBy('sort_order') + ->orderBy('price') + ->get(); + + $offers = $plans + ->map(fn (Plan $plan): ?array => $this->transformPlan($plan)) + ->filter() + ->values() + ->all(); + + return [ + 'version' => 1, + 'offers' => $offers, + ]; + } + + /** + * Transform a Plan model into a ServerHunter offer array. + * + * @return array|null + */ + public function transformPlan(Plan $plan): ?array + { + if (! in_array($plan->service_type, ['vps', 'dedicated'], true)) { + return null; + } + + $config = $plan->provisioning_config ?? []; + $specs = $this->resolveSpecs($plan, $config); + $typeConfig = config("serverhunter.{$plan->service_type}", []); + + $monthlyPrice = $this->getMonthlyPrice($plan); + if ($monthlyPrice === null) { + return null; + } + + $stock = $this->resolveStock($plan); + + return [ + 'name' => $plan->name, + 'internal' => $plan->slug, + 'url' => 'https://ezscale.cloud/pricing', + 'currency' => config('serverhunter.currency'), + 'price' => number_format((float) $monthlyPrice, 2, '.', ''), + 'setup_fee' => $typeConfig['setup_fee'] ?? '0.00', + 'stock' => $stock, + 'billing_interval' => 'monthly', + 'product_type' => $plan->service_type, + 'virtualization' => $typeConfig['virtualization'] ?? 'kvm', + 'visibility' => 'visible', + 'gpu_name' => $specs['gpu_name'], + 'cpu_type' => $specs['cpu_type'], + 'cpu_name' => $specs['cpu_name'], + 'cpu_amount' => (string) $specs['cpu_amount'], + 'cpu_cores' => (string) $specs['cpu_cores'], + 'cpu_speed' => $specs['cpu_speed'], + 'memory_amount' => (string) $specs['memory_mb'], + 'memory_type' => $specs['memory_type'], + 'memory_ecc' => $specs['memory_ecc'], + 'hdd_amount' => (string) $specs['hdd_amount'], + 'hdd_capacity' => (string) $specs['hdd_capacity'], + 'ssd_amount' => (string) $specs['ssd_amount'], + 'ssd_capacity' => (string) $specs['ssd_capacity'], + 'uplink' => (string) $specs['uplink_mbps'], + 'traffic' => (string) $specs['bandwidth_tb'], + 'unmetered' => $typeConfig['unmetered'] ?? [], + 'operating_systems' => $typeConfig['operating_systems'] ?? [], + 'control_panel' => [], + 'country_code' => config('serverhunter.country_code'), + 'location' => config('serverhunter.location'), + 'coordinates' => config('serverhunter.coordinates'), + 'payment_methods' => config('serverhunter.payment_methods'), + 'features' => $typeConfig['features'] ?? [], + ]; + } + + /** + * Push offers to the ServerHunter API. + * + * @param array{version: int, offers: list>} $feed + */ + public function pushToApi(array $feed): array + { + $apiKey = config('serverhunter.api_key'); + + if (empty($apiKey)) { + throw new \RuntimeException('SERVERHUNTER_API_KEY is not configured.'); + } + + $response = Http::withToken($apiKey) + ->timeout(30) + ->post(config('serverhunter.api_url').'/v1/offers', $feed); + + return [ + 'status' => $response->status(), + 'body' => $response->json(), + 'successful' => $response->successful(), + ]; + } + + /** + * Fetch and cache the ServerHunter spider IP whitelist. + * + * @return list + */ + public function getSpiderIps(): array + { + $ttl = (int) config('serverhunter.spider_ips_cache_ttl', 86400); + + return Cache::remember('serverhunter:spider-ips', $ttl, function (): array { + $response = Http::timeout(10) + ->get(config('serverhunter.spider_ips_url')); + + if (! $response->successful()) { + return []; + } + + $body = trim($response->body()); + + if (empty($body)) { + return []; + } + + // The response is a newline-separated list of IPs + return array_values(array_filter( + array_map('trim', explode("\n", $body)), + fn (string $ip): bool => $ip !== '' && filter_var($ip, FILTER_VALIDATE_IP) !== false, + )); + }); + } + + /** + * Get the monthly price for a plan. + */ + private function getMonthlyPrice(Plan $plan): ?string + { + // First try the plan_prices table for a monthly price + $monthlyPrice = $plan->priceForCycle('monthly'); + + if ($monthlyPrice !== null) { + return $monthlyPrice->price; + } + + // Fall back to the plan's base price + if ($plan->price !== null && (float) $plan->price > 0) { + return $plan->price; + } + + return null; + } + + /** + * Resolve hardware specs from provisioning_config or derive from plan name. + * + * @param array $config + * @return array + */ + private function resolveSpecs(Plan $plan, array $config): array + { + $defaults = config('serverhunter.defaults', []); + $inferredCores = $this->inferCoresFromSlug($plan->slug); + + $cpuCores = $config['cpu_cores'] ?? $inferredCores; + $diskGb = $config['disk_gb'] ?? $this->inferDiskFromSlug($plan->slug, $cpuCores); + $memoryMb = $config['memory_mb'] ?? ($cpuCores * 1024); + $diskType = $config['disk_type'] ?? $defaults['disk_type'] ?? 'nvme'; + $isNvmeOrSsd = in_array($diskType, ['nvme', 'ssd'], true); + + return [ + 'gpu_name' => $config['gpu_name'] ?? null, + 'cpu_type' => $config['cpu_type'] ?? $defaults['cpu_type'] ?? 'amd', + 'cpu_name' => $config['cpu_name'] ?? $defaults['cpu_name'] ?? 'EPYC', + 'cpu_speed' => $config['cpu_speed'] ?? $defaults['cpu_speed'] ?? '3.70', + 'cpu_amount' => $config['cpu_amount'] ?? 1, + 'cpu_cores' => $cpuCores, + 'memory_mb' => $memoryMb, + 'memory_type' => $config['memory_type'] ?? $defaults['memory_type'] ?? 'ddr4', + 'memory_ecc' => $config['memory_ecc'] ?? $defaults['memory_ecc'] ?? 'ecc', + 'hdd_amount' => $isNvmeOrSsd ? 0 : ($config['hdd_amount'] ?? 1), + 'hdd_capacity' => $isNvmeOrSsd ? 0 : ($config['hdd_capacity'] ?? $diskGb), + 'ssd_amount' => $isNvmeOrSsd ? ($config['ssd_amount'] ?? 1) : 0, + 'ssd_capacity' => $isNvmeOrSsd ? $diskGb : 0, + 'uplink_mbps' => $config['uplink_mbps'] ?? $defaults['uplink_mbps'] ?? 1000, + 'bandwidth_tb' => $config['bandwidth_tb'] ?? 0, + ]; + } + + /** + * Try to infer the number of CPU cores from the plan slug. + * e.g., "vps-1" => 1, "vps-4" => 4. Storage plans default to 2 cores. + */ + private function inferCoresFromSlug(string $slug): int + { + // Storage plans are not CPU-based — use sensible defaults + if (str_starts_with($slug, 'stor-')) { + return 2; + } + + if (preg_match('/^vps-(\d+)$/', $slug, $matches)) { + return max(1, (int) $matches[1]); + } + + return 1; + } + + /** + * Infer disk size from slug. Storage plans use their capacity, VPS uses cores * 25. + */ + private function inferDiskFromSlug(string $slug, int $cores): int + { + return match (true) { + $slug === 'stor-500' => 500, + $slug === 'stor-1tb' => 1000, + str_starts_with($slug, 'stor-36bay') => 4000, + default => $cores * 25, + }; + } + + /** + * Resolve stock status from plan attributes. + */ + private function resolveStock(Plan $plan): string + { + if ($plan->stock_quantity === null) { + return 'in_stock'; + } + + if ($plan->stock_quantity <= 0) { + return 'out_of_stock'; + } + + if ($plan->stock_quantity <= 5) { + return 'limited'; + } + + return 'in_stock'; + } +} diff --git a/website/app/Services/Support/SlaService.php b/website/app/Services/Support/SlaService.php new file mode 100644 index 0000000..52d987c --- /dev/null +++ b/website/app/Services/Support/SlaService.php @@ -0,0 +1,192 @@ +departmentRelation; + + if (! $department?->sla_policy_id) { + return; + } + + $policy = SlaPolicy::query()->find($department->sla_policy_id); + + if (! $policy) { + return; + } + + // Find a policy matching the ticket priority, or fall back to the department's default + $priorityPolicy = SlaPolicy::query() + ->where('priority', $ticket->priority) + ->where('id', $policy->id) + ->first(); + + $effectivePolicy = $priorityPolicy ?? $policy; + + $now = Carbon::now(); + + $ticket->update([ + 'sla_policy_id' => $effectivePolicy->id, + 'first_response_due_at' => $this->calculateDueTime( + $now, + $effectivePolicy->first_response_hours, + $effectivePolicy->business_hours_only + ), + 'resolution_due_at' => $this->calculateDueTime( + $now, + $effectivePolicy->resolution_hours, + $effectivePolicy->business_hours_only + ), + ]); + } + + /** + * Calculate the due time accounting for business hours if required. + */ + public function calculateDueTime(Carbon $from, int $hours, bool $businessHoursOnly): Carbon + { + if (! $businessHoursOnly) { + return $from->copy()->addHours($hours); + } + + $businessHoursConfig = SlaBusinessHours::query() + ->where('is_holiday', false) + ->orderBy('day_of_week') + ->get(); + + if ($businessHoursConfig->isEmpty()) { + // No business hours configured, fall back to calendar hours + return $from->copy()->addHours($hours); + } + + $remainingMinutes = $hours * 60; + $current = $from->copy(); + + $maxIterations = $hours * 5; // Safety valve + $iteration = 0; + + while ($remainingMinutes > 0 && $iteration < $maxIterations) { + $iteration++; + $dayOfWeek = (int) $current->dayOfWeek; + + $dayHours = $businessHoursConfig->where('day_of_week', $dayOfWeek)->first(); + + if (! $dayHours) { + // Not a business day, skip to next day + $current->addDay()->startOfDay(); + + continue; + } + + $startTime = Carbon::parse($dayHours->start_time, $current->timezone)->setDate( + $current->year, + $current->month, + $current->day + ); + $endTime = Carbon::parse($dayHours->end_time, $current->timezone)->setDate( + $current->year, + $current->month, + $current->day + ); + + if ($current->lt($startTime)) { + $current = $startTime->copy(); + } + + if ($current->gte($endTime)) { + $current->addDay()->startOfDay(); + + continue; + } + + $availableMinutes = (int) $current->diffInMinutes($endTime); + + if ($availableMinutes >= $remainingMinutes) { + $current->addMinutes($remainingMinutes); + $remainingMinutes = 0; + } else { + $remainingMinutes -= $availableMinutes; + $current->addDay()->startOfDay(); + } + } + + return $current; + } + + /** + * Check all open tickets for SLA breaches. + */ + public function checkBreaches(): int + { + $now = Carbon::now(); + $breachCount = 0; + + // Check first response breaches + $firstResponseBreaches = SupportTicket::query() + ->whereNotNull('first_response_due_at') + ->whereNull('first_responded_at') + ->where('first_response_due_at', '<', $now) + ->where('sla_first_response_breached', false) + ->whereIn('status', ['open', 'in_progress', 'waiting']) + ->get(); + + foreach ($firstResponseBreaches as $ticket) { + $ticket->update(['sla_first_response_breached' => true]); + Log::warning('SLA first response breached', [ + 'ticket_id' => $ticket->id, + 'ticket_reference' => $ticket->ticket_reference, + 'due_at' => $ticket->first_response_due_at->toDateTimeString(), + ]); + $breachCount++; + } + + // Check resolution breaches + $resolutionBreaches = SupportTicket::query() + ->whereNotNull('resolution_due_at') + ->whereNull('resolved_at') + ->where('resolution_due_at', '<', $now) + ->where('sla_resolution_breached', false) + ->whereIn('status', ['open', 'in_progress', 'waiting']) + ->get(); + + foreach ($resolutionBreaches as $ticket) { + $ticket->update(['sla_resolution_breached' => true]); + Log::warning('SLA resolution breached', [ + 'ticket_id' => $ticket->id, + 'ticket_reference' => $ticket->ticket_reference, + 'due_at' => $ticket->resolution_due_at->toDateTimeString(), + ]); + $breachCount++; + } + + return $breachCount; + } + + /** + * Record the first staff response on a ticket. + */ + public function recordFirstResponse(SupportTicket $ticket): void + { + if ($ticket->first_responded_at !== null) { + return; + } + + $ticket->update([ + 'first_responded_at' => Carbon::now(), + ]); + } +} diff --git a/website/bootstrap/app.php b/website/bootstrap/app.php index d184690..81e61ba 100644 --- a/website/bootstrap/app.php +++ b/website/bootstrap/app.php @@ -13,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', then: function (): void { Route::domain(config('app.domains.marketing')) - ->middleware('web') + ->middleware(['web', 'track_affiliate']) ->group(base_path('routes/marketing.php')); Route::domain(config('app.domains.account')) @@ -21,7 +21,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->group(base_path('routes/account.php')); Route::domain(config('app.domains.admin')) - ->middleware(['web', 'auth', 'verified', 'role:admin']) + ->middleware(['web', 'auth', 'verified', 'role:admin|super_admin|billing_admin|support_agent|support_lead|readonly_admin']) ->group(base_path('routes/admin.php')); Route::domain(config('app.domains.account')) @@ -59,6 +59,8 @@ return Application::configure(basePath: dirname(__DIR__)) 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, 'ensure_not_suspended' => \App\Http\Middleware\EnsureUserNotSuspended::class, + 'serverhunter' => \App\Http\Middleware\AllowServerHunterSpider::class, + 'track_affiliate' => \App\Http\Middleware\TrackAffiliateReferral::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/website/config/affiliate.php b/website/config/affiliate.php new file mode 100644 index 0000000..c69b250 --- /dev/null +++ b/website/config/affiliate.php @@ -0,0 +1,13 @@ + env('AFFILIATE_COMMISSION_TYPE', 'percentage'), + 'default_commission_rate' => (float) env('AFFILIATE_COMMISSION_RATE', 10.00), + 'default_recurring_commissions' => (bool) env('AFFILIATE_RECURRING_COMMISSIONS', false), + 'default_minimum_payout' => (float) env('AFFILIATE_MINIMUM_PAYOUT', 50.00), + + 'cookie_lifetime_days' => (int) env('AFFILIATE_COOKIE_DAYS', 30), + 'auto_approve' => (bool) env('AFFILIATE_AUTO_APPROVE', false), +]; diff --git a/website/config/billing.php b/website/config/billing.php index 3b39768..25e740e 100644 --- a/website/config/billing.php +++ b/website/config/billing.php @@ -57,6 +57,8 @@ return [ 'suspension' => [ 'days_past_due_to_suspend' => (int) env('SUSPENSION_DAYS_PAST_DUE', 7), 'days_suspended_to_terminate' => (int) env('SUSPENSION_DAYS_TO_TERMINATE', 30), + 'warning_days_before_suspend' => (int) env('SUSPENSION_WARNING_DAYS', 1), + 'warning_days_before_terminate' => (int) env('SUSPENSION_TERMINATE_WARNING_DAYS', 7), ], /* diff --git a/website/config/fraud.php b/website/config/fraud.php new file mode 100644 index 0000000..f3a6adc --- /dev/null +++ b/website/config/fraud.php @@ -0,0 +1,28 @@ + env('FRAUD_DETECTION_ENABLED', true), + + 'thresholds' => [ + 'auto_approve_below' => (int) env('FRAUD_AUTO_APPROVE_BELOW', 30), + 'auto_hold_above' => (int) env('FRAUD_AUTO_HOLD_ABOVE', 60), + 'auto_reject_above' => (int) env('FRAUD_AUTO_REJECT_ABOVE', 85), + ], + + 'disposable_email_domains' => [ + 'mailinator.com', + 'guerrillamail.com', + 'tempmail.com', + 'throwaway.email', + 'yopmail.com', + 'sharklasers.com', + 'guerrillamailblock.com', + 'grr.la', + 'dispostable.com', + 'trashmail.com', + 'temp-mail.org', + '10minutemail.com', + ], +]; diff --git a/website/config/serverhunter.php b/website/config/serverhunter.php new file mode 100644 index 0000000..801f802 --- /dev/null +++ b/website/config/serverhunter.php @@ -0,0 +1,41 @@ + env('SERVERHUNTER_API_KEY'), + 'api_url' => 'https://api.serverhunter.com', + 'location' => 'Atlanta, Georgia, United States of America', + 'country_code' => 'US', + 'coordinates' => '33.7490,-84.3880', + 'currency' => 'USD', + 'payment_methods' => ['creditcard', 'paypal'], + 'spider_ips_url' => 'https://www.serverhunter.com/spider/ips/', + 'spider_ips_cache_ttl' => 86400, // 24 hours + + 'vps' => [ + 'virtualization' => 'kvm', + 'features' => ['ddos', 'ipv6', 'instant_setup', 'api'], + 'operating_systems' => ['ubuntu', 'debian', 'centos', 'fedora', 'windows', 'custom'], + 'unmetered' => ['inbound', 'outbound'], + 'setup_fee' => '0.00', + ], + + 'dedicated' => [ + 'virtualization' => 'none', + 'features' => ['ddos', 'ipv6', 'api', 'hwraid', 'kvm'], + 'operating_systems' => ['ubuntu', 'debian', 'centos', 'fedora', 'windows', 'custom', 'proxmox', 'vmware'], + 'unmetered' => ['outbound'], + 'setup_fee' => '0.00', + ], + + 'defaults' => [ + 'cpu_type' => 'intel', + 'cpu_name' => 'Xeon', + 'cpu_speed' => '2.40', + 'memory_type' => 'ddr4', + 'memory_ecc' => 'ecc', + 'disk_type' => 'ssd', + 'uplink_mbps' => 1000, + ], +]; diff --git a/website/database/factories/AccountCreditFactory.php b/website/database/factories/AccountCreditFactory.php new file mode 100644 index 0000000..9c1b6bb --- /dev/null +++ b/website/database/factories/AccountCreditFactory.php @@ -0,0 +1,24 @@ + */ +class AccountCreditFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'amount' => fake()->randomFloat(2, 5, 200), + 'currency' => 'USD', + 'type' => fake()->randomElement(['credit_note', 'admin_adjustment', 'refund', 'referral']), + 'description' => fake()->sentence(), + ]; + } +} diff --git a/website/database/factories/AffiliateCommissionFactory.php b/website/database/factories/AffiliateCommissionFactory.php new file mode 100644 index 0000000..eb3853e --- /dev/null +++ b/website/database/factories/AffiliateCommissionFactory.php @@ -0,0 +1,68 @@ + */ +class AffiliateCommissionFactory extends Factory +{ + protected $model = AffiliateCommission::class; + + /** @return array */ + public function definition(): array + { + return [ + 'affiliate_id' => Affiliate::factory(), + 'referral_id' => AffiliateReferral::factory(), + 'payment_transaction_id' => null, + 'amount' => fake()->randomFloat(2, 1, 50), + 'currency' => 'USD', + 'type' => fake()->randomElement(['signup', 'renewal']), + 'status' => 'pending', + ]; + } + + public function approved(): static + { + return $this->state(fn (): array => [ + 'status' => 'approved', + 'approved_at' => now(), + ]); + } + + public function paid(): static + { + return $this->state(fn (): array => [ + 'status' => 'paid', + 'approved_at' => now(), + 'paid_at' => now(), + ]); + } + + public function reversed(): static + { + return $this->state(fn (): array => [ + 'status' => 'reversed', + ]); + } + + public function signup(): static + { + return $this->state(fn (): array => [ + 'type' => 'signup', + ]); + } + + public function renewal(): static + { + return $this->state(fn (): array => [ + 'type' => 'renewal', + ]); + } +} diff --git a/website/database/factories/AffiliateFactory.php b/website/database/factories/AffiliateFactory.php new file mode 100644 index 0000000..1c7f895 --- /dev/null +++ b/website/database/factories/AffiliateFactory.php @@ -0,0 +1,74 @@ + */ +class AffiliateFactory extends Factory +{ + protected $model = Affiliate::class; + + /** @return array */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'referral_code' => Str::lower(Str::random(8)), + 'status' => 'active', + 'commission_type' => 'percentage', + 'commission_rate' => 10.00, + 'recurring_commissions' => false, + 'minimum_payout' => 50.00, + 'total_earned' => 0, + 'total_paid' => 0, + 'pending_balance' => 0, + 'approved_at' => now(), + ]; + } + + public function pending(): static + { + return $this->state(fn (): array => [ + 'status' => 'pending', + 'approved_at' => null, + ]); + } + + public function active(): static + { + return $this->state(fn (): array => [ + 'status' => 'active', + 'approved_at' => now(), + ]); + } + + public function suspended(): static + { + return $this->state(fn (): array => [ + 'status' => 'suspended', + ]); + } + + public function flat(): static + { + return $this->state(fn (): array => [ + 'commission_type' => 'flat', + 'commission_rate' => 5.00, + ]); + } + + public function withEarnings(): static + { + return $this->state(fn (): array => [ + 'total_earned' => fake()->randomFloat(2, 50, 500), + 'total_paid' => fake()->randomFloat(2, 0, 200), + 'pending_balance' => fake()->randomFloat(2, 10, 100), + ]); + } +} diff --git a/website/database/factories/AffiliatePayoutFactory.php b/website/database/factories/AffiliatePayoutFactory.php new file mode 100644 index 0000000..2813634 --- /dev/null +++ b/website/database/factories/AffiliatePayoutFactory.php @@ -0,0 +1,50 @@ + */ +class AffiliatePayoutFactory extends Factory +{ + protected $model = AffiliatePayout::class; + + /** @return array */ + public function definition(): array + { + return [ + 'affiliate_id' => Affiliate::factory(), + 'amount' => fake()->randomFloat(2, 50, 500), + 'currency' => 'USD', + 'method' => fake()->randomElement(['credit', 'paypal', 'bank_transfer']), + 'status' => 'pending', + ]; + } + + public function processing(): static + { + return $this->state(fn (): array => [ + 'status' => 'processing', + ]); + } + + public function completed(): static + { + return $this->state(fn (): array => [ + 'status' => 'completed', + 'processed_at' => now(), + 'reference' => 'PAY-'.strtoupper(fake()->bothify('########')), + ]); + } + + public function failed(): static + { + return $this->state(fn (): array => [ + 'status' => 'failed', + ]); + } +} diff --git a/website/database/factories/AffiliateReferralFactory.php b/website/database/factories/AffiliateReferralFactory.php new file mode 100644 index 0000000..434872c --- /dev/null +++ b/website/database/factories/AffiliateReferralFactory.php @@ -0,0 +1,44 @@ + */ +class AffiliateReferralFactory extends Factory +{ + protected $model = AffiliateReferral::class; + + /** @return array */ + public function definition(): array + { + return [ + 'affiliate_id' => Affiliate::factory(), + 'referred_user_id' => User::factory(), + 'subscription_id' => null, + 'status' => 'pending', + 'signup_ip' => fake()->ipv4(), + 'referral_url' => fake()->url(), + ]; + } + + public function approved(): static + { + return $this->state(fn (): array => [ + 'status' => 'approved', + 'approved_at' => now(), + ]); + } + + public function rejected(): static + { + return $this->state(fn (): array => [ + 'status' => 'rejected', + ]); + } +} diff --git a/website/database/factories/CannedResponseFactory.php b/website/database/factories/CannedResponseFactory.php new file mode 100644 index 0000000..b93c31a --- /dev/null +++ b/website/database/factories/CannedResponseFactory.php @@ -0,0 +1,30 @@ + + */ +class CannedResponseFactory extends Factory +{ + protected $model = CannedResponse::class; + + /** @return array */ + public function definition(): array + { + return [ + 'title' => fake()->sentence(4), + 'content' => fake()->paragraphs(2, true), + 'category' => fake()->optional()->randomElement(['greeting', 'billing', 'technical', 'closing']), + 'is_shared' => true, + 'created_by' => User::factory(), + 'usage_count' => fake()->numberBetween(0, 100), + ]; + } +} diff --git a/website/database/factories/CartItemFactory.php b/website/database/factories/CartItemFactory.php new file mode 100644 index 0000000..05030ba --- /dev/null +++ b/website/database/factories/CartItemFactory.php @@ -0,0 +1,39 @@ + */ +class CartItemFactory extends Factory +{ + protected $model = CartItem::class; + + /** @return array */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'session_id' => null, + 'plan_id' => Plan::factory(), + 'billing_cycle' => $this->faker->randomElement(['monthly', 'quarterly', 'semi_annual', 'annual']), + 'quantity' => $this->faker->numberBetween(1, 3), + 'config_selections' => null, + 'provisioning_config' => null, + 'coupon_code' => null, + ]; + } + + public function forSession(string $sessionId): static + { + return $this->state(fn (): array => [ + 'user_id' => null, + 'session_id' => $sessionId, + ]); + } +} diff --git a/website/database/factories/CreditNoteFactory.php b/website/database/factories/CreditNoteFactory.php new file mode 100644 index 0000000..84cc58d --- /dev/null +++ b/website/database/factories/CreditNoteFactory.php @@ -0,0 +1,42 @@ + */ +class CreditNoteFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'number' => CreditNote::generateNumber(), + 'amount' => fake()->randomFloat(2, 5, 200), + 'currency' => 'USD', + 'reason' => fake()->sentence(), + 'status' => 'issued', + 'issued_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'draft', + 'issued_at' => null, + ]); + } + + public function voided(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'voided', + ]); + } +} diff --git a/website/database/factories/CurrencyFactory.php b/website/database/factories/CurrencyFactory.php new file mode 100644 index 0000000..c6abf91 --- /dev/null +++ b/website/database/factories/CurrencyFactory.php @@ -0,0 +1,47 @@ + */ +class CurrencyFactory extends Factory +{ + protected $model = Currency::class; + + /** @return array */ + public function definition(): array + { + return [ + 'code' => strtoupper($this->faker->unique()->lexify('???')), + 'symbol' => $this->faker->randomElement(['$', '€', '£', '¥', '₹']), + 'name' => $this->faker->words(2, true), + 'decimal_places' => 2, + 'exchange_rate' => $this->faker->randomFloat(6, 0.5, 2.0), + 'is_base' => false, + 'is_enabled' => true, + 'last_synced_at' => null, + ]; + } + + public function base(): static + { + return $this->state(fn (): array => [ + 'code' => 'USD', + 'symbol' => '$', + 'name' => 'US Dollar', + 'exchange_rate' => 1.000000, + 'is_base' => true, + ]); + } + + public function disabled(): static + { + return $this->state(fn (): array => [ + 'is_enabled' => false, + ]); + } +} diff --git a/website/database/factories/DebitNoteFactory.php b/website/database/factories/DebitNoteFactory.php new file mode 100644 index 0000000..43f4556 --- /dev/null +++ b/website/database/factories/DebitNoteFactory.php @@ -0,0 +1,43 @@ + */ +class DebitNoteFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'number' => DebitNote::generateNumber(), + 'amount' => fake()->randomFloat(2, 5, 100), + 'currency' => 'USD', + 'reason_type' => fake()->randomElement(['late_fee', 'adjustment', 'underpayment']), + 'reason' => fake()->sentence(), + 'status' => 'issued', + 'issued_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'draft', + 'issued_at' => null, + ]); + } + + public function voided(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'voided', + ]); + } +} diff --git a/website/database/factories/DepartmentFactory.php b/website/database/factories/DepartmentFactory.php new file mode 100644 index 0000000..5106a8a --- /dev/null +++ b/website/database/factories/DepartmentFactory.php @@ -0,0 +1,32 @@ + + */ +class DepartmentFactory extends Factory +{ + protected $model = Department::class; + + /** @return array */ + public function definition(): array + { + $name = fake()->unique()->randomElement(['Billing', 'Technical', 'Sales', 'General', 'Abuse', 'Network']); + + return [ + 'name' => $name, + 'slug' => Str::slug($name), + 'email' => fake()->optional()->safeEmail(), + 'auto_assign_to' => null, + 'sla_policy_id' => null, + 'sort_order' => fake()->numberBetween(0, 10), + ]; + } +} diff --git a/website/database/factories/KnowledgeBaseArticleFactory.php b/website/database/factories/KnowledgeBaseArticleFactory.php new file mode 100644 index 0000000..14cbc8f --- /dev/null +++ b/website/database/factories/KnowledgeBaseArticleFactory.php @@ -0,0 +1,65 @@ + + */ +class KnowledgeBaseArticleFactory extends Factory +{ + protected $model = KnowledgeBaseArticle::class; + + /** @return array */ + public function definition(): array + { + $title = fake()->unique()->sentence(4); + + return [ + 'category_id' => KnowledgeBaseCategory::factory(), + 'author_id' => User::factory(), + 'title' => $title, + 'slug' => Str::slug($title), + 'content' => fake()->paragraphs(5, true), + 'excerpt' => fake()->optional(0.8)->sentence(10), + 'status' => fake()->randomElement(['draft', 'published', 'archived']), + 'is_featured' => fake()->boolean(20), + 'view_count' => fake()->numberBetween(0, 500), + 'helpful_count' => fake()->numberBetween(0, 50), + 'not_helpful_count' => fake()->numberBetween(0, 20), + 'published_at' => fake()->optional(0.7)->dateTimeBetween('-6 months', 'now'), + ]; + } + + public function published(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'published', + 'published_at' => fake()->dateTimeBetween('-6 months', 'now'), + ]); + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => 'draft', + 'published_at' => null, + ]); + } + + public function featured(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_featured' => true, + 'status' => 'published', + 'published_at' => fake()->dateTimeBetween('-6 months', 'now'), + ]); + } +} diff --git a/website/database/factories/KnowledgeBaseCategoryFactory.php b/website/database/factories/KnowledgeBaseCategoryFactory.php new file mode 100644 index 0000000..7d1e930 --- /dev/null +++ b/website/database/factories/KnowledgeBaseCategoryFactory.php @@ -0,0 +1,47 @@ + + */ +class KnowledgeBaseCategoryFactory extends Factory +{ + protected $model = KnowledgeBaseCategory::class; + + /** @return array */ + public function definition(): array + { + $name = fake()->unique()->words(2, true); + + return [ + 'parent_id' => null, + 'name' => ucfirst($name), + 'slug' => Str::slug($name), + 'description' => fake()->optional(0.7)->sentence(), + 'icon' => fake()->randomElement(['tabler-server', 'tabler-credit-card', 'tabler-settings', 'tabler-help', 'tabler-shield', 'tabler-world']), + 'sort_order' => fake()->numberBetween(0, 10), + 'is_visible' => true, + ]; + } + + public function hidden(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_visible' => false, + ]); + } + + public function withParent(KnowledgeBaseCategory $parent): static + { + return $this->state(fn (array $attributes): array => [ + 'parent_id' => $parent->id, + ]); + } +} diff --git a/website/database/factories/OrderRiskAssessmentFactory.php b/website/database/factories/OrderRiskAssessmentFactory.php new file mode 100644 index 0000000..2ae8195 --- /dev/null +++ b/website/database/factories/OrderRiskAssessmentFactory.php @@ -0,0 +1,89 @@ + */ +class OrderRiskAssessmentFactory extends Factory +{ + protected $model = OrderRiskAssessment::class; + + /** @return array */ + public function definition(): array + { + $score = fake()->numberBetween(0, 100); + + return [ + 'order_id' => null, + 'user_id' => User::factory(), + 'risk_score' => $score, + 'risk_level' => match (true) { + $score >= 85 => 'critical', + $score >= 60 => 'high', + $score >= 30 => 'medium', + default => 'low', + }, + 'checks' => [ + 'disposable_email' => ['score' => 0, 'details' => 'Email domain is legitimate'], + 'velocity' => ['score' => 0, 'details' => 'No suspicious activity'], + 'ip_country_mismatch' => ['score' => 0, 'details' => 'IP matches billing country'], + 'email_domain_age' => ['score' => 0, 'details' => 'Email domain has MX records'], + 'previous_flags' => ['score' => 0, 'details' => 'No previous flags'], + ], + 'auto_action' => match (true) { + $score >= 85 => 'reject', + $score >= 60 => 'hold', + default => 'approve', + }, + ]; + } + + public function low(): static + { + return $this->state(fn (): array => [ + 'risk_score' => fake()->numberBetween(0, 29), + 'risk_level' => 'low', + 'auto_action' => 'approve', + ]); + } + + public function medium(): static + { + return $this->state(fn (): array => [ + 'risk_score' => fake()->numberBetween(30, 59), + 'risk_level' => 'medium', + 'auto_action' => 'approve', + ]); + } + + public function high(): static + { + return $this->state(fn (): array => [ + 'risk_score' => fake()->numberBetween(60, 84), + 'risk_level' => 'high', + 'auto_action' => 'hold', + ]); + } + + public function critical(): static + { + return $this->state(fn (): array => [ + 'risk_score' => fake()->numberBetween(85, 100), + 'risk_level' => 'critical', + 'auto_action' => 'reject', + ]); + } + + public function reviewed(): static + { + return $this->state(fn (): array => [ + 'reviewed_by' => User::factory(), + 'reviewed_at' => now(), + ]); + } +} diff --git a/website/database/factories/PaymentTransactionFactory.php b/website/database/factories/PaymentTransactionFactory.php new file mode 100644 index 0000000..2682692 --- /dev/null +++ b/website/database/factories/PaymentTransactionFactory.php @@ -0,0 +1,45 @@ + */ +class PaymentTransactionFactory extends Factory +{ + protected $model = PaymentTransaction::class; + + /** @return array */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'gateway' => fake()->randomElement(['stripe', 'paypal']), + 'gateway_transaction_id' => 'txn_'.fake()->unique()->bothify('############'), + 'amount' => fake()->randomFloat(2, 5, 500), + 'currency' => 'USD', + 'status' => 'completed', + 'payment_method' => 'card', + 'description' => fake()->sentence(), + ]; + } + + public function pending(): static + { + return $this->state(fn (): array => ['status' => 'pending']); + } + + public function completed(): static + { + return $this->state(fn (): array => ['status' => 'completed']); + } + + public function failed(): static + { + return $this->state(fn (): array => ['status' => 'failed']); + } +} diff --git a/website/database/factories/QuoteFactory.php b/website/database/factories/QuoteFactory.php new file mode 100644 index 0000000..4275819 --- /dev/null +++ b/website/database/factories/QuoteFactory.php @@ -0,0 +1,94 @@ + */ +class QuoteFactory extends Factory +{ + protected $model = Quote::class; + + /** @return array */ + public function definition(): array + { + $items = []; + $itemCount = $this->faker->numberBetween(1, 4); + + for ($i = 0; $i < $itemCount; $i++) { + $items[] = [ + 'description' => $this->faker->sentence(4), + 'quantity' => $this->faker->numberBetween(1, 5), + 'unit_price' => $this->faker->randomFloat(2, 5, 500), + ]; + } + + $subtotal = collect($items)->sum(fn (array $item): float => $item['unit_price'] * $item['quantity']); + $tax = round($subtotal * 0.1, 2); + + return [ + 'user_id' => User::factory(), + 'prospect_email' => null, + 'prospect_name' => null, + 'number' => Quote::generateNumber(), + 'status' => 'draft', + 'items' => $items, + 'subtotal' => $subtotal, + 'tax' => $tax, + 'total' => $subtotal + $tax, + 'currency' => 'USD', + 'notes' => $this->faker->optional()->paragraph(), + 'valid_until' => $this->faker->optional()->dateTimeBetween('+7 days', '+60 days'), + 'accepted_at' => null, + 'sent_at' => null, + 'created_by' => User::factory(), + ]; + } + + public function prospect(): static + { + return $this->state(fn (): array => [ + 'user_id' => null, + 'prospect_email' => $this->faker->safeEmail(), + 'prospect_name' => $this->faker->name(), + ]); + } + + public function sent(): static + { + return $this->state(fn (): array => [ + 'status' => 'sent', + 'sent_at' => now(), + ]); + } + + public function accepted(): static + { + return $this->state(fn (): array => [ + 'status' => 'accepted', + 'sent_at' => now()->subDays(3), + 'accepted_at' => now(), + ]); + } + + public function rejected(): static + { + return $this->state(fn (): array => [ + 'status' => 'rejected', + 'sent_at' => now()->subDays(3), + ]); + } + + public function expired(): static + { + return $this->state(fn (): array => [ + 'status' => 'sent', + 'sent_at' => now()->subDays(45), + 'valid_until' => now()->subDays(5), + ]); + } +} diff --git a/website/database/factories/SlaPolicyFactory.php b/website/database/factories/SlaPolicyFactory.php new file mode 100644 index 0000000..e40426d --- /dev/null +++ b/website/database/factories/SlaPolicyFactory.php @@ -0,0 +1,37 @@ + + */ +class SlaPolicyFactory extends Factory +{ + protected $model = SlaPolicy::class; + + /** @return array */ + public function definition(): array + { + return [ + 'name' => fake()->words(3, true).' SLA', + 'priority' => fake()->randomElement(['low', 'medium', 'high', 'urgent']), + 'first_response_hours' => fake()->randomElement([1, 2, 4, 8, 24]), + 'resolution_hours' => fake()->randomElement([8, 24, 48, 72]), + 'business_hours_only' => fake()->boolean(70), + ]; + } + + public function urgent(): static + { + return $this->state(fn (array $attributes): array => [ + 'priority' => 'urgent', + 'first_response_hours' => 1, + 'resolution_hours' => 4, + ]); + } +} diff --git a/website/database/factories/TicketTagFactory.php b/website/database/factories/TicketTagFactory.php new file mode 100644 index 0000000..e721a6b --- /dev/null +++ b/website/database/factories/TicketTagFactory.php @@ -0,0 +1,25 @@ + + */ +class TicketTagFactory extends Factory +{ + protected $model = TicketTag::class; + + /** @return array */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->randomElement(['bug', 'feature-request', 'urgent', 'billing-issue', 'network', 'abuse', 'migration', 'dns']), + 'color' => fake()->hexColor(), + ]; + } +} diff --git a/website/database/migrations/2026_03_16_180347_add_recurring_amount_to_subscriptions_table.php b/website/database/migrations/2026_03_16_180347_add_recurring_amount_to_subscriptions_table.php new file mode 100644 index 0000000..e392119 --- /dev/null +++ b/website/database/migrations/2026_03_16_180347_add_recurring_amount_to_subscriptions_table.php @@ -0,0 +1,24 @@ +decimal('recurring_amount', 10, 2)->nullable()->after('billing_cycle'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table): void { + $table->dropColumn('recurring_amount'); + }); + } +}; diff --git a/website/database/migrations/2026_03_17_000001_add_lifecycle_fields_to_plans_table.php b/website/database/migrations/2026_03_17_000001_add_lifecycle_fields_to_plans_table.php new file mode 100644 index 0000000..5f6b330 --- /dev/null +++ b/website/database/migrations/2026_03_17_000001_add_lifecycle_fields_to_plans_table.php @@ -0,0 +1,32 @@ +unsignedInteger('days_to_suspend')->nullable()->after('sort_order'); + $table->unsignedInteger('days_to_terminate')->nullable()->after('days_to_suspend'); + $table->boolean('auto_suspend_enabled')->default(true)->after('days_to_terminate'); + $table->boolean('auto_terminate_enabled')->default(true)->after('auto_suspend_enabled'); + }); + } + + public function down(): void + { + Schema::table('plans', function (Blueprint $table) { + $table->dropColumn([ + 'days_to_suspend', + 'days_to_terminate', + 'auto_suspend_enabled', + 'auto_terminate_enabled', + ]); + }); + } +}; diff --git a/website/database/migrations/2026_03_17_000002_add_lifecycle_overrides_to_users_table.php b/website/database/migrations/2026_03_17_000002_add_lifecycle_overrides_to_users_table.php new file mode 100644 index 0000000..c634d0e --- /dev/null +++ b/website/database/migrations/2026_03_17_000002_add_lifecycle_overrides_to_users_table.php @@ -0,0 +1,30 @@ +unsignedInteger('override_days_to_suspend')->nullable()->after('virtfusion_user_id'); + $table->unsignedInteger('override_days_to_terminate')->nullable()->after('override_days_to_suspend'); + $table->decimal('credit_balance', 10, 2)->default(0)->after('override_days_to_terminate'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'override_days_to_suspend', + 'override_days_to_terminate', + 'credit_balance', + ]); + }); + } +}; diff --git a/website/database/migrations/2026_03_17_000010_create_account_credits_table.php b/website/database/migrations/2026_03_17_000010_create_account_credits_table.php new file mode 100644 index 0000000..eab164e --- /dev/null +++ b/website/database/migrations/2026_03_17_000010_create_account_credits_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->decimal('amount', 10, 2); + $table->string('currency', 3)->default('USD'); + $table->enum('type', ['credit_note', 'admin_adjustment', 'refund', 'referral']); + $table->text('description')->nullable(); + $table->string('reference_type')->nullable(); + $table->unsignedBigInteger('reference_id')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['user_id', 'type']); + $table->index(['reference_type', 'reference_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('account_credits'); + } +}; diff --git a/website/database/migrations/2026_03_17_000011_create_credit_notes_table.php b/website/database/migrations/2026_03_17_000011_create_credit_notes_table.php new file mode 100644 index 0000000..bd2ac8e --- /dev/null +++ b/website/database/migrations/2026_03_17_000011_create_credit_notes_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('invoice_id')->nullable()->constrained()->nullOnDelete(); + $table->string('number')->unique(); + $table->decimal('amount', 10, 2); + $table->string('currency', 3)->default('USD'); + $table->text('reason')->nullable(); + $table->enum('status', ['draft', 'issued', 'voided'])->default('draft'); + $table->timestamp('issued_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('credit_notes'); + } +}; diff --git a/website/database/migrations/2026_03_17_000012_create_debit_notes_table.php b/website/database/migrations/2026_03_17_000012_create_debit_notes_table.php new file mode 100644 index 0000000..c09afdc --- /dev/null +++ b/website/database/migrations/2026_03_17_000012_create_debit_notes_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('invoice_id')->nullable()->constrained()->nullOnDelete(); + $table->string('number')->unique(); + $table->decimal('amount', 10, 2); + $table->string('currency', 3)->default('USD'); + $table->enum('reason_type', ['late_fee', 'adjustment', 'underpayment']); + $table->text('reason')->nullable(); + $table->enum('status', ['draft', 'issued', 'voided'])->default('draft'); + $table->timestamp('issued_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('debit_notes'); + } +}; diff --git a/website/database/migrations/2026_03_17_000013_add_credit_balance_to_users_table.php b/website/database/migrations/2026_03_17_000013_add_credit_balance_to_users_table.php new file mode 100644 index 0000000..3145861 --- /dev/null +++ b/website/database/migrations/2026_03_17_000013_add_credit_balance_to_users_table.php @@ -0,0 +1,28 @@ +decimal('credit_balance', 10, 2)->default(0)->after('status'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('credit_balance'); + }); + } +}; diff --git a/website/database/migrations/2026_03_17_000020_seed_staff_roles_and_permissions.php b/website/database/migrations/2026_03_17_000020_seed_staff_roles_and_permissions.php new file mode 100644 index 0000000..b1b5f14 --- /dev/null +++ b/website/database/migrations/2026_03_17_000020_seed_staff_roles_and_permissions.php @@ -0,0 +1,165 @@ +> + */ + private const PERMISSION_GROUPS = [ + 'customers' => [ + 'customers.view', + 'customers.edit', + 'customers.create', + 'customers.delete', + 'customers.suspend', + 'customers.impersonate', + ], + 'services' => [ + 'services.view', + 'services.suspend', + 'services.terminate', + 'services.provision', + 'services.extend', + ], + 'invoices' => [ + 'invoices.view', + 'invoices.create', + 'invoices.edit', + 'invoices.void', + 'invoices.refund', + ], + 'tickets' => [ + 'tickets.view', + 'tickets.reply', + 'tickets.assign', + 'tickets.close', + 'tickets.delete', + ], + 'reports' => [ + 'reports.view', + 'reports.export', + ], + 'settings' => [ + 'settings.view', + 'settings.edit', + ], + 'audit_logs' => [ + 'audit_logs.view', + 'audit_logs.export', + ], + 'plans' => [ + 'plans.view', + 'plans.create', + 'plans.edit', + 'plans.delete', + ], + 'coupons' => [ + 'coupons.view', + 'coupons.create', + 'coupons.edit', + 'coupons.delete', + ], + 'credit_notes' => [ + 'credit_notes.view', + 'credit_notes.create', + 'credit_notes.void', + ], + 'debit_notes' => [ + 'debit_notes.view', + 'debit_notes.create', + 'debit_notes.void', + ], + 'affiliates' => [ + 'affiliates.view', + 'affiliates.approve', + 'affiliates.payout', + ], + 'staff' => [ + 'staff.view', + 'staff.manage', + ], + ]; + + public function up(): void + { + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + + // Create all granular permissions (idempotent) + foreach (self::PERMISSION_GROUPS as $permissions) { + foreach ($permissions as $permission) { + Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']); + } + } + + $allPermissions = Permission::where('guard_name', 'web')->pluck('name')->toArray(); + $allGranular = collect(self::PERMISSION_GROUPS)->flatten()->toArray(); + + // super_admin — ALL permissions + $superAdmin = Role::firstOrCreate(['name' => 'super_admin', 'guard_name' => 'web']); + $superAdmin->syncPermissions($allPermissions); + + // admin — ALL permissions (backward compat, interchangeable with super_admin) + $admin = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); + $admin->syncPermissions($allPermissions); + + // billing_admin + $billingAdmin = Role::firstOrCreate(['name' => 'billing_admin', 'guard_name' => 'web']); + $billingAdmin->syncPermissions([ + 'customers.view', 'customers.edit', + 'invoices.view', 'invoices.create', 'invoices.edit', 'invoices.void', 'invoices.refund', + 'services.view', 'services.suspend', + 'reports.view', 'reports.export', + 'credit_notes.view', 'credit_notes.create', 'credit_notes.void', + 'debit_notes.view', 'debit_notes.create', 'debit_notes.void', + 'plans.view', + ]); + + // support_agent + $supportAgent = Role::firstOrCreate(['name' => 'support_agent', 'guard_name' => 'web']); + $supportAgent->syncPermissions([ + 'customers.view', + 'tickets.view', 'tickets.reply', + 'services.view', + ]); + + // support_lead + $supportLead = Role::firstOrCreate(['name' => 'support_lead', 'guard_name' => 'web']); + $supportLead->syncPermissions([ + 'customers.view', 'customers.edit', + 'tickets.view', 'tickets.reply', 'tickets.assign', 'tickets.close', 'tickets.delete', + 'services.view', + ]); + + // readonly_admin — all *.view permissions only + $readonlyAdmin = Role::firstOrCreate(['name' => 'readonly_admin', 'guard_name' => 'web']); + $viewPermissions = collect($allGranular)->filter(fn (string $p): bool => str_ends_with($p, '.view'))->toArray(); + $readonlyAdmin->syncPermissions($viewPermissions); + + // Ensure customer role exists + Role::firstOrCreate(['name' => 'customer', 'guard_name' => 'web']); + + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + } + + public function down(): void + { + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + + // Remove new roles (keep admin and customer) + Role::whereIn('name', ['super_admin', 'billing_admin', 'support_agent', 'support_lead', 'readonly_admin'])->delete(); + + // Remove granular permissions + $allGranular = collect(self::PERMISSION_GROUPS)->flatten()->toArray(); + Permission::whereIn('name', $allGranular)->delete(); + + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + } +}; diff --git a/website/database/migrations/2026_03_17_100001_create_knowledge_base_categories_table.php b/website/database/migrations/2026_03_17_100001_create_knowledge_base_categories_table.php new file mode 100644 index 0000000..58348dd --- /dev/null +++ b/website/database/migrations/2026_03_17_100001_create_knowledge_base_categories_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('parent_id')->nullable()->constrained('knowledge_base_categories')->nullOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('icon')->nullable(); + $table->integer('sort_order')->default(0); + $table->boolean('is_visible')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('knowledge_base_categories'); + } +}; diff --git a/website/database/migrations/2026_03_17_100002_create_knowledge_base_articles_table.php b/website/database/migrations/2026_03_17_100002_create_knowledge_base_articles_table.php new file mode 100644 index 0000000..3f89ed3 --- /dev/null +++ b/website/database/migrations/2026_03_17_100002_create_knowledge_base_articles_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('category_id')->constrained('knowledge_base_categories')->cascadeOnDelete(); + $table->foreignId('author_id')->constrained('users')->cascadeOnDelete(); + $table->string('title'); + $table->string('slug')->unique(); + $table->longText('content'); + $table->text('excerpt')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->boolean('is_featured')->default(false); + $table->unsignedInteger('view_count')->default(0); + $table->unsignedInteger('helpful_count')->default(0); + $table->unsignedInteger('not_helpful_count')->default(0); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + + DB::statement('ALTER TABLE knowledge_base_articles ADD FULLTEXT fulltext_search (title, content)'); + } + + public function down(): void + { + Schema::dropIfExists('knowledge_base_articles'); + } +}; diff --git a/website/database/migrations/2026_03_17_100003_create_article_revisions_table.php b/website/database/migrations/2026_03_17_100003_create_article_revisions_table.php new file mode 100644 index 0000000..61e3ef6 --- /dev/null +++ b/website/database/migrations/2026_03_17_100003_create_article_revisions_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('article_id')->constrained('knowledge_base_articles')->cascadeOnDelete(); + $table->longText('content'); + $table->foreignId('edited_by')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('article_revisions'); + } +}; diff --git a/website/database/migrations/2026_03_17_100004_create_article_votes_table.php b/website/database/migrations/2026_03_17_100004_create_article_votes_table.php new file mode 100644 index 0000000..cfa51c7 --- /dev/null +++ b/website/database/migrations/2026_03_17_100004_create_article_votes_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('article_id')->constrained('knowledge_base_articles')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnDelete(); + $table->boolean('is_helpful'); + $table->string('ip_address', 45); + $table->timestamps(); + + $table->unique(['article_id', 'user_id']); + $table->index(['article_id', 'ip_address']); + }); + } + + public function down(): void + { + Schema::dropIfExists('article_votes'); + } +}; diff --git a/website/database/migrations/2026_03_17_200001_create_departments_table.php b/website/database/migrations/2026_03_17_200001_create_departments_table.php new file mode 100644 index 0000000..a25e4e3 --- /dev/null +++ b/website/database/migrations/2026_03_17_200001_create_departments_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('email')->nullable(); + $table->foreignId('auto_assign_to')->nullable()->constrained('users')->nullOnDelete(); + $table->unsignedBigInteger('sla_policy_id')->nullable(); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('departments'); + } +}; diff --git a/website/database/migrations/2026_03_17_200002_create_sla_policies_table.php b/website/database/migrations/2026_03_17_200002_create_sla_policies_table.php new file mode 100644 index 0000000..431b8e1 --- /dev/null +++ b/website/database/migrations/2026_03_17_200002_create_sla_policies_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('name'); + $table->string('priority'); + $table->integer('first_response_hours'); + $table->integer('resolution_hours'); + $table->boolean('business_hours_only')->default(true); + $table->timestamps(); + }); + + // Add FK constraint to departments now that sla_policies exists + Schema::table('departments', function (Blueprint $table): void { + $table->foreign('sla_policy_id')->references('id')->on('sla_policies')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('departments', function (Blueprint $table): void { + $table->dropForeign(['sla_policy_id']); + }); + + Schema::dropIfExists('sla_policies'); + } +}; diff --git a/website/database/migrations/2026_03_17_200003_create_sla_business_hours_table.php b/website/database/migrations/2026_03_17_200003_create_sla_business_hours_table.php new file mode 100644 index 0000000..f5a846a --- /dev/null +++ b/website/database/migrations/2026_03_17_200003_create_sla_business_hours_table.php @@ -0,0 +1,27 @@ +id(); + $table->tinyInteger('day_of_week'); + $table->time('start_time'); + $table->time('end_time'); + $table->boolean('is_holiday')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sla_business_hours'); + } +}; diff --git a/website/database/migrations/2026_03_17_200004_create_canned_responses_table.php b/website/database/migrations/2026_03_17_200004_create_canned_responses_table.php new file mode 100644 index 0000000..6f67f82 --- /dev/null +++ b/website/database/migrations/2026_03_17_200004_create_canned_responses_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('title'); + $table->text('content'); + $table->string('category')->nullable(); + $table->boolean('is_shared')->default(true); + $table->foreignId('created_by')->constrained('users')->cascadeOnDelete(); + $table->unsignedInteger('usage_count')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('canned_responses'); + } +}; diff --git a/website/database/migrations/2026_03_17_200005_create_ticket_tags_table.php b/website/database/migrations/2026_03_17_200005_create_ticket_tags_table.php new file mode 100644 index 0000000..68b5524 --- /dev/null +++ b/website/database/migrations/2026_03_17_200005_create_ticket_tags_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('color', 7); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_tags'); + } +}; diff --git a/website/database/migrations/2026_03_17_200006_create_support_ticket_tag_pivot.php b/website/database/migrations/2026_03_17_200006_create_support_ticket_tag_pivot.php new file mode 100644 index 0000000..8ae2a2f --- /dev/null +++ b/website/database/migrations/2026_03_17_200006_create_support_ticket_tag_pivot.php @@ -0,0 +1,24 @@ +foreignId('ticket_id')->constrained('support_tickets')->cascadeOnDelete(); + $table->foreignId('tag_id')->constrained('ticket_tags')->cascadeOnDelete(); + $table->primary(['ticket_id', 'tag_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('support_ticket_tag'); + } +}; diff --git a/website/database/migrations/2026_03_17_200007_create_ticket_custom_fields_table.php b/website/database/migrations/2026_03_17_200007_create_ticket_custom_fields_table.php new file mode 100644 index 0000000..3fa25c5 --- /dev/null +++ b/website/database/migrations/2026_03_17_200007_create_ticket_custom_fields_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->enum('type', ['text', 'select', 'checkbox', 'number']); + $table->json('options')->nullable(); + $table->boolean('is_required')->default(false); + $table->foreignId('department_id')->nullable()->constrained('departments')->nullOnDelete(); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_custom_fields'); + } +}; diff --git a/website/database/migrations/2026_03_17_200008_create_ticket_custom_field_values_table.php b/website/database/migrations/2026_03_17_200008_create_ticket_custom_field_values_table.php new file mode 100644 index 0000000..c800ae7 --- /dev/null +++ b/website/database/migrations/2026_03_17_200008_create_ticket_custom_field_values_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('ticket_id')->constrained('support_tickets')->cascadeOnDelete(); + $table->foreignId('custom_field_id')->constrained('ticket_custom_fields')->cascadeOnDelete(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_custom_field_values'); + } +}; diff --git a/website/database/migrations/2026_03_17_200009_create_ticket_satisfaction_ratings_table.php b/website/database/migrations/2026_03_17_200009_create_ticket_satisfaction_ratings_table.php new file mode 100644 index 0000000..9bcd995 --- /dev/null +++ b/website/database/migrations/2026_03_17_200009_create_ticket_satisfaction_ratings_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('ticket_id')->constrained('support_tickets')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->tinyInteger('rating'); + $table->text('comment')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_satisfaction_ratings'); + } +}; diff --git a/website/database/migrations/2026_03_17_200010_add_ticket_enhancements_to_support_tickets.php b/website/database/migrations/2026_03_17_200010_add_ticket_enhancements_to_support_tickets.php new file mode 100644 index 0000000..c6f1607 --- /dev/null +++ b/website/database/migrations/2026_03_17_200010_add_ticket_enhancements_to_support_tickets.php @@ -0,0 +1,48 @@ +foreignId('department_id')->nullable()->after('department')->constrained('departments')->nullOnDelete(); + $table->foreignId('assigned_to')->nullable()->after('department_id')->constrained('users')->nullOnDelete(); + $table->foreignId('sla_policy_id')->nullable()->after('assigned_to')->constrained('sla_policies')->nullOnDelete(); + $table->timestamp('first_response_due_at')->nullable()->after('last_reply_at'); + $table->timestamp('resolution_due_at')->nullable()->after('first_response_due_at'); + $table->timestamp('first_responded_at')->nullable()->after('resolution_due_at'); + $table->timestamp('resolved_at')->nullable()->after('first_responded_at'); + $table->boolean('sla_first_response_breached')->default(false)->after('resolved_at'); + $table->boolean('sla_resolution_breached')->default(false)->after('sla_first_response_breached'); + $table->foreignId('merged_into_ticket_id')->nullable()->after('sla_resolution_breached')->constrained('support_tickets')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('support_tickets', function (Blueprint $table): void { + $table->dropForeign(['department_id']); + $table->dropForeign(['assigned_to']); + $table->dropForeign(['sla_policy_id']); + $table->dropForeign(['merged_into_ticket_id']); + $table->dropColumn([ + 'department_id', + 'assigned_to', + 'sla_policy_id', + 'first_response_due_at', + 'resolution_due_at', + 'first_responded_at', + 'resolved_at', + 'sla_first_response_breached', + 'sla_resolution_breached', + 'merged_into_ticket_id', + ]); + }); + } +}; diff --git a/website/database/migrations/2026_03_17_200011_add_is_internal_to_ticket_replies.php b/website/database/migrations/2026_03_17_200011_add_is_internal_to_ticket_replies.php new file mode 100644 index 0000000..03312cd --- /dev/null +++ b/website/database/migrations/2026_03_17_200011_add_is_internal_to_ticket_replies.php @@ -0,0 +1,24 @@ +boolean('is_internal')->default(false)->after('via_email'); + }); + } + + public function down(): void + { + Schema::table('ticket_replies', function (Blueprint $table): void { + $table->dropColumn('is_internal'); + }); + } +}; diff --git a/website/database/migrations/2026_03_17_200012_migrate_departments_to_table.php b/website/database/migrations/2026_03_17_200012_migrate_departments_to_table.php new file mode 100644 index 0000000..dc9773b --- /dev/null +++ b/website/database/migrations/2026_03_17_200012_migrate_departments_to_table.php @@ -0,0 +1,47 @@ + $department) { + DB::table('departments')->insertOrIgnore([ + 'name' => Str::title($department), + 'slug' => Str::slug($department), + 'sort_order' => $index, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // Update existing support_tickets to set department_id based on department string + $deptRows = DB::table('departments')->get(); + + foreach ($deptRows as $dept) { + DB::table('support_tickets') + ->where('department', $dept->slug) + ->whereNull('department_id') + ->update(['department_id' => $dept->id]); + } + } + + public function down(): void + { + // Keep old department column data intact; just clear the department_id + DB::table('support_tickets')->update(['department_id' => null]); + + $departments = config('tickets.departments', ['billing', 'technical', 'sales', 'general']); + + foreach ($departments as $department) { + DB::table('departments')->where('slug', Str::slug($department))->delete(); + } + } +}; diff --git a/website/database/migrations/2026_03_17_300001_create_currencies_table.php b/website/database/migrations/2026_03_17_300001_create_currencies_table.php new file mode 100644 index 0000000..77120b7 --- /dev/null +++ b/website/database/migrations/2026_03_17_300001_create_currencies_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('code', 3)->unique(); + $table->string('symbol', 5); + $table->string('name'); + $table->tinyInteger('decimal_places')->default(2); + $table->decimal('exchange_rate', 12, 6)->default(1.000000); + $table->boolean('is_base')->default(false); + $table->boolean('is_enabled')->default(true); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('currencies'); + } +}; diff --git a/website/database/migrations/2026_03_17_300002_add_currency_to_users_table.php b/website/database/migrations/2026_03_17_300002_add_currency_to_users_table.php new file mode 100644 index 0000000..92be67e --- /dev/null +++ b/website/database/migrations/2026_03_17_300002_add_currency_to_users_table.php @@ -0,0 +1,24 @@ +string('currency', 3)->default('USD')->after('credit_balance'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('currency'); + }); + } +}; diff --git a/website/database/migrations/2026_03_17_300003_create_cart_items_table.php b/website/database/migrations/2026_03_17_300003_create_cart_items_table.php new file mode 100644 index 0000000..5cebacb --- /dev/null +++ b/website/database/migrations/2026_03_17_300003_create_cart_items_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('session_id')->nullable(); + $table->foreignId('plan_id')->constrained()->cascadeOnDelete(); + $table->string('billing_cycle'); + $table->integer('quantity')->default(1); + $table->json('config_selections')->nullable(); + $table->json('provisioning_config')->nullable(); + $table->string('coupon_code')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + $table->index('session_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cart_items'); + } +}; diff --git a/website/database/migrations/2026_03_17_300004_create_quotes_table.php b/website/database/migrations/2026_03_17_300004_create_quotes_table.php new file mode 100644 index 0000000..c5e0e4f --- /dev/null +++ b/website/database/migrations/2026_03_17_300004_create_quotes_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('prospect_email')->nullable(); + $table->string('prospect_name')->nullable(); + $table->string('number')->unique(); + $table->enum('status', ['draft', 'sent', 'accepted', 'rejected', 'expired'])->default('draft'); + $table->json('items'); + $table->decimal('subtotal', 10, 2); + $table->decimal('tax', 10, 2)->default(0); + $table->decimal('total', 10, 2); + $table->string('currency', 3)->default('USD'); + $table->text('notes')->nullable(); + $table->date('valid_until')->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->foreignId('created_by')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('quotes'); + } +}; diff --git a/website/database/migrations/2026_03_17_500001_create_affiliates_table.php b/website/database/migrations/2026_03_17_500001_create_affiliates_table.php new file mode 100644 index 0000000..526978d --- /dev/null +++ b/website/database/migrations/2026_03_17_500001_create_affiliates_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('referral_code')->unique(); + $table->enum('status', ['pending', 'active', 'suspended'])->default('pending'); + $table->enum('commission_type', ['percentage', 'flat'])->default('percentage'); + $table->decimal('commission_rate', 8, 2)->default(10.00); + $table->boolean('recurring_commissions')->default(false); + $table->decimal('minimum_payout', 8, 2)->default(50.00); + $table->decimal('total_earned', 10, 2)->default(0); + $table->decimal('total_paid', 10, 2)->default(0); + $table->decimal('pending_balance', 10, 2)->default(0); + $table->timestamp('approved_at')->nullable(); + $table->timestamps(); + + $table->index('status'); + $table->index('referral_code'); + }); + } + + public function down(): void + { + Schema::dropIfExists('affiliates'); + } +}; diff --git a/website/database/migrations/2026_03_17_500002_create_affiliate_referrals_table.php b/website/database/migrations/2026_03_17_500002_create_affiliate_referrals_table.php new file mode 100644 index 0000000..5d030e5 --- /dev/null +++ b/website/database/migrations/2026_03_17_500002_create_affiliate_referrals_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('affiliate_id')->constrained()->cascadeOnDelete(); + $table->foreignId('referred_user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('subscription_id')->nullable()->constrained()->nullOnDelete(); + $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending'); + $table->string('signup_ip', 45)->nullable(); + $table->string('referral_url')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->timestamps(); + + $table->index('affiliate_id'); + $table->index('referred_user_id'); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('affiliate_referrals'); + } +}; diff --git a/website/database/migrations/2026_03_17_500003_create_affiliate_commissions_table.php b/website/database/migrations/2026_03_17_500003_create_affiliate_commissions_table.php new file mode 100644 index 0000000..02cdf26 --- /dev/null +++ b/website/database/migrations/2026_03_17_500003_create_affiliate_commissions_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('affiliate_id')->constrained()->cascadeOnDelete(); + $table->foreignId('referral_id')->constrained('affiliate_referrals')->cascadeOnDelete(); + $table->foreignId('payment_transaction_id')->nullable()->constrained('payment_transactions')->nullOnDelete(); + $table->decimal('amount', 10, 2); + $table->string('currency', 3)->default('USD'); + $table->enum('type', ['signup', 'renewal']); + $table->enum('status', ['pending', 'approved', 'paid', 'reversed'])->default('pending'); + $table->timestamp('approved_at')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + + $table->index('affiliate_id'); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('affiliate_commissions'); + } +}; diff --git a/website/database/migrations/2026_03_17_500004_create_affiliate_payouts_table.php b/website/database/migrations/2026_03_17_500004_create_affiliate_payouts_table.php new file mode 100644 index 0000000..985f299 --- /dev/null +++ b/website/database/migrations/2026_03_17_500004_create_affiliate_payouts_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('affiliate_id')->constrained()->cascadeOnDelete(); + $table->decimal('amount', 10, 2); + $table->string('currency', 3)->default('USD'); + $table->enum('method', ['credit', 'paypal', 'bank_transfer']); + $table->enum('status', ['pending', 'processing', 'completed', 'failed'])->default('pending'); + $table->string('reference')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + + $table->index('affiliate_id'); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('affiliate_payouts'); + } +}; diff --git a/website/database/migrations/2026_03_17_500005_create_order_risk_assessments_table.php b/website/database/migrations/2026_03_17_500005_create_order_risk_assessments_table.php new file mode 100644 index 0000000..8461a9f --- /dev/null +++ b/website/database/migrations/2026_03_17_500005_create_order_risk_assessments_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('order_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->tinyInteger('risk_score')->unsigned()->default(0); + $table->enum('risk_level', ['low', 'medium', 'high', 'critical'])->default('low'); + $table->json('checks'); + $table->enum('auto_action', ['approve', 'hold', 'reject'])->default('approve'); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + $table->index('risk_level'); + $table->index('auto_action'); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_risk_assessments'); + } +}; diff --git a/website/database/seeders/ConfigOptionSeeder.php b/website/database/seeders/ConfigOptionSeeder.php index b36fd9b..0f0dfa5 100644 --- a/website/database/seeders/ConfigOptionSeeder.php +++ b/website/database/seeders/ConfigOptionSeeder.php @@ -109,7 +109,7 @@ class ConfigOptionSeeder extends Seeder ); // IPv4 Addresses (quantity) - $ipv4Option = $this->seedQuantityOption($vpsAddons, 'IPv4 Addresses', 1, 8, 'addresses', 3.00, 1); + $ipv4Option = $this->seedQuantityOption($vpsAddons, 'IPv4 Addresses', 1, 8, 'addresses', 8.00, 1); // Windows License (checkbox) $winOption = $this->seedCheckboxOption($vpsAddons, 'Windows License', 2); diff --git a/website/database/seeders/CurrencySeeder.php b/website/database/seeders/CurrencySeeder.php new file mode 100644 index 0000000..72e4dc4 --- /dev/null +++ b/website/database/seeders/CurrencySeeder.php @@ -0,0 +1,29 @@ + 'USD', 'symbol' => '$', 'name' => 'US Dollar', 'decimal_places' => 2, 'exchange_rate' => 1.000000, 'is_base' => true, 'is_enabled' => true], + ['code' => 'EUR', 'symbol' => '€', 'name' => 'Euro', 'decimal_places' => 2, 'exchange_rate' => 0.920000, 'is_base' => false, 'is_enabled' => true], + ['code' => 'GBP', 'symbol' => '£', 'name' => 'British Pound', 'decimal_places' => 2, 'exchange_rate' => 0.790000, 'is_base' => false, 'is_enabled' => true], + ['code' => 'CAD', 'symbol' => 'C$', 'name' => 'Canadian Dollar', 'decimal_places' => 2, 'exchange_rate' => 1.360000, 'is_base' => false, 'is_enabled' => true], + ['code' => 'AUD', 'symbol' => 'A$', 'name' => 'Australian Dollar', 'decimal_places' => 2, 'exchange_rate' => 1.540000, 'is_base' => false, 'is_enabled' => true], + ]; + + foreach ($currencies as $data) { + Currency::query()->updateOrCreate( + ['code' => $data['code']], + $data, + ); + } + } +} diff --git a/website/database/seeders/RoleAndPermissionSeeder.php b/website/database/seeders/RoleAndPermissionSeeder.php index 764754e..03d035f 100644 --- a/website/database/seeders/RoleAndPermissionSeeder.php +++ b/website/database/seeders/RoleAndPermissionSeeder.php @@ -10,27 +10,164 @@ use Spatie\Permission\Models\Role; class RoleAndPermissionSeeder extends Seeder { + /** + * All granular permissions grouped by category. + * + * @var array> + */ + private const PERMISSION_GROUPS = [ + 'customers' => [ + 'customers.view', + 'customers.edit', + 'customers.create', + 'customers.delete', + 'customers.suspend', + 'customers.impersonate', + ], + 'services' => [ + 'services.view', + 'services.suspend', + 'services.terminate', + 'services.provision', + 'services.extend', + ], + 'invoices' => [ + 'invoices.view', + 'invoices.create', + 'invoices.edit', + 'invoices.void', + 'invoices.refund', + ], + 'tickets' => [ + 'tickets.view', + 'tickets.reply', + 'tickets.assign', + 'tickets.close', + 'tickets.delete', + ], + 'reports' => [ + 'reports.view', + 'reports.export', + ], + 'settings' => [ + 'settings.view', + 'settings.edit', + ], + 'audit_logs' => [ + 'audit_logs.view', + 'audit_logs.export', + ], + 'plans' => [ + 'plans.view', + 'plans.create', + 'plans.edit', + 'plans.delete', + ], + 'coupons' => [ + 'coupons.view', + 'coupons.create', + 'coupons.edit', + 'coupons.delete', + ], + 'credit_notes' => [ + 'credit_notes.view', + 'credit_notes.create', + 'credit_notes.void', + ], + 'debit_notes' => [ + 'debit_notes.view', + 'debit_notes.create', + 'debit_notes.void', + ], + 'affiliates' => [ + 'affiliates.view', + 'affiliates.approve', + 'affiliates.payout', + ], + 'staff' => [ + 'staff.view', + 'staff.manage', + ], + ]; + + /** + * Legacy permissions for backward compatibility. + * + * @var list + */ + private const LEGACY_PERMISSIONS = [ + 'manage users', + 'manage services', + 'manage plans', + 'manage invoices', + 'manage coupons', + 'view audit logs', + 'impersonate users', + ]; + public function run(): void { app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); - $permissions = [ - 'manage users', - 'manage services', - 'manage plans', - 'manage invoices', - 'manage coupons', - 'view audit logs', - 'impersonate users', - ]; - - foreach ($permissions as $permission) { - Permission::create(['name' => $permission]); + // Create legacy permissions (idempotent) + foreach (self::LEGACY_PERMISSIONS as $permission) { + Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']); } - $adminRole = Role::create(['name' => 'admin']); - $adminRole->givePermissionTo(Permission::all()); + // Create all granular permissions (idempotent) + foreach (self::PERMISSION_GROUPS as $permissions) { + foreach ($permissions as $permission) { + Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']); + } + } - Role::create(['name' => 'customer']); + $allPermissions = Permission::where('guard_name', 'web')->pluck('name')->toArray(); + + // admin — ALL permissions + $adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); + $adminRole->syncPermissions($allPermissions); + + // customer — no permissions + Role::firstOrCreate(['name' => 'customer', 'guard_name' => 'web']); + + // super_admin — ALL permissions + $superAdmin = Role::firstOrCreate(['name' => 'super_admin', 'guard_name' => 'web']); + $superAdmin->syncPermissions($allPermissions); + + // billing_admin + $billingAdmin = Role::firstOrCreate(['name' => 'billing_admin', 'guard_name' => 'web']); + $billingAdmin->syncPermissions([ + 'customers.view', 'customers.edit', + 'invoices.view', 'invoices.create', 'invoices.edit', 'invoices.void', 'invoices.refund', + 'services.view', 'services.suspend', + 'reports.view', 'reports.export', + 'credit_notes.view', 'credit_notes.create', 'credit_notes.void', + 'debit_notes.view', 'debit_notes.create', 'debit_notes.void', + 'plans.view', + ]); + + // support_agent + $supportAgent = Role::firstOrCreate(['name' => 'support_agent', 'guard_name' => 'web']); + $supportAgent->syncPermissions([ + 'customers.view', + 'tickets.view', 'tickets.reply', + 'services.view', + ]); + + // support_lead + $supportLead = Role::firstOrCreate(['name' => 'support_lead', 'guard_name' => 'web']); + $supportLead->syncPermissions([ + 'customers.view', 'customers.edit', + 'tickets.view', 'tickets.reply', 'tickets.assign', 'tickets.close', 'tickets.delete', + 'services.view', + ]); + + // readonly_admin — all *.view permissions only + $readonlyAdmin = Role::firstOrCreate(['name' => 'readonly_admin', 'guard_name' => 'web']); + $allGranular = collect(self::PERMISSION_GROUPS)->flatten()->toArray(); + $viewPermissions = collect($allGranular)->filter(fn (string $p): bool => str_ends_with($p, '.view'))->toArray(); + $readonlyAdmin->syncPermissions($viewPermissions); + + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); } } diff --git a/website/resources/ts/Components/Panel/ActivityLog.vue b/website/resources/ts/Components/Panel/ActivityLog.vue new file mode 100644 index 0000000..b2b6302 --- /dev/null +++ b/website/resources/ts/Components/Panel/ActivityLog.vue @@ -0,0 +1,86 @@ + + + diff --git a/website/resources/ts/Components/Panel/BandwidthChart.vue b/website/resources/ts/Components/Panel/BandwidthChart.vue new file mode 100644 index 0000000..2f48812 --- /dev/null +++ b/website/resources/ts/Components/Panel/BandwidthChart.vue @@ -0,0 +1,111 @@ + + + diff --git a/website/resources/ts/Components/Panel/ResourceGauge.vue b/website/resources/ts/Components/Panel/ResourceGauge.vue new file mode 100644 index 0000000..6940e71 --- /dev/null +++ b/website/resources/ts/Components/Panel/ResourceGauge.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/website/resources/ts/Components/Panel/ServiceTabLayout.vue b/website/resources/ts/Components/Panel/ServiceTabLayout.vue new file mode 100644 index 0000000..c978a94 --- /dev/null +++ b/website/resources/ts/Components/Panel/ServiceTabLayout.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/website/resources/ts/Components/Panel/WebConsole.vue b/website/resources/ts/Components/Panel/WebConsole.vue new file mode 100644 index 0000000..ea4dfbc --- /dev/null +++ b/website/resources/ts/Components/Panel/WebConsole.vue @@ -0,0 +1,87 @@ + + +