feat: complete pre-launch audit — frontend polish, churn prevention, login history, financial reports, configurable checkout

Includes all work from phases 6-9+ and frontend polish rounds 1 & 2:

- Login history with device trust, new device notifications, session management
- Churn prevention: cancellation surveys, winback campaigns with email sequences
- Financial reports: revenue, P&L, tax, aging, refund, subscription reports with PDF/CSV/JSON export
- Configurable checkout: plan config groups/options, build-your-own VPS
- Frontend polish: fix broken legal links, add SEO meta tags, favicon, font display=swap,
  Head titles on all 14 marketing pages, mobile responsive fixes, AuthLayout legal footer,
  remove false 24/7 claims, hide empty stats, correct uptime SLA to 99.9%,
  GameServers notify buttons linked to /contact, 301 redirects for /terms and /privacy
- WHMCS migration scripts
- Update legal page effective dates to March 16, 2026

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-03-16 11:39:25 -04:00
parent 5be235d35e
commit b4ef90465c
187 changed files with 27317 additions and 1840 deletions

View File

@@ -1,22 +0,0 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(ls:*)",
"WebFetch(domain:lowendbox.com)",
"WebFetch(domain:www.lowendtalk.com)",
"Bash(php artisan tinker:*)",
"WebFetch(domain:www.vultr.com)",
"WebFetch(domain:www.digitalocean.com)",
"WebFetch(domain:www.linode.com)",
"Bash(vendor/bin/pint:*)",
"Bash(php artisan make:request:*)",
"Bash(php artisan make:controller:*)",
"Bash(php artisan make:test:*)",
"Bash(php artisan test:*)",
"Bash(npm run build:*)"
]
},
"outputStyle": "default",
"spinnerTipsEnabled": false
}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.superpowers/
.mcp.json
.claude/settings.local.json
ezscale-discovery-*/

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,306 @@
# VPS Pricing Overhaul — Design Spec
## Overview
Replace the current flat pricing model (one price per plan, monthly only) with a multi-cycle pricing system (1/3/6/12 months), updated VPS plan tiers reflecting actual infrastructure costs ($1,500/mo datacenter), hardware generation (Xeon E5-2680 v2/v4), unmetered bandwidth, and tiered disk I/O limits. Includes migration path for all existing customers and VirtFusion package deprecation.
## Context
### Infrastructure
- 3 hypervisor nodes in Atlanta (E5-2680 v2/v4, 1,158 GB RAM, NFS-backed SATA SSD storage)
- Monthly infrastructure cost: $1,500
- 82 VMs total, ~14 are EZSCALE internal, ~68 customer
- RAM is 89% allocated — the binding constraint
- Storage is NFS-backed SATA SSD (not local NVMe)
### Problems with current pricing
- Plans named inconsistently (Nano/Micro/Mini/Standard/Plus/Pro vs VirtFusion's Micro/Mini/Basic/Standard/Advanced/Pro)
- Only monthly billing — no commitment incentives, higher churn
- Prices too low for actual cost basis ($3.50 Nano loses money at $22/VM cost)
- Stripe only gets one price per plan — multi-cycle discounts calculated client-side only
- IPv4 addon ($3/mo) not charged through Stripe
- Bandwidth caps listed but offering unmetered (AUP)
## New Plan Lineup
### VPS Plans
| Plan Slug | vCPU | RAM | SSD | Bandwidth | IOPS R/W | MB/s R/W |
|-----------|------|-----|-----|-----------|----------|----------|
| vps-1 | 1 | 1 GB | 25 GB | Unmetered | 2,500/2,500 | 40/40 |
| vps-2 | 1 | 2 GB | 50 GB | Unmetered | 3,000/3,000 | 50/50 |
| vps-4 | 2 | 4 GB | 80 GB | Unmetered | 4,000/4,000 | 75/75 |
| vps-8 | 4 | 8 GB | 160 GB | Unmetered | 5,000/5,000 | 100/100 |
| vps-16 | 6 | 16 GB | 320 GB | Unmetered | 6,000/6,000 | 150/150 |
| vps-32 | 8 | 32 GB | 640 GB | Unmetered | 8,000/8,000 | 200/200 |
### Storage VPS Plans
| Plan Slug | vCPU | RAM | SSD | Bandwidth | IOPS R/W | MB/s R/W |
|-----------|------|-----|-----|-----------|----------|----------|
| stor-500 | 2 | 2 GB | 500 GB | Unmetered | 3,000/3,000 | 75/75 |
| stor-1tb | 2 | 4 GB | 1 TB | Unmetered | 4,000/4,000 | 100/100 |
### Pricing (USD)
| Plan | 1-mo | 3-mo (5% off) | 6-mo (10% off) | 12-mo (15% off) |
|------|------|---------------|----------------|-----------------|
| VPS-1 | $5.00 | $14.25 | $27.00 | $51.00 |
| VPS-2 | $8.00 | $22.80 | $43.20 | $81.60 |
| VPS-4 | $15.00 | $42.75 | $81.00 | $153.00 |
| VPS-8 | $30.00 | $85.50 | $162.00 | $306.00 |
| VPS-16 | $55.00 | $156.75 | $297.00 | $561.00 |
| VPS-32 | $99.00 | $282.15 | $534.60 | $1,009.80 |
| STOR-500 | $18.00 | $51.30 | $97.20 | $183.60 |
| STOR-1TB | $28.00 | $79.80 | $151.20 | $285.60 |
### Addons
- Extra IPv4: $3.00/mo per address (billed per cycle with same discount tier)
- /64 IPv6: included with every VPS
### I/O Limits
- Applied as internal VirtFusion package settings only
- NOT advertised on pricing page
- Scale with tier to prevent single-customer abuse of shared NFS storage
## Customer Migration Map
### Package → Plan Mapping
| Old Package (VirtFusion ID) | Old Specs | New Plan | New Specs | Spec Changes |
|-----------------------------|-----------|----------|-----------|--------------|
| Micro (19, 32) | 1 vCPU, 1 GB, 25 GB, 500 GB BW | VPS-1 | 1 vCPU, 1 GB, 25 GB, Unmetered | BW upgrade |
| Mini (20) | 1 vCPU, 2 GB, 50 GB, 4 TB BW | VPS-2 | 1 vCPU, 2 GB, 50 GB, Unmetered | BW upgrade |
| Basic (21) | 2 vCPU, 4 GB, 80 GB, 6 TB BW | VPS-4 | 2 vCPU, 4 GB, 80 GB, Unmetered | BW upgrade |
| Standard (22) | 4 vCPU, 8 GB, 160 GB, 8 TB BW | VPS-8 | 4 vCPU, 8 GB, 160 GB, Unmetered | BW upgrade |
| Advanced (23) | 6 vCPU, 16 GB, 320 GB, 10 TB BW | VPS-16 | 6 vCPU, 16 GB, 320 GB, Unmetered | BW upgrade |
| Pro (24) | 8 vCPU, 32 GB, 640 GB, 16 TB BW | VPS-32 | 8 vCPU, 32 GB, 640 GB, Unmetered | BW upgrade |
| Dev Starter (40) | 2 vCPU, 2 GB, 60 GB, 4 TB BW | VPS-4 | 2 vCPU, 4 GB, 80 GB, Unmetered | RAM +2 GB, disk +20 GB |
| Storage Box (41) | 2 vCPU, 2 GB, 500 GB, 8 TB BW | STOR-500 | 2 vCPU, 2 GB, 500 GB, Unmetered | BW upgrade |
| RAM Optimized (42) | 4 vCPU, 16 GB, 240 GB, 10 TB BW | VPS-16 | 6 vCPU, 16 GB, 320 GB, Unmetered | CPU +2, disk +80 GB |
| VPS-3-Custom (11) | 4 vCPU, 8 GB, 60 GB, 4 TB BW | VPS-8 | 4 vCPU, 8 GB, 160 GB, Unmetered | Disk +100 GB |
| Base Package (43) | 2 vCPU, 1 GB, 10 GB, 200 GB BW | VPS-1 | 1 vCPU, 1 GB, 25 GB, Unmetered | CPU -1, disk +15 GB |
### Deprecation Actions
- All old packages set to status 'archived' in the plans table
- Old VirtFusion packages disabled (not deleted, for audit trail)
- New VirtFusion packages created with new I/O limits
- Existing VMs migrated to new packages in VirtFusion (package swap, no rebuild)
### Special Case: Base Package (ID 43)
Loses 1 vCPU (2→1). Check if any customer on this package needs 2 cores. If so, migrate to VPS-4 instead of VPS-1.
## Database Changes
### New Table: `plan_prices`
```sql
CREATE TABLE plan_prices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
plan_id BIGINT UNSIGNED NOT NULL,
billing_cycle ENUM('monthly', 'quarterly', 'semi_annual', 'annual') NOT NULL,
price DECIMAL(10,2) NOT NULL,
stripe_price_id VARCHAR(255) NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
FOREIGN KEY (plan_id) REFERENCES plans(id) ON DELETE CASCADE,
UNIQUE(plan_id, billing_cycle)
);
```
### Plans Table Changes
- Existing `price` column retained as base monthly price (for display/sorting)
- Existing `billing_cycle` column retained for backward compatibility
- New plans use `plan_prices` for actual billing; `plans.price` = monthly base price
### No Schema Changes Needed
- `subscriptions.billing_cycle` already supports monthly/quarterly/semi_annual/annual
- `subscriptions.provisioning_config` already supports `additional_ipv4` count
- `plans.features` JSON already stores arbitrary plan metadata
## Application Changes
### New: PlanPrice Model
- `belongsTo(Plan)`
- `$fillable = ['plan_id', 'billing_cycle', 'price', 'stripe_price_id']`
- `$casts = ['price' => 'decimal:2']`
### Updated: Plan Model
- Add `prices(): HasMany(PlanPrice)` relationship
- Add `priceForCycle(string $cycle): ?PlanPrice` helper method (queries plan_prices for the given cycle)
- Deprecate direct use of `plans.stripe_price_id` — keep column for backward compat but all new code uses `PlanPrice->stripe_price_id`
### Updated: PlanSeeder
- Create 8 new plans with features JSON:
```json
{
"vcpu": "2",
"ram": "4 GB",
"storage": "80 GB SSD",
"bandwidth": "Unmetered",
"ipv4": "1 Included",
"ipv6": "/64 Included",
"iops_read": 4000,
"iops_write": 4000,
"mbps_read": 75,
"mbps_write": 75,
"virtfusion_package_id": null
}
```
- Create 4 `plan_prices` per plan
- Archive old plans (status → 'archived') but do NOT delete — existing subscriptions still reference them
- Include `migration_map` array mapping old plan slugs to new ones
### Updated: SyncStripePrices Command
- **Fix `semi_annually` → `semi_annual`:** The existing command uses `semi_annually` in two match arms (lines 46, 54). Normalize to `semi_annual` to match the rest of the codebase (CheckoutController, StorePlanRequest, frontend).
- Iterate `plan_prices` rows instead of `plans`
- Create one Stripe Product per Plan
- Create one Stripe Price per PlanPrice row:
- monthly → `interval: 'month', interval_count: 1`
- quarterly → `interval: 'month', interval_count: 3`
- semi_annual → `interval: 'month', interval_count: 6`
- annual → `interval: 'year', interval_count: 1`
- Store `stripe_price_id` on each PlanPrice row
- Create a separate Stripe Product for "Additional IPv4 Address"
- Create 4 Stripe Prices for IPv4 addon (one per cycle, with discount baked in):
- monthly: $3.00/mo
- quarterly: $8.55/3mo ($3 × 3 × 0.95)
- semi_annual: $16.20/6mo ($3 × 6 × 0.90)
- annual: $30.60/yr ($3 × 12 × 0.85)
- Store IPv4 addon Stripe price IDs in config or a dedicated `addon_prices` config array
### Updated: CheckoutController
- Validate `billing_cycle` from request
- Look up `PlanPrice` for selected plan + cycle
- Pass billing cycle to billing service (service resolves price internally)
- Handle IPv4 addon quantity from `configuration.additional_ipv4`
### Updated: BillingServiceInterface
- **No signature change.** The existing `createSubscription(User, Plan, ?paymentMethodId, ?couponCode, billingCycle)` signature already accepts `billingCycle`.
- Services resolve the correct `PlanPrice` internally via `$plan->priceForCycle($billingCycle)->stripe_price_id`
- This keeps the interface stable and avoids breaking PayPal.
### Updated: StripeBillingService
- `createSubscription()`: Use `$plan->priceForCycle($billingCycle)->stripe_price_id` instead of `$plan->stripe_price_id`
- Add IPv4 addon as additional subscription item using Cashier's multi-price API:
```php
$subscription = $user->newSubscription($plan->slug)
->price($planPrice->stripe_price_id)
->price($ipv4PriceId, $additionalIpv4Count) // only if count > 0
->create($paymentMethodId);
```
- `swapSubscription()`: Must also resolve cycle-specific price. Accept billing cycle parameter, use `$newPlan->priceForCycle($billingCycle)->stripe_price_id` for the swap. If customer is swapping plans but keeping the same cycle, pull cycle from the existing subscription record.
- Calculate `current_period_end` based on billing cycle
### Updated: Pricing.vue (Marketing)
- Add billing cycle toggle: Monthly | Quarterly | Semi-Annual | Annual
- Show savings percentage on non-monthly cycles
- Display cycle-specific price from `plan_prices` data (plans eager-loaded with prices)
- Pass selected cycle to checkout URL as query param
- TypeScript interface:
```typescript
interface PlanPrice {
id: number
plan_id: number
billing_cycle: 'monthly' | 'quarterly' | 'semi_annual' | 'annual'
price: string
}
```
- Plan interface updated: `prices: PlanPrice[]`
### Updated: Checkout/Show.vue
- Pre-select billing cycle from URL query param (or default monthly)
- Show cycle-specific price from `plan_prices`
- IPv4 addon total uses cycle-specific IPv4 Stripe price (discount already baked in)
- Display breakdown: plan price + IPv4 addon = total
### Updated: Admin Plans Edit
- Add price fields for all 4 billing cycles
- Save to `plan_prices` table
- Trigger Stripe price sync on save
### Updated: MarketingController
- Eager-load `plan_prices` when passing plans to Pricing.vue:
```php
$plans = Plan::where('status', 'active')
->where('service_type', 'vps')
->with('prices')
->orderBy('sort_order')
->get();
```
## Existing Subscription Handling
### Archived plans remain functional
- Archived plans are hidden from new purchases (Pricing.vue, Checkout) via `status = 'active'` filter
- Archived plans are NOT filtered from subscription lookups, dashboard display, or webhook handlers
- Customer dashboard shows the plan name from whatever plan_id is on their subscription — works for both active and archived plans
- Webhook handlers that look up subscriptions by `stripe_subscription_id` are unaffected (they don't filter by plan status)
### Stripe subscription migration timing
- Existing Stripe subscriptions continue on their current Stripe price until manually migrated
- Migration happens per-customer: either at renewal, or via an admin bulk action
- No automatic migration — this prevents surprise billing changes
### Plan swaps with archived plans
- If a customer on an archived plan wants to upgrade, the swap creates a new subscription on the new plan/cycle price
- `swapSubscription()` always uses the target plan's `PlanPrice`, never the source plan's
## Billing Cycle Naming Fix
**Existing inconsistency:** `SyncStripePrices.php` (lines 46, 54) and `CustomerController.php` (line 307) use `semi_annually`. All other code uses `semi_annual`. Standardize to `semi_annual` everywhere as part of this work.
## Files to Create
- `database/migrations/XXXX_create_plan_prices_table.php`
- `app/Models/PlanPrice.php`
- `app/Console/Commands/MigrateVpsPlans.php` — artisan command to migrate existing subscriptions/services from old plan IDs to new plan IDs
### MigrateVpsPlans Command
- Maps old plan IDs → new plan IDs using the migration map
- Updates `subscriptions.plan_id` for active subscriptions on old plans
- Updates `services.plan_id` if services reference plans
- Logs all changes (old_plan_id → new_plan_id, subscription_id, user_id)
- Idempotent: skips subscriptions already on new plans
- Dry-run mode: `--dry-run` flag to preview changes without applying
- Does NOT change Stripe subscriptions (those migrate separately)
## Files to Modify
- `app/Models/Plan.php` — add prices() relationship, priceForCycle() helper
- `database/seeders/PlanSeeder.php` — new plans + plan_prices + archive old
- `app/Console/Commands/SyncStripePrices.php` — multi-price sync, fix semi_annually→semi_annual
- `app/Http/Controllers/Account/CheckoutController.php` — cycle-aware checkout
- `app/Http/Controllers/Admin/CustomerController.php` — fix semi_annually→semi_annual (line 307)
- `app/Services/Billing/StripeBillingService.php` — cycle-specific price, IPv4 addon, swapSubscription fix
- `app/Services/Billing/PayPalBillingService.php` — cycle-specific plan (if PayPal used)
- `resources/ts/Pages/Marketing/Pricing.vue` — billing cycle toggle
- `resources/ts/Pages/Checkout/Show.vue` — cycle selection, price display
- `resources/ts/Pages/Admin/Plans/Edit.vue` — multi-cycle price editing
- `app/Http/Controllers/Marketing/MarketingController.php` — eager-load plan_prices
- `resources/ts/types/models.ts` — add PlanPrice interface, update Plan interface
- `app/Http/Resources/SubscriptionResource.php` — include billing_cycle and plan pricing
- `app/Http/Resources/ServiceResource.php` — include plan pricing data
## Testing
### New Test Files
- `tests/Feature/PlanPriceTest.php` — PlanPrice model, Plan::priceForCycle(), seeder verification
- `tests/Feature/MultiCycleCheckoutTest.php` — checkout with each billing cycle, IPv4 addon billing
- `tests/Feature/MigrateVpsPlansTest.php` — migration command dry-run and execution
### Test Cases
- PlanPrice model: CRUD, unique constraint on (plan_id, billing_cycle), cascade delete
- Plan::priceForCycle(): returns correct price for each cycle, returns null for invalid cycle
- PlanSeeder: creates all 8 plans with 4 prices each (32 plan_prices rows)
- Checkout: monthly/quarterly/semi_annual/annual each use correct stripe_price_id
- Checkout: IPv4 addon adds correct Stripe line item with quantity
- Checkout: invalid billing cycle returns validation error
- SyncStripePrices: creates 4 Stripe prices per plan, stores IDs
- MigrateVpsPlans: maps old plans to new, skips already-migrated, dry-run works
- swapSubscription: uses cycle-specific price from target plan
## Out of Scope
- VirtFusion API package creation (manual via VirtFusion admin panel)
- Bulk Stripe subscription price migration (per-customer at renewal or future admin tool)
- PayPal multi-cycle support (existing limitation, separate effort)
- Customer communication/email about pricing changes
- Mid-subscription IPv4 quantity changes (add/remove IPs after checkout — future feature)

View File

@@ -0,0 +1,518 @@
# Configurable Options & Build Your Own System
**Date:** 2026-03-16
**Status:** Approved
**Author:** Claude + Andrew
## Overview
Add a configurable options system that serves two purposes:
1. **Preset plan add-ons** — Customers customize orders with dropdowns, radios, quantities (RAM tiers, drive bays, management level)
2. **Build Your Own** — Customers build custom specs from scratch using sliders with per-unit pricing
Both use the same database schema. Build Your Own is a special mode of configurable option groups where options are slider-type with per-unit pricing.
## Service Type Support
| Service Type | Preset Config Options | Build Your Own | Hourly Billing |
|-------------|----------------------|---------------|---------------|
| VPS | Yes (IPv4, Windows, Management, Block Storage) | Yes (CPU/RAM/Disk sliders) | Yes (with monthly cap) |
| Dedicated | Yes (RAM, Drive Bays, NVMe, Network, RAID, Management) | No | No |
| Hosting | No | No | No |
| MySQL | Yes (Region) | Yes (Storage/Connections sliders) | Yes (with monthly cap) |
| Game | No (existing plans) | Yes (RAM/Disk/Slots sliders) | Yes (with monthly cap) |
| Backups | Yes (qty-based: VMs, Agents, Storage) | No | No |
## Database Schema
### plan_config_groups
Stores groups of related configurable options. Groups can be preset (attached to specific plans) or build-your-own (attached to a service type).
```sql
plan_config_groups
id bigint unsigned PK auto_increment
name varchar(255) NOT NULL
description text NULL
mode varchar(20) NOT NULL DEFAULT 'preset' -- 'preset' | 'build_your_own'
service_type varchar(50) NULL -- only for BYO groups
is_active bool NOT NULL DEFAULT true -- enables soft-disabling without deletion
sort_order int NOT NULL DEFAULT 0
created_at timestamp NULL
updated_at timestamp NULL
deleted_at timestamp NULL -- SoftDeletes for archiving
```
### plan_config_group_plan (pivot)
Many-to-many pivot between config groups and plans. Only used for preset groups.
```sql
plan_config_group_plan
plan_config_group_id bigint unsigned FK CASCADE
plan_id bigint unsigned FK CASCADE
UNIQUE(plan_config_group_id, plan_id)
```
### plan_config_options
Individual configurable options within a group.
```sql
plan_config_options
id bigint unsigned PK auto_increment
group_id bigint unsigned FK CASCADE
name varchar(255) NOT NULL
description text NULL
type varchar(50) NOT NULL -- dropdown, radio, quantity, checkbox, text, slider
provisioning_key varchar(100) NULL -- machine-readable key: cpu_cores, ram_gb, disk_gb, etc.
required bool NOT NULL DEFAULT false
is_active bool NOT NULL DEFAULT true -- enables soft-disabling individual options
min_qty int NULL -- for quantity/slider types
max_qty int NULL -- for quantity/slider types
step int NULL DEFAULT 1 -- slider step increment
unit_label varchar(50) NULL -- "cores", "GB", "slots", "addresses"
hourly_price decimal(10,4) NULL -- per-unit hourly price (slider/qty)
monthly_price decimal(10,2) NULL -- per-unit monthly price (slider/qty)
quarterly_price decimal(10,2) NULL
semi_annual_price decimal(10,2) NULL
annual_price decimal(10,2) NULL
sort_order int NOT NULL DEFAULT 0
created_at timestamp NULL
updated_at timestamp NULL
```
**Type behavior:**
- `dropdown` — VSelect, single choice from values list
- `radio` — VRadioGroup, single choice from values list
- `quantity` — VTextField (number), uses per-unit pricing from option row
- `checkbox` — VCheckbox, on/off, price from single value row
- `text` — VTextField, no pricing (e.g., hostname)
- `slider` — VSlider, uses per-unit pricing from option row, respects min/max/step
### plan_config_values
Discrete choices for dropdown/radio/checkbox options. Not used for slider/quantity/text types (those use the per-unit price on the option itself).
```sql
plan_config_values
id bigint unsigned PK auto_increment
option_id bigint unsigned FK CASCADE
label varchar(255) NOT NULL
value varchar(255) NULL -- internal/raw value
hourly_price decimal(10,4) NOT NULL DEFAULT 0
monthly_price decimal(10,2) NOT NULL DEFAULT 0
quarterly_price decimal(10,2) NOT NULL DEFAULT 0
semi_annual_price decimal(10,2) NOT NULL DEFAULT 0
annual_price decimal(10,2) NOT NULL DEFAULT 0
is_default bool NOT NULL DEFAULT false
sort_order int NOT NULL DEFAULT 0
created_at timestamp NULL
updated_at timestamp NULL
```
### subscription_config_selections
What the customer selected, with prices locked at time of purchase.
```sql
subscription_config_selections
id bigint unsigned PK auto_increment
subscription_id bigint unsigned FK CASCADE
option_id bigint unsigned FK RESTRICT -- RESTRICT: cannot delete option with existing selections
value_id bigint unsigned FK SET NULL NULL -- SET NULL: if value deleted, selection preserved
quantity int NULL -- for quantity/slider
text_value varchar(500) NULL -- for text type
locked_price decimal(10,4) NOT NULL -- total price for this selection (4 decimals for hourly precision)
locked_hourly_price decimal(10,4) NULL
billing_cycle varchar(50) NOT NULL
is_custom_build bool NOT NULL DEFAULT false
created_at timestamp NULL
updated_at timestamp NULL
UNIQUE(subscription_id, option_id)
```
### New plans (base $0, internal status for BYO checkout)
BYO custom plans use `status: 'internal'` — a new status value that:
- Is treated as available for checkout (bypasses `isAvailable()` check)
- Is excluded from public plan listings (pricing page, marketing pages)
- Is distinct from 'hidden' (which is for grandfathered legacy plans)
The `Plan::isAvailable()` method is updated: `return in_array($this->status, ['active', 'internal']);`
- `vps-custom` (service_type: vps, status: internal)
- `mysql-custom` (service_type: mysql, status: internal)
- `game-custom` (service_type: game, status: internal)
## Stripe Billing Strategy
### Preset Plans with Config Options
Base plan has a pre-existing Stripe price. Config add-on total is computed at checkout and a **single dynamic Stripe price** is created via `Stripe\Price::create()` for the total amount (base + add-ons). This ensures the Stripe invoice reflects the actual amount charged.
```php
$totalMonthly = $plan->priceForCycle($cycle) + $configTotal;
$stripePrice = \Stripe\Price::create([
'unit_amount' => (int) ($totalMonthly * 100),
'currency' => 'usd',
'recurring' => ['interval' => $stripeInterval],
'product' => $plan->stripe_product_id,
]);
```
### Build Your Own
Same approach — total computed from slider values, single dynamic Stripe price created. The `{type}-custom` plan has a Stripe product but no pre-set price.
### Price Changes / Upgrades (Future)
When a customer upgrades config options on an existing subscription, a new Stripe price is created and the subscription is swapped to it. Deferred to v2.
## Coupon Application
Coupons apply to the **total computed price** (base plan + all config selections), not just the base price. For BYO where base is $0, the coupon applies to the config total. The coupon discount is applied before creating the Stripe price:
```php
$totalBeforeCoupon = $basePlanPrice + $configTotal;
$discount = $coupon->calculateDiscount($totalBeforeCoupon);
$finalPrice = $totalBeforeCoupon - $discount;
```
## Models
### PlanConfigGroup
- `use SoftDeletes`
- `hasMany(PlanConfigOption::class, 'group_id')`
- `belongsToMany(Plan::class, 'plan_config_group_plan')`
- Scopes: `preset()`, `buildYourOwn()`, `forServiceType($type)`, `active()`
### PlanConfigOption
- `belongsTo(PlanConfigGroup::class, 'group_id')`
- `hasMany(PlanConfigValue::class, 'option_id')`
- Scope: `active()`
- Methods: `isSlider()`, `isQuantity()`, `isDropdown()`, `isRadio()`, `isCheckbox()`, `isText()`
- Method: `calculatePrice(int $quantity, string $cycle): float`
- Method: `getHourlyPrice(int $quantity): float`
### PlanConfigValue
- `belongsTo(PlanConfigOption::class, 'option_id')`
- Method: `getPriceForCycle(string $cycle): float`
### SubscriptionConfigSelection
- `belongsTo(Subscription::class)`
- `belongsTo(PlanConfigOption::class, 'option_id')`
- `belongsTo(PlanConfigValue::class, 'value_id')`
### Plan (updated)
- Add: `configGroups(): BelongsToMany`
- Update: `isAvailable()` returns true for `'active'` and `'internal'` status
- Update: `scopePublic()` scope excludes `'internal'` and `'hidden'` plans from public listings
### Subscription (updated)
- Add: `configSelections(): HasMany`
- Add: `totalConfigPrice(string $cycle): float`
- Add: `totalHourlyPrice(): float`
### TypeScript Interfaces (types/index.ts)
```typescript
export interface PlanConfigGroup {
id: number
name: string
description: string | null
mode: 'preset' | 'build_your_own'
service_type: string | null
is_active: boolean
sort_order: number
options: PlanConfigOption[]
plans?: Plan[]
}
export interface PlanConfigOption {
id: number
group_id: number
name: string
description: string | null
type: 'dropdown' | 'radio' | 'quantity' | 'checkbox' | 'text' | 'slider'
provisioning_key: string | null
required: boolean
is_active: boolean
min_qty: number | null
max_qty: number | null
step: number | null
unit_label: string | null
hourly_price: number | null
monthly_price: number | null
quarterly_price: number | null
semi_annual_price: number | null
annual_price: number | null
sort_order: number
values: PlanConfigValue[]
}
export interface PlanConfigValue {
id: number
option_id: number
label: string
value: string | null
hourly_price: number
monthly_price: number
quarterly_price: number
semi_annual_price: number
annual_price: number
is_default: boolean
sort_order: number
}
export interface SubscriptionConfigSelection {
id: number
subscription_id: number
option_id: number
value_id: number | null
quantity: number | null
text_value: string | null
locked_price: number
locked_hourly_price: number | null
billing_cycle: string
is_custom_build: boolean
option?: PlanConfigOption
value?: PlanConfigValue
}
```
## Admin UI
### Pages
- `Admin/ConfigGroups/Index.vue` — List all groups with VDataTable, filter tabs (All/Preset/BYO)
- `Admin/ConfigGroups/Create.vue` — Create form with inline options + values builder
- `Admin/ConfigGroups/Edit.vue` — Edit form, same structure as create
### Admin Controller: ConfigGroupController
- `index()` — paginated list with option/plan counts
- `create()` — form with available plans
- `store(StoreConfigGroupRequest)` — create group + options + values in transaction
- `edit(PlanConfigGroup)` — form pre-populated
- `update(UpdateConfigGroupRequest, PlanConfigGroup)` — sync options/values
- `destroy(PlanConfigGroup)` — delete (soft-block if selections exist)
### Form Request: StoreConfigGroupRequest
Validates nested structure:
- name, description, mode, service_type
- options[].name, options[].type, options[].required, options[].min_qty, options[].max_qty, options[].step
- options[].values[].label, options[].values[].monthly_price, etc.
### Admin Navigation
Under Infrastructure: `{ title: 'Config Options', to: '/config-groups', icon: 'tabler-adjustments' }`
### Admin Routes
```
GET /config-groups → ConfigGroupController@index
GET /config-groups/create → ConfigGroupController@create
POST /config-groups → ConfigGroupController@store
GET /config-groups/{id}/edit → ConfigGroupController@edit
PUT /config-groups/{id} → ConfigGroupController@update
DELETE /config-groups/{id} → ConfigGroupController@destroy
```
## Checkout Flow
### Path 1: Preset Plan (enhanced existing flow)
```
Pricing Page → Pick Plan → /checkout/{plan_id}?cycle=monthly
```
`Checkout/Show.vue` enhanced:
- After billing cycle selection, loads configurable option groups for the plan
- Renders options by type (VSelect, VRadioGroup, VSlider, VCheckbox, VTextField)
- Price summary updates live: base + config selections
- Existing VPS provisioning config (OS, SSH key) remains separate
### Path 2: Build Your Own (new)
```
Pricing Page → Toggle "Build Your Own" → Configure sliders → Deploy Now
→ /checkout/custom/{serviceType}?config={encoded_selections}
```
New route: `GET /checkout/custom/{serviceType}` renders Checkout/Show.vue in custom mode.
### Checkout/Show.vue Modes
- `mode: 'preset'` — plan card + optional config options
- `mode: 'custom'` — slider configurator as main content, no plan card
### Price Summary Sidebar (both modes)
```
Order Summary
─────────────
Base plan: $30.00 (or $0 for BYO)
RAM: 64 GB +$15.00
NVMe: 2x 1TB +$30.00
Management: Semi +$25.00
──────────────────────
Monthly: $100.00
Hourly: $0.137/hr
Monthly cap: $100.00
Billing: [Monthly ▼]
[ Deploy Now ]
```
### CheckoutController Changes
**show(Plan $plan)** — enhanced to load config groups:
```php
$configGroups = $plan->configGroups()->with('options.values')->get();
// Pass to Inertia as 'configGroups' prop
```
**showCustom(string $serviceType)** — new method for BYO:
```php
$configGroup = PlanConfigGroup::buildYourOwn()->forServiceType($serviceType)->first();
$plan = Plan::where('slug', "{$serviceType}-custom")->firstOrFail();
// Render same checkout component in 'custom' mode
```
**store(Request $request, Plan $plan)** — enhanced to validate and save selections:
```php
// Validate config selections against option types/ranges
// Calculate and lock prices at time of purchase
// Create subscription
// Create subscription_config_selections rows
// Dispatch SubscriptionCreated event
```
### Validation Rules for Config Selections
- Dropdown/radio: value_id must belong to the option
- Quantity/slider: quantity must be between min_qty and max_qty, divisible by step
- Checkbox: value_id present (checked) or null (unchecked)
- Text: string, max 500 chars
- Required options must have a selection
## Pricing Page Changes
`Marketing/Pricing.vue` enhanced:
- Segmented toggle at top: **Preset Plans | Build Your Own**
- Service type tabs work in both modes
- **In BYO mode:** Only tabs for supported service types are shown (VPS, MySQL, Game). If user is on a non-BYO tab (e.g., Dedicated) and toggles to BYO, auto-switch to the first supported tab (VPS).
- Preset mode: existing plan cards (unchanged)
- Build Your Own mode: vertical slider configurator (Layout A — sliders left, price summary right)
- Billing cycle switcher works in both modes (hourly shown only in BYO mode)
- "Deploy Now" in BYO routes to `/checkout/custom/{serviceType}`
## Build Your Own Configurator UI
Layout A: Vertical Sliders + Side Summary
**Left panel (sliders):**
- Each resource as a labeled VSlider with current value display
- Min/max labels at slider ends
- Per-unit price shown next to current value
- Sliders use EZSCALE navy blue (#1d4ed8) accent
**Right panel (sticky summary):**
- Hourly rate (large)
- Monthly cap
- Line-item breakdown (CPU: $X, RAM: $X, Disk: $X)
- Billing cycle selector
- "Deploy Now" CTA button
## Build Your Own Resource Configuration
### VPS
| Resource | Min | Max | Step | Unit | Hourly/unit | Monthly/unit |
|----------|-----|-----|------|------|-------------|-------------|
| CPU Cores | 1 | 16 | 1 | cores | $0.003 | $2.00 |
| RAM | 1 | 64 | 1 | GB | $0.0015 | $1.00 |
| SSD Storage | 25 | 1000 | 25 | GB | $0.0001 | $0.05 |
| Bandwidth | — | — | — | — | — | Included |
### MySQL
| Resource | Min | Max | Step | Unit | Hourly/unit | Monthly/unit |
|----------|-----|-----|------|------|-------------|-------------|
| Storage | 5 | 500 | 5 | GB | $0.0003 | $0.20 |
| Max Connections | 50 | 1000 | 50 | conns | $0.0001 | $0.05 |
| Daily Backups | 0 | 1 | 1 | toggle | — | $2.00 |
### Game Servers
| Resource | Min | Max | Step | Unit | Hourly/unit | Monthly/unit |
|----------|-----|-----|------|------|-------------|-------------|
| RAM | 1 | 16 | 1 | GB | $0.002 | $1.50 |
| Storage | 10 | 200 | 10 | GB | $0.0001 | $0.08 |
| Player Slots | 10 | 200 | 10 | slots | $0.0001 | $0.05 |
*Note: Per-unit prices are initial values. Adjusted via admin UI without code changes.*
## Hourly Billing
### Display Model (MVP)
- Hourly rate calculated and displayed to customer: `sum(unit_hourly_price × quantity)`
- Monthly cap calculated: `sum(unit_monthly_price × quantity)`
- Customer billed at monthly cap on their billing cycle
- Actual hourly metering deferred to Phase 2
### Future: Real Hourly Metering (Phase 2)
- `usage_records` table tracks server uptime hours
- Cron job calculates charges: `min(hourly_rate × hours, monthly_cap)`
- Prepaid credit system for hourly customers
- Server auto-suspend when credits depleted
## Provisioning Integration
### Preset Plans
Existing provisioning flow unchanged. Config selections stored but provisioning uses the plan's package_id.
### Build Your Own VPS
VirtFusion's `servers_create` API accepts raw resource parameters alongside a packageId. For BYO:
1. Use a designated BYO package in VirtFusion as a base template (handles network profile, storage profile, etc.)
2. Override resource params from slider selections using `provisioning_key` mapping:
- `cpuCores` ← option where `provisioning_key = 'cpu_cores'`
- `memory` ← option where `provisioning_key = 'ram_gb'` × 1024 (MB)
- `storage` ← option where `provisioning_key = 'disk_gb'`
3. The `provisioning_key` field on `plan_config_options` makes this mapping explicit and admin-configurable
**Note:** If VirtFusion requires a package for server creation, create a minimal "BYO Base" package in VirtFusion with minimum resources and override via the API params. Test this approach before implementation — if VirtFusion doesn't support resource overrides on `servers_create`, fall back to creating dynamic packages per BYO order via the packages API.
### Build Your Own MySQL
Manual provisioning for MVP. The subscription is created with config selections stored, and an admin notification is sent for manual setup. Automated provisioning deferred until a MySQL provisioning API is available.
### Build Your Own Game
Pterodactyl API supports resource allocation on server creation (`memory`, `disk`, `cpu`). Map from `provisioning_key` values on the slider options, similar to VPS approach.
## Migration Script Integration
### Phase 2 Enhancement
Import WHMCS configurable option groups, options, and values:
- Map WHMCS config group IDs to EZSCALE config group IDs
- Create plan_config_groups with mode='preset'
- Attach to mapped plans via pivot
- Import all options and values with pricing
### Phase 3 Enhancement
Import customer config selections:
- For each WHMCS service with configoptions, create subscription_config_selections
- Lock prices from WHMCS recurring amounts
## Testing
### Unit Tests
- PlanConfigOption::calculatePrice() with various types
- PlanConfigOption::getHourlyPrice() calculation
- Subscription::totalConfigPrice() aggregation
- Subscription::totalHourlyPrice() aggregation
### Feature Tests
- Admin CRUD for config groups (create/read/update/delete)
- Nested option/value creation and validation
- Preset checkout with config options selected
- BYO checkout with slider values
- Price calculation matches expected totals
- Config selections locked at purchase price
- Required option validation
- Slider min/max/step validation
- Customer cannot access admin config pages
## Out of Scope (Future)
- Real-time hourly billing with usage metering (Phase 2)
- Config option dependencies (e.g., NVMe requires RAID H730)
- Config option stock/inventory limits
- Upgrade/downgrade config options on existing subscriptions
- Config option comparison across plans

View File

@@ -0,0 +1,15 @@
WHMCS_API_URL=https://account.ezscale.cloud/includes/api.php
WHMCS_API_IDENTIFIER=
WHMCS_API_SECRET=
EZSCALE_DB_HOST=127.0.0.1
EZSCALE_DB_PORT=3306
EZSCALE_DB_DATABASE=ezscale_billing
EZSCALE_DB_USERNAME=ezscale
EZSCALE_DB_PASSWORD=
LARAVEL_APP_KEY=
DRY_RUN=false
BATCH_SIZE=250
LOG_LEVEL=info

6
scripts/whmcs-migrate/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/vendor/
.env
state/
logs/
exports/
composer.lock

View File

@@ -0,0 +1,14 @@
{
"name": "ezscale/whmcs-migrate",
"description": "WHMCS to EZSCALE migration script",
"require": {
"php": "^8.3",
"vlucas/phpdotenv": "^5.6",
"monolog/monolog": "^3.0"
},
"autoload": {
"psr-4": {
"WhmcsMigrate\\": "src/"
}
}
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
/**
* WHMCS to EZSCALE Migration Script
*
* Usage:
* php migrate.php Run all phases (fetches from WHMCS API)
* php migrate.php --phase=1 Run only phase 1
* php migrate.php --dry-run Run without writing to the database
* php migrate.php --export-only Export all WHMCS data to JSONL files (no DB writes)
* php migrate.php --from-export Import from previously exported JSONL files (no API calls)
* php migrate.php --status Show current migration progress
* php migrate.php --export-status Show exported JSONL file summary
* php migrate.php --validate-only Test connectivity without migrating
* php migrate.php --reset Reset all migration state
* php migrate.php --reset-exports Delete all exported JSONL files
* php migrate.php --help Show this help message
*/
require_once __DIR__ . '/vendor/autoload.php';
use WhmcsMigrate\MigrationRunner;
// ---------------------------------------------------------------------------
// Banner
// ---------------------------------------------------------------------------
const VERSION = '2.0.0';
$banner = <<<BANNER
╔═══════════════════════════════════════════════════╗
║ WHMCS → EZSCALE Migration Tool v%s ║
║ %s ║
╚═══════════════════════════════════════════════════╝
BANNER;
fprintf(STDOUT, $banner, VERSION, date('Y-m-d H:i:s'));
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
$options = getopt('', [
'dry-run',
'export-only',
'from-export',
'phase:',
'reset',
'reset-exports',
'status',
'export-status',
'validate-only',
'help',
]);
if (isset($options['help'])) {
printUsage();
exit(0);
}
// ---------------------------------------------------------------------------
// Resolve base path (directory containing this script)
// ---------------------------------------------------------------------------
$basePath = __DIR__;
// ---------------------------------------------------------------------------
// Apply CLI overrides before constructing the runner
// ---------------------------------------------------------------------------
if (isset($options['dry-run'])) {
putenv('DRY_RUN=true');
$_ENV['DRY_RUN'] = 'true';
$_SERVER['DRY_RUN'] = 'true';
}
if (isset($options['export-only'])) {
putenv('EXPORT_ONLY=true');
$_ENV['EXPORT_ONLY'] = 'true';
$_SERVER['EXPORT_ONLY'] = 'true';
}
if (isset($options['from-export'])) {
putenv('FROM_EXPORT=true');
$_ENV['FROM_EXPORT'] = 'true';
$_SERVER['FROM_EXPORT'] = 'true';
}
// ---------------------------------------------------------------------------
// Execute
// ---------------------------------------------------------------------------
try {
$runner = new MigrationRunner($basePath);
if (isset($options['reset'])) {
$runner->reset();
exit(0);
}
if (isset($options['reset-exports'])) {
$runner->resetExports();
exit(0);
}
if (isset($options['status'])) {
$runner->showStatus();
exit(0);
}
if (isset($options['export-status'])) {
$runner->showExportSummary();
exit(0);
}
if (isset($options['validate-only'])) {
$runner->validateOnly();
exit(0);
}
$phaseNumber = isset($options['phase']) ? (int) $options['phase'] : null;
$runner->run($phaseNumber);
exit(0);
} catch (Throwable $e) {
fprintf(STDERR, "\nFATAL ERROR: %s\n", $e->getMessage());
fprintf(STDERR, " File: %s:%d\n", $e->getFile(), $e->getLine());
fprintf(STDERR, " Trace:\n%s\n", $e->getTraceAsString());
exit(1);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function printUsage(): void
{
$usage = <<<USAGE
Usage: php migrate.php [OPTIONS]
Options:
--dry-run Run the migration without writing to the database.
All operations are logged but no INSERT/UPDATE queries
are executed.
--export-only Export all WHMCS data to JSONL files without touching
the database. Each entity type gets its own .jsonl file
in the exports/ directory. This is fast for re-imports.
--from-export Import from previously exported JSONL files instead of
hitting the WHMCS API. Much faster for subsequent runs
since there are no API calls or rate-limit waits.
Requires --export-only to have been run first.
--phase=N Run only the specified phase number (1-7).
Phases:
1 Clients → Users + UserProfiles
2 Products → Plans + PlanPrices
3 Services → Subscriptions + Services
4 Invoices → Invoices + InvoiceItems
5 Transactions → PaymentTransactions
6 Promotions → Coupons
7 Orders → Orders
--status Display the current progress of all phases and exit.
--export-status Display a summary of all exported JSONL files.
--validate-only Test WHMCS API and EZSCALE database connectivity
without performing any migration.
--reset Delete all migration state (ID mappings and progress)
so the migration can be re-run from scratch.
--reset-exports Delete all exported JSONL files from the exports/
directory.
--help Show this help message and exit.
Configuration:
Copy .env.example to .env and fill in:
- WHMCS API credentials (WHMCS_API_URL, WHMCS_API_IDENTIFIER, WHMCS_API_SECRET)
- EZSCALE database credentials (EZSCALE_DB_*)
- Laravel APP_KEY (LARAVEL_APP_KEY) for encrypting sensitive data
- BATCH_SIZE (default: 250, WHMCS API supports up to 250)
Optional plan mapping:
Edit plan_mapping.json to map WHMCS product IDs to existing EZSCALE
plan slugs. Unmapped products will be auto-created as new plans.
Workflow:
1. Export all data: php migrate.php --export-only
2. Review exports: php migrate.php --export-status
3. Test import: php migrate.php --from-export --dry-run
4. Run import: php migrate.php --from-export
5. Check status: php migrate.php --status
For a direct migration (no export step):
php migrate.php
State:
Migration progress and ID mappings are saved to the state/ directory.
If the migration is interrupted, re-running will resume from the last
checkpoint. Use --reset to clear all state.
Exports:
JSONL files are saved to the exports/ directory. Each line is one JSON
record. Use --reset-exports to delete all export files.
Logs:
Detailed logs are written to logs/migration.log.
USAGE;
echo $usage;
}

View File

@@ -0,0 +1,46 @@
{
"_comment": "WHMCS product ID → EZSCALE plan slug. Products not listed here will be SKIPPED (not auto-created).",
"mappings": {
"194": "vps-1",
"195": "vps-2",
"230": "vps-4",
"196": "vps-4",
"231": "stor-500",
"197": "vps-8",
"232": "vps-16",
"198": "vps-16",
"199": "vps-32",
"161": "vps-3-custom",
"201": "dell-r330-lff",
"117": "dell-r420-lff",
"110": "dell-r620-sff-10-bay",
"143": "dell-r620-sff-8-bay",
"125": "dell-r520-lff",
"104": "dell-r430-lff",
"209": "dell-r630-sff",
"109": "dell-r730-lff",
"203": "stor-36bay",
"70": "hosting-small",
"96": "hosting-medium",
"97": "hosting-large",
"106": "hosting-dedicated",
"12": "mysql-bronze",
"13": "mysql-silver",
"14": "mysql-gold",
"15": "mysql-platinum",
"1": "procon-layer",
"170": "veeam-enterprise-plus",
"171": "veeam-standard",
"172": "veeam-cloud-connect"
},
"skip": [
"3",
"37",
"62",
"147", "148", "149", "150", "151", "152", "153", "154", "155", "156", "157",
"173", "174", "175", "176", "177", "178", "179",
"180", "159", "111",
"213",
"224", "225", "226", "227", "228", "229"
]
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
use Dotenv\Dotenv;
use RuntimeException;
final class Config
{
private array $overrides = [];
public function __construct(string $basePath)
{
$dotenv = Dotenv::createImmutable($basePath);
$dotenv->safeLoad();
}
/**
* Set a runtime override (e.g., from CLI flags).
*/
public function override(string $key, mixed $value): void
{
$this->overrides[$key] = $value;
}
/**
* Get a config value with optional default.
*/
public function get(string $key, mixed $default = null): mixed
{
if (array_key_exists($key, $this->overrides)) {
return $this->overrides[$key];
}
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
if ($value === false) {
return $default;
}
return $value;
}
/**
* Get a required config value. Throws if missing.
*/
public function getRequired(string $key): string
{
$value = $this->get($key);
if ($value === null || $value === '') {
throw new RuntimeException("Required configuration key '{$key}' is not set.");
}
return (string) $value;
}
/**
* Whether the migration is running in dry-run mode.
*/
public function isDryRun(): bool
{
$value = $this->get('DRY_RUN', 'false');
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
/**
* Number of records to process per batch.
*
* WHMCS API supports limitnum up to 250. Default raised from 100 to 250.
*/
public function getBatchSize(): int
{
return (int) $this->get('BATCH_SIZE', 250);
}
/**
* Whether the migration is in export-only mode (fetch from API and save to JSONL files).
*/
public function isExportOnly(): bool
{
return filter_var($this->get('EXPORT_ONLY', 'false'), FILTER_VALIDATE_BOOLEAN);
}
/**
* Whether the migration should read from exported JSONL files instead of the API.
*/
public function isFromExport(): bool
{
return filter_var($this->get('FROM_EXPORT', 'false'), FILTER_VALIDATE_BOOLEAN);
}
/**
* Monolog log level string.
*/
public function getLogLevel(): string
{
return (string) $this->get('LOG_LEVEL', 'info');
}
/**
* Get the Laravel APP_KEY as raw binary (decoded from base64).
*/
public function getLaravelAppKey(): string
{
$key = $this->getRequired('LARAVEL_APP_KEY');
// Strip the 'base64:' prefix if present
if (str_starts_with($key, 'base64:')) {
$key = substr($key, 7);
}
$decoded = base64_decode($key, strict: true);
if ($decoded === false) {
throw new RuntimeException('LARAVEL_APP_KEY is not valid base64.');
}
return $decoded;
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
use PDO;
use PDOStatement;
final class Database
{
private PDO $pdo;
public function __construct(Config $config)
{
$host = $config->getRequired('EZSCALE_DB_HOST');
$port = $config->get('EZSCALE_DB_PORT', '3306');
$database = $config->getRequired('EZSCALE_DB_DATABASE');
$username = $config->getRequired('EZSCALE_DB_USERNAME');
$password = $config->get('EZSCALE_DB_PASSWORD', '');
$dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4";
$this->pdo = new PDO($dsn, $username, (string) $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
]);
}
/**
* Get the underlying PDO instance.
*/
public function getPdo(): PDO
{
return $this->pdo;
}
/**
* Insert a row and return the last insert ID.
*/
public function insert(string $table, array $data): int
{
$data = $this->ensureTimestamps($data);
$columns = implode(', ', array_map(fn (string $col): string => "`{$col}`", array_keys($data)));
$placeholders = implode(', ', array_map(fn (string $col): string => ":{$col}", array_keys($data)));
$sql = "INSERT INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
/**
* Insert a row using INSERT IGNORE. Returns last insert ID (0 if ignored).
*/
public function insertIgnore(string $table, array $data): int
{
$data = $this->ensureTimestamps($data);
$columns = implode(', ', array_map(fn (string $col): string => "`{$col}`", array_keys($data)));
$placeholders = implode(', ', array_map(fn (string $col): string => ":{$col}", array_keys($data)));
$sql = "INSERT IGNORE INTO `{$table}` ({$columns}) VALUES ({$placeholders})";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
/**
* Update rows matching the where clause. Returns affected row count.
*/
public function update(string $table, array $data, array $where): int
{
$setParts = [];
$params = [];
foreach ($data as $column => $value) {
$paramKey = "set_{$column}";
$setParts[] = "`{$column}` = :{$paramKey}";
$params[$paramKey] = $value;
}
$whereParts = [];
foreach ($where as $column => $value) {
$paramKey = "where_{$column}";
$whereParts[] = "`{$column}` = :{$paramKey}";
$params[$paramKey] = $value;
}
$sql = sprintf(
'UPDATE `%s` SET %s WHERE %s',
$table,
implode(', ', $setParts),
implode(' AND ', $whereParts),
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
/**
* Execute a SELECT query and return all rows.
*/
public function query(string $sql, array $params = []): array
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Execute a SELECT query and return a single row or null.
*/
public function queryOne(string $sql, array $params = []): ?array
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return $row !== false ? $row : null;
}
/**
* Execute a raw SQL statement (UPDATE, DELETE, etc.) and return affected rows.
*/
public function execute(string $sql, array $params = []): int
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
public function beginTransaction(): void
{
$this->pdo->beginTransaction();
}
public function commit(): void
{
$this->pdo->commit();
}
public function rollback(): void
{
$this->pdo->rollBack();
}
/**
* Check if a row exists matching the given conditions.
*/
public function tableHasRow(string $table, array $where): bool
{
$whereParts = [];
$params = [];
foreach ($where as $column => $value) {
$whereParts[] = "`{$column}` = :{$column}";
$params[$column] = $value;
}
$sql = sprintf(
'SELECT 1 FROM `%s` WHERE %s LIMIT 1',
$table,
implode(' AND ', $whereParts),
);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetch() !== false;
}
/**
* Ensure created_at and updated_at are set if not already present.
*/
private function ensureTimestamps(array $data): array
{
$now = date('Y-m-d H:i:s');
if (! array_key_exists('created_at', $data)) {
$data['created_at'] = $now;
}
if (! array_key_exists('updated_at', $data)) {
$data['updated_at'] = $now;
}
return $data;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
use RuntimeException;
final class Encryptor
{
private const string CIPHER = 'aes-256-cbc';
private const int IV_LENGTH = 16;
public function __construct(
private readonly string $key,
) {
if (strlen($this->key) !== 32) {
throw new RuntimeException(
'Encryption key must be exactly 32 bytes for AES-256-CBC. Got ' . strlen($this->key) . ' bytes.',
);
}
}
/**
* Encrypt a value using Laravel-compatible AES-256-CBC encryption.
*
* The output format matches Laravel's Illuminate\Encryption\Encrypter exactly:
* base64(json({ iv: base64, value: base64(encrypted), mac: hmac_hex, tag: '' }))
*/
public function encrypt(string $value): string
{
$iv = random_bytes(self::IV_LENGTH);
// openssl_encrypt with default options (no OPENSSL_RAW_DATA flag) returns base64-encoded ciphertext
$encrypted = openssl_encrypt($value, self::CIPHER, $this->key, 0, $iv);
if ($encrypted === false) {
throw new RuntimeException('Encryption failed: ' . openssl_error_string());
}
$ivBase64 = base64_encode($iv);
// HMAC is computed over the base64-encoded IV concatenated with the base64-encoded ciphertext
$mac = hash_hmac('sha256', $ivBase64 . $encrypted, $this->key);
$payload = json_encode([
'iv' => $ivBase64,
'value' => $encrypted,
'mac' => $mac,
'tag' => '',
], JSON_THROW_ON_ERROR);
return base64_encode($payload);
}
/**
* Encrypt an array by JSON-encoding it first.
*/
public function encryptArray(array $data): string
{
return $this->encrypt(json_encode($data, JSON_THROW_ON_ERROR));
}
}

View File

@@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
use RuntimeException;
/**
* Manages JSONL export files for offline/fast re-imports.
*
* Each entity type (clients, products, invoices, etc.) gets its own JSONL file.
* Each line is a self-contained JSON object representing one API response batch.
*/
final class ExportManager
{
private string $exportDir;
/** @var array<string, resource> Open file handles for writing */
private array $writeHandles = [];
/** @var array<string, resource> Open file handles for reading */
private array $readHandles = [];
/** @var array<string, int> Number of records written per entity */
private array $writeCounts = [];
public function __construct(string $basePath)
{
$this->exportDir = $basePath . '/exports';
if (! is_dir($this->exportDir)) {
mkdir($this->exportDir, 0755, true);
}
}
/**
* Get the export directory path.
*/
public function getExportDir(): string
{
return $this->exportDir;
}
/**
* Write a single record to a JSONL export file.
*
* @param string $entity Entity type (e.g., 'clients', 'products', 'invoices')
* @param array<string, mixed> $record The record data to write
*/
public function writeRecord(string $entity, array $record): void
{
$handle = $this->getWriteHandle($entity);
$line = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) . "\n";
$written = fwrite($handle, $line);
if ($written === false) {
throw new RuntimeException("Failed to write to export file for entity '{$entity}'");
}
$this->writeCounts[$entity] = ($this->writeCounts[$entity] ?? 0) + 1;
}
/**
* Write a batch of records to a JSONL export file.
*
* @param string $entity Entity type
* @param array<int, array<string, mixed>> $records The records to write
*/
public function writeBatch(string $entity, array $records): void
{
foreach ($records as $record) {
$this->writeRecord($entity, $record);
}
}
/**
* Read all records from a JSONL export file as a generator.
*
* This uses a generator to avoid loading the entire file into memory.
*
* @param string $entity Entity type
* @return \Generator<int, array<string, mixed>>
*/
public function readRecords(string $entity): \Generator
{
$path = $this->getFilePath($entity);
if (! file_exists($path)) {
throw new RuntimeException("Export file not found: {$path}. Run --export-only first.");
}
$handle = fopen($path, 'r');
if ($handle === false) {
throw new RuntimeException("Cannot open export file: {$path}");
}
$lineNumber = 0;
try {
while (($line = fgets($handle)) !== false) {
$lineNumber++;
$line = trim($line);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException(
"Invalid JSON on line {$lineNumber} of {$path}: " . json_last_error_msg()
);
}
yield $decoded;
}
} finally {
fclose($handle);
}
}
/**
* Read all records from a JSONL export file into an array.
*
* Use this only for small datasets (e.g., products, promotions).
* For large datasets, use readRecords() generator instead.
*
* @param string $entity Entity type
* @return array<int, array<string, mixed>>
*/
public function readAll(string $entity): array
{
$records = [];
foreach ($this->readRecords($entity) as $record) {
$records[] = $record;
}
return $records;
}
/**
* Count the number of records in an export file without loading them all.
*/
public function countRecords(string $entity): int
{
$path = $this->getFilePath($entity);
if (! file_exists($path)) {
return 0;
}
$count = 0;
$handle = fopen($path, 'r');
if ($handle === false) {
return 0;
}
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if ($line !== '') {
$count++;
}
}
fclose($handle);
return $count;
}
/**
* Check if an export file exists for a given entity.
*/
public function hasExport(string $entity): bool
{
$path = $this->getFilePath($entity);
return file_exists($path) && filesize($path) > 0;
}
/**
* Get the number of records written so far for an entity.
*/
public function getWriteCount(string $entity): int
{
return $this->writeCounts[$entity] ?? 0;
}
/**
* Get a summary of all export files.
*
* @return array<string, array{file: string, records: int, size: string}>
*/
public function getSummary(): array
{
$entities = ['clients', 'client_details', 'products', 'client_services', 'invoices', 'invoice_details', 'transactions', 'promotions', 'orders'];
$summary = [];
foreach ($entities as $entity) {
$path = $this->getFilePath($entity);
if (file_exists($path)) {
$size = filesize($path);
$records = $this->countRecords($entity);
$summary[$entity] = [
'file' => basename($path),
'records' => $records,
'size' => $this->formatBytes($size),
];
}
}
return $summary;
}
/**
* Reset (delete) the export file for a given entity.
*/
public function resetExport(string $entity): void
{
$this->closeWriteHandle($entity);
$path = $this->getFilePath($entity);
if (file_exists($path)) {
unlink($path);
}
unset($this->writeCounts[$entity]);
}
/**
* Reset all export files.
*/
public function resetAll(): void
{
$this->closeAllHandles();
$files = glob($this->exportDir . '/*.jsonl');
if (is_array($files)) {
foreach ($files as $file) {
unlink($file);
}
}
$this->writeCounts = [];
}
/**
* Flush and close all open file handles.
*/
public function closeAllHandles(): void
{
foreach (array_keys($this->writeHandles) as $entity) {
$this->closeWriteHandle($entity);
}
}
/**
* Get the JSONL file path for a given entity type.
*/
public function getFilePath(string $entity): string
{
return $this->exportDir . '/' . $entity . '.jsonl';
}
/**
* Get or create a write handle for the given entity.
*
* @return resource
*/
private function getWriteHandle(string $entity)
{
if (! isset($this->writeHandles[$entity])) {
$path = $this->getFilePath($entity);
$handle = fopen($path, 'a');
if ($handle === false) {
throw new RuntimeException("Cannot open export file for writing: {$path}");
}
$this->writeHandles[$entity] = $handle;
}
return $this->writeHandles[$entity];
}
/**
* Close a write handle for the given entity.
*/
private function closeWriteHandle(string $entity): void
{
if (isset($this->writeHandles[$entity])) {
fflush($this->writeHandles[$entity]);
fclose($this->writeHandles[$entity]);
unset($this->writeHandles[$entity]);
}
}
/**
* Format bytes into a human-readable string.
*/
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$power = $bytes > 0 ? floor(log($bytes, 1024)) : 0;
$power = min($power, count($units) - 1);
return round($bytes / pow(1024, $power), 2) . ' ' . $units[(int) $power];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger as MonologLogger;
final class Logger
{
private MonologLogger $logger;
public function __construct(string $logDir, string $level = 'info')
{
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$this->logger = new MonologLogger('whmcs-migrate');
$monologLevel = Level::fromName($level);
// File handler — detailed format
$fileFormatter = new LineFormatter(
format: "[%datetime%] %level_name%: %message% %context%\n",
dateFormat: 'Y-m-d H:i:s',
allowInlineLineBreaks: true,
ignoreEmptyContextAndExtra: true,
);
$fileHandler = new StreamHandler($logDir . '/migration.log', $monologLevel);
$fileHandler->setFormatter($fileFormatter);
$this->logger->pushHandler($fileHandler);
// Console handler — colorized
$consoleFormatter = new LineFormatter(
format: "%level_name%: %message% %context%\n",
dateFormat: null,
allowInlineLineBreaks: true,
ignoreEmptyContextAndExtra: true,
);
$consoleHandler = new StreamHandler('php://stdout', $monologLevel);
$consoleHandler->setFormatter($consoleFormatter);
$this->logger->pushHandler($consoleHandler);
}
public function info(string $message, array $context = []): void
{
$this->logger->info($message, $context);
}
public function warning(string $message, array $context = []): void
{
$this->logger->warning($message, $context);
}
public function error(string $message, array $context = []): void
{
$this->logger->error($message, $context);
}
public function debug(string $message, array $context = []): void
{
$this->logger->debug($message, $context);
}
/**
* Log a visual section separator.
*/
public function section(string $title): void
{
$separator = str_repeat('=', 60);
$this->info("\n{$separator}\n{$title}\n{$separator}");
}
}

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
use RuntimeException;
use Throwable;
use WhmcsMigrate\Phases\Phase1Clients;
use WhmcsMigrate\Phases\Phase2Products;
use WhmcsMigrate\Phases\Phase3Services;
use WhmcsMigrate\Phases\Phase4Invoices;
use WhmcsMigrate\Phases\Phase5Transactions;
use WhmcsMigrate\Phases\Phase6Coupons;
use WhmcsMigrate\Phases\Phase7Orders;
use WhmcsMigrate\Phases\PhaseInterface;
final class MigrationRunner
{
private Config $config;
private Logger $logger;
private Database $db;
private WhmcsApi $api;
private StateManager $state;
private Encryptor $encryptor;
private Validator $validator;
private ExportManager $exportManager;
/** @var list<PhaseInterface> */
private array $phases = [];
public function __construct(string $basePath)
{
$this->config = new Config($basePath);
$this->logger = new Logger(
$basePath . '/logs',
$this->config->getLogLevel(),
);
$this->db = new Database($this->config);
$this->api = new WhmcsApi($this->config, $this->logger);
$this->state = new StateManager($basePath . '/state');
$this->encryptor = new Encryptor($this->config->getLaravelAppKey());
$this->validator = new Validator($this->db, $this->logger);
$this->exportManager = new ExportManager($basePath);
$this->registerPhases();
}
/**
* Allow overriding config values (e.g., --dry-run from CLI).
*/
public function getConfig(): Config
{
return $this->config;
}
/**
* Run all phases or a specific phase by number.
*/
public function run(?int $phaseNumber = null): void
{
$modeLabel = '';
if ($this->config->isDryRun()) {
$modeLabel = ' [DRY RUN]';
} elseif ($this->config->isExportOnly()) {
$modeLabel = ' [EXPORT ONLY]';
} elseif ($this->config->isFromExport()) {
$modeLabel = ' [FROM EXPORT]';
}
$this->logger->section("WHMCS to EZSCALE Migration{$modeLabel}");
$this->logger->info('Started at ' . date('Y-m-d H:i:s'));
$this->logger->info('Batch size: ' . $this->config->getBatchSize());
$phasesToRun = $this->phases;
if ($phaseNumber !== null) {
$phasesToRun = array_filter(
$this->phases,
fn (PhaseInterface $phase): bool => $phase->getPhaseNumber() === $phaseNumber,
);
if (empty($phasesToRun)) {
throw new RuntimeException("Phase {$phaseNumber} is not registered.");
}
}
foreach ($phasesToRun as $phase) {
$name = $phase->getName();
$number = $phase->getPhaseNumber();
// In export-only mode, skip the "already complete" check
if (! $this->config->isExportOnly() && $phase->shouldSkip()) {
$this->logger->info("Phase {$number} ({$name}): already complete, skipping.");
continue;
}
$this->logger->info("Phase {$number} ({$name}): validating...");
if (! $phase->validate()) {
$this->logger->error("Phase {$number} ({$name}): validation failed. Stopping.");
return;
}
$this->logger->info("Phase {$number} ({$name}): starting...");
try {
$phase->run();
} catch (Throwable $e) {
$this->logger->error("Phase {$number} ({$name}): FAILED — {$e->getMessage()}", [
'exception' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
$this->state->save();
throw $e;
}
}
// Close all export file handles
$this->exportManager->closeAllHandles();
if ($this->config->isExportOnly()) {
$this->logger->section('Export Complete');
$this->showExportSummary();
} else {
$this->logger->section('Migration Complete');
$this->showStatus();
}
}
/**
* Print the current progress table for all phases.
*/
public function showStatus(): void
{
$this->logger->info('');
$this->logger->info(str_pad('Phase', 8) . str_pad('Name', 40) . str_pad('Status', 14) . str_pad('Count', 10) . str_pad('Errors', 10));
$this->logger->info(str_repeat('-', 82));
foreach ($this->phases as $phase) {
$number = $phase->getPhaseNumber();
$name = $phase->getName();
$progress = $this->state->getProgress($phase->getPhaseKey());
$this->logger->info(
str_pad((string) $number, 8)
. str_pad($name, 40)
. str_pad($progress['status'], 14)
. str_pad((string) $progress['count'], 10)
. str_pad((string) $progress['errors'], 10),
);
}
$this->logger->info('');
// Show mapping counts
$entityTypes = ['clients', 'products', 'services', 'subscriptions', 'invoices', 'transactions', 'orders', 'promotions'];
foreach ($entityTypes as $entity) {
$mappingCount = $this->state->getMappingCount($entity);
if ($mappingCount > 0) {
$this->logger->info(" {$entity} mappings: {$mappingCount}");
}
}
}
/**
* Print a summary of exported JSONL files.
*/
public function showExportSummary(): void
{
$summary = $this->exportManager->getSummary();
if (empty($summary)) {
$this->logger->info('No export files found.');
return;
}
$this->logger->info('');
$this->logger->info(str_pad('Entity', 25) . str_pad('Records', 12) . str_pad('Size', 15) . 'File');
$this->logger->info(str_repeat('-', 72));
$totalRecords = 0;
foreach ($summary as $entity => $info) {
$totalRecords += $info['records'];
$this->logger->info(
str_pad($entity, 25)
. str_pad(number_format($info['records']), 12)
. str_pad($info['size'], 15)
. $info['file'],
);
}
$this->logger->info(str_repeat('-', 72));
$this->logger->info(str_pad('Total', 25) . number_format($totalRecords));
$this->logger->info('');
$this->logger->info("Export directory: {$this->exportManager->getExportDir()}");
$this->logger->info('To import from these files, run: php migrate.php --from-export');
}
/**
* Reset all migration state (mappings and progress).
*/
public function reset(): void
{
$this->logger->warning('Resetting all migration state...');
$this->state->reset();
$this->logger->info('Migration state has been reset.');
}
/**
* Reset all exported JSONL files.
*/
public function resetExports(): void
{
$this->logger->warning('Resetting all export files...');
$this->exportManager->resetAll();
$this->logger->info('Export files have been deleted.');
}
/**
* Run pre-flight validation only (no data changes).
*/
public function validateOnly(): void
{
$this->logger->section('Validation Only');
$ok = $this->validator->validateAll($this->api);
if ($ok) {
$this->logger->info('All validations passed. Ready to migrate.');
} else {
$this->logger->error('Validation failed. Fix the issues above before running the migration.');
}
}
/**
* Register all migration phases in execution order.
*/
private function registerPhases(): void
{
$deps = [
$this->db,
$this->api,
$this->state,
$this->logger,
$this->config,
$this->encryptor,
$this->exportManager,
];
$this->phases = [
new Phase1Clients(...$deps),
new Phase2Products(...$deps),
new Phase3Services(...$deps),
new Phase4Invoices(...$deps),
new Phase5Transactions(...$deps),
new Phase6Coupons(...$deps),
new Phase7Orders(...$deps),
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate\Phases;
use WhmcsMigrate\Config;
use WhmcsMigrate\Database;
use WhmcsMigrate\Encryptor;
use WhmcsMigrate\ExportManager;
use WhmcsMigrate\Logger;
use WhmcsMigrate\StateManager;
use WhmcsMigrate\WhmcsApi;
abstract class AbstractPhase implements PhaseInterface
{
public function __construct(
protected readonly Database $db,
protected readonly WhmcsApi $api,
protected readonly StateManager $state,
protected readonly Logger $logger,
protected readonly Config $config,
protected readonly Encryptor $encryptor,
protected readonly ExportManager $exportManager,
) {}
/**
* Whether the migration is running in dry-run mode.
*/
protected function isDryRun(): bool
{
return $this->config->isDryRun();
}
/**
* Whether we are exporting data from the API only (no DB writes).
*/
protected function isExportOnly(): bool
{
return $this->config->isExportOnly();
}
/**
* Whether we should read from exported JSONL files instead of the API.
*/
protected function isFromExport(): bool
{
return $this->config->isFromExport();
}
/**
* Number of records to process per batch.
*/
protected function getBatchSize(): int
{
return $this->config->getBatchSize();
}
/**
* Get the state key for this phase (e.g., "phase_1").
*/
public function getPhaseKey(): string
{
return 'phase_' . $this->getPhaseNumber();
}
/**
* Whether this phase has already been completed and should be skipped.
*/
public function shouldSkip(): bool
{
return $this->state->isPhaseComplete($this->getPhaseKey());
}
/**
* Mark this phase as started in the state manager.
*/
protected function markStarted(): void
{
$this->state->setProgress($this->getPhaseKey(), 'in_progress');
}
/**
* Mark this phase as complete in the state manager.
*/
protected function markComplete(int $count, int $errors): void
{
$this->state->setProgress($this->getPhaseKey(), 'complete', 0, $count, $errors);
}
/**
* Log that an entity was skipped during migration.
*/
protected function logSkipped(string $entity, string $reason): void
{
$this->logger->warning("Skipped {$entity}: {$reason}");
}
}

View File

@@ -0,0 +1,554 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate\Phases;
use Throwable;
use WhmcsMigrate\ProgressTracker;
use WhmcsMigrate\StatusMapper;
final class Phase1Clients extends AbstractPhase
{
public function getPhaseNumber(): int
{
return 1;
}
public function getName(): string
{
return 'Clients → Users + UserProfiles';
}
public function validate(): bool
{
// In export-only mode, skip DB validation
if ($this->isExportOnly()) {
return $this->validateApiConnectivity();
}
// In from-export mode, skip API validation
if ($this->isFromExport()) {
return $this->validateDbPrerequisites();
}
// Normal mode: validate both
return $this->validateDbPrerequisites() && $this->validateApiConnectivity();
}
public function run(): void
{
if (! $this->isExportOnly() && $this->shouldSkip()) {
$this->logger->info('Phase 1 already complete, skipping');
return;
}
$this->logger->section('Phase 1: ' . $this->getName());
if ($this->isExportOnly()) {
$this->runExport();
return;
}
if ($this->isFromExport()) {
$this->runFromExport();
return;
}
$this->runFromApi();
}
/**
* Export all client data from WHMCS API to JSONL files.
*/
private function runExport(): void
{
$this->logger->info('Exporting clients from WHMCS API...');
// Reset the export files for this entity
$this->exportManager->resetExport('clients');
$this->exportManager->resetExport('client_details');
// Get total client count
$probeResponse = $this->api->call('GetClients', ['limitstart' => 0, 'limitnum' => 1]);
$totalClients = (int) ($probeResponse['totalresults'] ?? 0);
$this->logger->info("Found {$totalClients} clients in WHMCS");
if ($totalClients === 0) {
return;
}
$batchSize = $this->getBatchSize();
$offset = 0;
$exported = 0;
$progress = new ProgressTracker('Exporting clients', $totalClients);
while ($offset < $totalClients) {
$response = $this->api->call('GetClients', [
'limitstart' => $offset,
'limitnum' => $batchSize,
]);
$clients = $response['clients']['client'] ?? [];
if (empty($clients)) {
break;
}
// Export each client's basic info from the list
$this->exportManager->writeBatch('clients', $clients);
// Fetch and export full details for each client
foreach ($clients as $client) {
$whmcsId = (int) ($client['id'] ?? 0);
if ($whmcsId === 0) {
continue;
}
try {
$details = $this->api->call('GetClientsDetails', ['clientid' => $whmcsId]);
if (($details['result'] ?? '') === 'success') {
// Merge the datecreated from the list response
$details['_datecreated_from_list'] = (string) ($client['datecreated'] ?? '');
$this->exportManager->writeRecord('client_details', $details);
}
} catch (Throwable $e) {
$this->logger->warning("Failed to fetch details for client {$whmcsId}: {$e->getMessage()}");
}
$exported++;
$progress->advance();
}
$offset += count($clients);
}
$progress->finish();
$this->logger->info("Exported {$exported} client records to JSONL");
}
/**
* Import clients from previously exported JSONL files.
*/
private function runFromExport(): void
{
if (! $this->exportManager->hasExport('client_details')) {
$this->logger->error('No client_details export found. Run --export-only first.');
return;
}
$this->markStarted();
$totalClients = $this->exportManager->countRecords('client_details');
$this->logger->info("Found {$totalClients} client records in export");
if ($totalClients === 0) {
$this->markComplete(0, 0);
return;
}
// Look up the customer role_id once
$role = $this->db->queryOne(
"SELECT `id` FROM `roles` WHERE `name` = :name AND `guard_name` = :guard",
['name' => 'customer', 'guard' => 'web'],
);
$customerRoleId = (int) $role['id'];
$progress = $this->state->getProgress($this->getPhaseKey());
$processedCount = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
if ($processedCount > 0) {
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
}
$tracker = new ProgressTracker('Importing clients', $totalClients);
$tracker->setCurrent($processedCount);
$currentIndex = 0;
foreach ($this->exportManager->readRecords('client_details') as $details) {
$currentIndex++;
// Skip already-processed records
if ($currentIndex <= $processedCount) {
continue;
}
$whmcsId = (int) ($details['id'] ?? $details['userid'] ?? $details['client_id'] ?? 0);
if ($whmcsId === 0) {
$errors++;
$processedCount++;
$tracker->advance();
continue;
}
try {
$dateCreatedFromList = (string) ($details['_datecreated_from_list'] ?? '');
$result = $this->migrateClientFromDetails($whmcsId, $customerRoleId, $details, $dateCreatedFromList);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate client {$whmcsId}", [
'error' => $e->getMessage(),
]);
}
$processedCount++;
$tracker->advance();
// Save progress every 100 records
if ($processedCount % 100 === 0) {
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
$this->state->save();
}
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 1 complete: {$count} clients migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
/**
* Original behavior: fetch from API and import directly.
*/
private function runFromApi(): void
{
$this->markStarted();
// Look up the customer role_id once
$role = $this->db->queryOne(
"SELECT `id` FROM `roles` WHERE `name` = :name AND `guard_name` = :guard",
['name' => 'customer', 'guard' => 'web'],
);
$customerRoleId = (int) $role['id'];
// Get total client count from WHMCS
$probeResponse = $this->api->call('GetClients', ['limitstart' => 0, 'limitnum' => 1]);
$totalClients = (int) ($probeResponse['totalresults'] ?? 0);
$this->logger->info("Found {$totalClients} clients in WHMCS");
if ($totalClients === 0) {
$this->markComplete(0, 0);
return;
}
// Resume from saved offset if applicable
$progress = $this->state->getProgress($this->getPhaseKey());
$offset = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
$batchSize = $this->getBatchSize();
if ($offset > 0) {
$this->logger->info("Resuming from offset {$offset} ({$count} migrated, {$errors} errors so far)");
}
$tracker = new ProgressTracker('Migrating clients', $totalClients);
$tracker->setCurrent($offset);
while ($offset < $totalClients) {
$this->logger->debug("Fetching clients batch: offset={$offset}, limit={$batchSize}");
$response = $this->api->call('GetClients', [
'limitstart' => $offset,
'limitnum' => $batchSize,
]);
$clients = $response['clients']['client'] ?? [];
if (empty($clients)) {
$this->logger->debug('No more clients returned, ending pagination');
break;
}
foreach ($clients as $client) {
$whmcsId = (int) ($client['id'] ?? 0);
if ($whmcsId === 0) {
$this->logger->warning('Skipping client with no ID', ['client' => $client]);
$errors++;
continue;
}
try {
// datecreated is in GetClients response, not GetClientsDetails
$dateCreatedFromList = (string) ($client['datecreated'] ?? '');
$result = $this->migrateClient($whmcsId, $customerRoleId, $dateCreatedFromList);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate client {$whmcsId}", [
'error' => $e->getMessage(),
]);
}
$tracker->advance();
}
$offset += count($clients);
$this->state->setProgress($this->getPhaseKey(), 'running', $offset, $count, $errors);
$this->state->save();
$this->logger->info("Batch complete: {$count} migrated, {$errors} errors, offset now {$offset}/{$totalClients}");
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 1 complete: {$count} clients migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
/**
* Migrate a single client from API (fetches GetClientsDetails).
*/
private function migrateClient(int $whmcsId, int $customerRoleId, string $dateCreatedFromList = ''): bool
{
if ($this->state->getMapping('clients', $whmcsId) !== null) {
$this->logger->debug("Client {$whmcsId} already mapped, skipping");
return false;
}
$details = $this->api->call('GetClientsDetails', ['clientid' => $whmcsId]);
if (($details['result'] ?? '') !== 'success') {
$this->logger->warning("GetClientsDetails failed for client {$whmcsId}", [
'response' => $details,
]);
$this->logSkipped('client', "API returned non-success for client {$whmcsId}");
return false;
}
return $this->migrateClientFromDetails($whmcsId, $customerRoleId, $details, $dateCreatedFromList);
}
/**
* Migrate a single client from already-fetched detail data.
* Used by both API and from-export modes.
*/
private function migrateClientFromDetails(int $whmcsId, int $customerRoleId, array $details, string $dateCreatedFromList = ''): bool
{
if ($this->state->getMapping('clients', $whmcsId) !== null) {
$this->logger->debug("Client {$whmcsId} already mapped, skipping");
return false;
}
$email = trim(strtolower($details['email'] ?? ''));
if ($email === '') {
$this->logger->warning("Client {$whmcsId} has no email, skipping");
$this->logSkipped('client', "No email for client {$whmcsId}");
return false;
}
$existingUser = $this->db->queryOne(
"SELECT `id` FROM `users` WHERE `email` = :email LIMIT 1",
['email' => $email],
);
if ($existingUser !== null) {
$existingUserId = (int) $existingUser['id'];
$this->logger->warning("Client {$whmcsId} email '{$email}' already exists as user {$existingUserId}, mapping to existing user");
$this->state->setMapping('clients', $whmcsId, $existingUserId);
return false;
}
$firstname = trim((string) ($details['firstname'] ?? ''));
$lastname = trim((string) ($details['lastname'] ?? ''));
$name = trim("{$firstname} {$lastname}");
if ($name === '') {
$name = strstr($email, '@', true) ?: $email;
}
$status = StatusMapper::mapClientStatus((string) ($details['status'] ?? 'Active'));
$phone = $this->nullIfEmpty((string) ($details['phonenumber'] ?? ''));
$company = $this->nullIfEmpty((string) ($details['companyname'] ?? ''));
$dateCreated = $this->parseDate($dateCreatedFromList) ?? $this->parseDate((string) ($details['datecreated'] ?? ''));
$credit = (string) ($details['credit'] ?? '0.00');
$now = date('Y-m-d H:i:s');
$stripeCustomerId = null;
$pmType = null;
$pmLastFour = null;
$gatewayIdRaw = (string) ($details['gatewayid'] ?? '');
if ($gatewayIdRaw !== '') {
$gatewayData = json_decode($gatewayIdRaw, true);
if (is_array($gatewayData) && isset($gatewayData['customer']) && str_starts_with($gatewayData['customer'], 'cus_')) {
$stripeCustomerId = $gatewayData['customer'];
}
}
$cctype = $this->nullIfEmpty((string) ($details['cctype'] ?? ''));
$cclastfour = $this->nullIfEmpty((string) ($details['cclastfour'] ?? ''));
if ($cctype !== null) {
$pmType = strtolower($cctype);
}
if ($cclastfour !== null && strlen($cclastfour) <= 4) {
$pmLastFour = $cclastfour;
}
$adminNotes = "Imported from WHMCS. Client ID: {$whmcsId}";
if ((float) $credit > 0) {
$adminNotes .= ". Credit balance: \${$credit}";
}
if ($this->isDryRun()) {
$stripeInfo = $stripeCustomerId ? " (Stripe: {$stripeCustomerId})" : '';
$this->logger->info("[DRY RUN] Would create user for client {$whmcsId}: {$name} <{$email}>{$stripeInfo}");
return true;
}
$this->db->beginTransaction();
try {
$userId = $this->db->insert('users', [
'name' => $name,
'email' => $email,
'password' => password_hash(bin2hex(random_bytes(32)), PASSWORD_BCRYPT),
'status' => $status,
'phone' => $phone,
'company' => $company,
'admin_notes' => $adminNotes,
'stripe_id' => $stripeCustomerId,
'pm_type' => $pmType,
'pm_last_four' => $pmLastFour,
'email_verified_at' => $now,
'created_at' => $dateCreated ?? $now,
'updated_at' => $now,
]);
$this->db->insert('user_profiles', [
'user_id' => $userId,
'billing_address_line1' => $this->nullIfEmpty((string) ($details['address1'] ?? '')),
'billing_address_line2' => $this->nullIfEmpty((string) ($details['address2'] ?? '')),
'billing_city' => $this->nullIfEmpty((string) ($details['city'] ?? '')),
'billing_state' => $this->nullIfEmpty((string) ($details['state'] ?? '')),
'billing_zip' => $this->nullIfEmpty((string) ($details['postcode'] ?? '')),
'billing_country' => $this->nullIfEmpty((string) ($details['country'] ?? '')),
'company_name' => $company,
]);
$this->db->execute(
"INSERT IGNORE INTO `model_has_roles` (`role_id`, `model_type`, `model_id`) VALUES (:role_id, :model_type, :model_id)",
['role_id' => $customerRoleId, 'model_type' => 'App\\Models\\User', 'model_id' => $userId],
);
$this->db->commit();
$this->state->setMapping('clients', $whmcsId, $userId);
$this->logger->debug("Migrated client {$whmcsId} → user {$userId} ({$name} <{$email}>)");
return true;
} catch (Throwable $e) {
$this->db->rollback();
throw $e;
}
}
private function validateDbPrerequisites(): bool
{
$role = $this->db->queryOne(
"SELECT `id` FROM `roles` WHERE `name` = :name AND `guard_name` = :guard",
['name' => 'customer', 'guard' => 'web'],
);
if ($role === null) {
$this->logger->error('Validation failed: no "customer" role found in roles table (guard_name=web)');
return false;
}
return true;
}
private function validateApiConnectivity(): bool
{
try {
$response = $this->api->call('GetClients', ['limitstart' => 0, 'limitnum' => 1]);
if (($response['result'] ?? '') !== 'success') {
$this->logger->error('Validation failed: WHMCS GetClients API returned non-success', [
'response' => $response,
]);
return false;
}
} catch (Throwable $e) {
$this->logger->error('Validation failed: cannot reach WHMCS API', [
'error' => $e->getMessage(),
]);
return false;
}
return true;
}
private function nullIfEmpty(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function parseDate(string $date): ?string
{
$date = trim($date);
if ($date === '' || str_starts_with($date, '0000-00-00')) {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
return $date;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date . ' 00:00:00';
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return null;
}
return date('Y-m-d H:i:s', $timestamp);
}
}

View File

@@ -0,0 +1,479 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate\Phases;
use RuntimeException;
use Throwable;
use WhmcsMigrate\ProgressTracker;
use WhmcsMigrate\StatusMapper;
final class Phase2Products extends AbstractPhase
{
/**
* WHMCS billing cycle keys mapped to EZSCALE plan_prices billing_cycle values.
*/
private const array CYCLE_MAP = [
'monthly' => 'monthly',
'quarterly' => 'quarterly',
'semiannually' => 'semi_annual',
'annually' => 'annual',
];
public function getPhaseNumber(): int
{
return 2;
}
public function getName(): string
{
return 'Products → Plans + PlanPrices';
}
public function validate(): bool
{
if ($this->isExportOnly()) {
return $this->validateApi();
}
if ($this->isFromExport()) {
return $this->validatePlanMapping();
}
return $this->validatePlanMapping() && $this->validateApi();
}
public function run(): void
{
if (! $this->isExportOnly() && $this->shouldSkip()) {
$this->logger->info('Phase 2 already complete, skipping');
return;
}
$this->logger->section('Phase 2: ' . $this->getName());
if ($this->isExportOnly()) {
$this->runExport();
return;
}
if ($this->isFromExport()) {
$this->runFromExport();
return;
}
$this->runFromApi();
}
private function runExport(): void
{
$this->logger->info('Exporting products from WHMCS API...');
$this->exportManager->resetExport('products');
$response = $this->api->call('GetProducts');
$products = $response['products']['product'] ?? [];
$totalProducts = (int) ($response['totalresults'] ?? count($products));
$this->logger->info("Found {$totalProducts} products in WHMCS");
if (empty($products)) {
return;
}
$this->exportManager->writeBatch('products', $products);
$this->logger->info("Exported {$totalProducts} product records to JSONL");
}
private function runFromExport(): void
{
if (! $this->exportManager->hasExport('products')) {
$this->logger->error('No products export found. Run --export-only first.');
return;
}
$this->markStarted();
$planData = $this->loadPlanMapping();
$planMapping = $planData['mappings'];
$skipList = $planData['skip'];
$this->logger->info('Plan mapping loaded', ['mapped_products' => count($planMapping), 'skipped_products' => count($skipList)]);
$products = $this->exportManager->readAll('products');
$totalProducts = count($products);
$this->logger->info("Found {$totalProducts} products in export");
if ($totalProducts === 0) {
$this->markComplete(0, 0);
return;
}
$count = 0;
$errors = 0;
$tracker = new ProgressTracker('Importing products', $totalProducts);
foreach ($products as $product) {
$pid = (int) ($product['pid'] ?? 0);
if ($pid === 0) {
$this->logger->warning('Skipping product with no pid', ['product' => $product]);
$errors++;
$tracker->advance();
continue;
}
try {
$result = $this->migrateProduct($pid, $product, $planMapping, $skipList);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate product {$pid}", [
'error' => $e->getMessage(),
'name' => $product['name'] ?? 'unknown',
]);
}
$tracker->advance();
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 2 complete: {$count} products migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
private function runFromApi(): void
{
$this->markStarted();
$planData = $this->loadPlanMapping();
$planMapping = $planData['mappings'];
$skipList = $planData['skip'];
$this->logger->info('Plan mapping loaded', ['mapped_products' => count($planMapping), 'skipped_products' => count($skipList)]);
$response = $this->api->call('GetProducts');
$products = $response['products']['product'] ?? [];
$totalProducts = (int) ($response['totalresults'] ?? count($products));
$this->logger->info("Found {$totalProducts} products in WHMCS");
if (empty($products)) {
$this->markComplete(0, 0);
return;
}
$count = 0;
$errors = 0;
$tracker = new ProgressTracker('Migrating products', $totalProducts);
foreach ($products as $product) {
$pid = (int) ($product['pid'] ?? 0);
if ($pid === 0) {
$this->logger->warning('Skipping product with no pid', ['product' => $product]);
$errors++;
$tracker->advance();
continue;
}
try {
$result = $this->migrateProduct($pid, $product, $planMapping, $skipList);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate product {$pid}", [
'error' => $e->getMessage(),
'name' => $product['name'] ?? 'unknown',
]);
}
$tracker->advance();
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 2 complete: {$count} products migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
/**
* Migrate a single WHMCS product to an EZSCALE plan.
*
* @param array<string, string> $planMapping WHMCS pid => EZSCALE slug
* @param array<int, string> $skipList WHMCS pids to skip entirely
*/
private function migrateProduct(int $pid, array $product, array $planMapping, array $skipList): bool
{
// Already mapped — skip
if ($this->state->getMapping('products', $pid) !== null) {
$this->logger->debug("Product {$pid} already mapped, skipping");
return false;
}
$productName = (string) ($product['name'] ?? "Product {$pid}");
// Check if this product is in the explicit skip list
if (in_array((string) $pid, $skipList, true)) {
$this->logger->info("Skipping product {$pid} ({$productName}) — in skip list");
$this->logSkipped('product', "Product {$pid} ({$productName}) is in skip list");
return false;
}
// Check if this product has an explicit mapping to an existing EZSCALE plan
if (array_key_exists((string) $pid, $planMapping)) {
return $this->mapToExistingPlan($pid, $productName, $planMapping[(string) $pid]);
}
// Unmapped and not skipped — log warning and skip (no auto-creation)
$this->logger->warning("Product {$pid} ({$productName}) has no mapping and is not in skip list — skipping. Add it to plan_mapping.json mappings or skip list.");
$this->logSkipped('product', "Product {$pid} ({$productName}) unmapped — not in mappings or skip list");
return false;
}
/**
* Map a WHMCS product to an existing EZSCALE plan by slug.
*/
private function mapToExistingPlan(int $pid, string $productName, string $ezscaleSlug): bool
{
$plan = $this->db->queryOne(
"SELECT `id` FROM `plans` WHERE `slug` = :slug LIMIT 1",
['slug' => $ezscaleSlug],
);
if ($plan === null) {
$this->logger->error("Mapped plan slug '{$ezscaleSlug}' not found in plans table for product {$pid} ({$productName})");
$this->logSkipped('product', "Plan slug '{$ezscaleSlug}' not found for WHMCS product {$pid}");
return false;
}
$planId = (int) $plan['id'];
$this->state->setMapping('products', $pid, $planId);
$this->logger->info("Mapped WHMCS product {$pid} ({$productName}) → existing plan {$planId} (slug: {$ezscaleSlug})");
return true;
}
/**
* Auto-create a new EZSCALE plan from WHMCS product data.
*/
private function autoCreatePlan(int $pid, array $product): bool
{
$productName = (string) ($product['name'] ?? "Product {$pid}");
$groupName = (string) ($product['groupname'] ?? '');
$description = strip_tags((string) ($product['description'] ?? ''));
$serviceType = StatusMapper::mapServiceType($groupName, $productName);
// Extract USD pricing
$pricing = $product['pricing']['USD'] ?? [];
$monthlyPrice = $this->parsePrice((string) ($pricing['monthly'] ?? '0.00'));
// Generate unique slug
$slug = $this->generateUniqueSlug($productName);
if ($this->isDryRun()) {
$this->logger->info("[DRY RUN] Would create plan for product {$pid}: {$productName} (slug: {$slug}, type: {$serviceType}, monthly: {$monthlyPrice})");
return true;
}
// Insert the plan
$planId = $this->db->insert('plans', [
'name' => $productName,
'slug' => $slug,
'description' => $description !== '' ? $description : null,
'service_type' => $serviceType,
'price' => $monthlyPrice,
'currency' => 'USD',
'billing_cycle' => 'monthly',
'status' => 'active',
'features' => null,
'provisioning_config' => null,
'sort_order' => 0,
]);
// Insert plan_prices for each valid billing cycle
$pricesInserted = $this->insertPlanPrices($planId, $pricing);
$this->state->setMapping('products', $pid, $planId);
$this->state->save();
$this->logger->info("Created plan {$planId} for WHMCS product {$pid}: {$productName} (slug: {$slug}, {$pricesInserted} price tiers)");
return true;
}
/**
* Insert plan_prices rows for each valid billing cycle.
*
* @return int Number of price rows inserted
*/
private function insertPlanPrices(int $planId, array $pricing): int
{
$inserted = 0;
foreach (self::CYCLE_MAP as $whmcsCycle => $ezscaleCycle) {
$priceStr = (string) ($pricing[$whmcsCycle] ?? '');
$price = $this->parsePrice($priceStr);
// WHMCS uses -1.00 for disabled cycles, skip zero and negative
if ($price <= 0) {
continue;
}
$this->db->insert('plan_prices', [
'plan_id' => $planId,
'billing_cycle' => $ezscaleCycle,
'price' => $price,
]);
$inserted++;
}
return $inserted;
}
private function parsePrice(string $price): float
{
$price = trim($price);
if ($price === '' || $price === '-1.00' || $price === '-1') {
return 0.0;
}
$parsed = (float) $price;
return $parsed > 0 ? round($parsed, 2) : 0.0;
}
private function generateUniqueSlug(string $name): string
{
$baseSlug = strtolower(trim($name));
$baseSlug = (string) preg_replace('/[^a-z0-9\s-]/', '', $baseSlug);
$baseSlug = (string) preg_replace('/[\s-]+/', '-', $baseSlug);
$baseSlug = trim($baseSlug, '-');
if ($baseSlug === '') {
$baseSlug = 'plan';
}
$slug = $baseSlug;
$suffix = 2;
while ($this->db->tableHasRow('plans', ['slug' => $slug])) {
$slug = "{$baseSlug}-{$suffix}";
$suffix++;
}
return $slug;
}
/**
* Load plan mapping and skip list from plan_mapping.json.
*
* @return array{mappings: array<string, string>, skip: array<int, string>}
*/
private function loadPlanMapping(): array
{
$path = $this->getPlanMappingPath();
$contents = file_get_contents($path);
if ($contents === false) {
throw new RuntimeException("Cannot read plan_mapping.json at {$path}");
}
$decoded = json_decode($contents, true);
if (! is_array($decoded) || ! array_key_exists('mappings', $decoded)) {
throw new RuntimeException('plan_mapping.json must contain a "mappings" key');
}
$mappings = [];
foreach ($decoded['mappings'] as $whmcsId => $slug) {
$mappings[(string) $whmcsId] = (string) $slug;
}
$skip = [];
if (array_key_exists('skip', $decoded) && is_array($decoded['skip'])) {
$skip = array_map('strval', $decoded['skip']);
}
return ['mappings' => $mappings, 'skip' => $skip];
}
private function getPlanMappingPath(): string
{
return dirname(__DIR__, 2) . '/plan_mapping.json';
}
private function validatePlanMapping(): bool
{
$mappingPath = $this->getPlanMappingPath();
if (! file_exists($mappingPath)) {
$this->logger->error("Validation failed: plan_mapping.json not found at {$mappingPath}");
return false;
}
$contents = file_get_contents($mappingPath);
if ($contents === false) {
$this->logger->error("Validation failed: cannot read plan_mapping.json");
return false;
}
$decoded = json_decode($contents, true);
if (! is_array($decoded) || ! array_key_exists('mappings', $decoded)) {
$this->logger->error('Validation failed: plan_mapping.json must contain a "mappings" key');
return false;
}
return true;
}
private function validateApi(): bool
{
try {
$response = $this->api->call('GetProducts');
if (($response['result'] ?? '') !== 'success') {
$this->logger->error('Validation failed: WHMCS GetProducts API returned non-success', [
'response' => $response,
]);
return false;
}
} catch (Throwable $e) {
$this->logger->error('Validation failed: cannot fetch products from WHMCS API', [
'error' => $e->getMessage(),
]);
return false;
}
return true;
}
}

View File

@@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate\Phases;
use Throwable;
use WhmcsMigrate\ProgressTracker;
use WhmcsMigrate\StatusMapper;
final class Phase3Services extends AbstractPhase
{
public function getPhaseNumber(): int
{
return 3;
}
public function getName(): string
{
return 'Services → Subscriptions + Services';
}
public function validate(): bool
{
if ($this->isExportOnly()) {
// Export needs client mappings to know which clients to fetch services for
$clientCount = $this->state->getMappingCount('clients');
if ($clientCount === 0) {
// In export-only mode, we can export by iterating all clients from the clients export
if (! $this->exportManager->hasExport('clients')) {
$this->logger->error('Validation failed: no client export or mappings found — run Phase 1 export first');
return false;
}
}
return true;
}
// Import modes need client + product mappings
$clientCount = $this->state->getMappingCount('clients');
if ($clientCount === 0) {
$this->logger->error('Validation failed: no client mappings found — run Phase 1 first');
return false;
}
$productCount = $this->state->getMappingCount('products');
if ($productCount === 0) {
$this->logger->error('Validation failed: no product mappings found — run Phase 2 first');
return false;
}
$this->logger->info("Validation passed: {$clientCount} client mappings, {$productCount} product mappings");
return true;
}
public function run(): void
{
if (! $this->isExportOnly() && $this->shouldSkip()) {
$this->logger->info('Phase 3 already complete, skipping');
return;
}
$this->logger->section('Phase 3: ' . $this->getName());
if ($this->isExportOnly()) {
$this->runExport();
return;
}
if ($this->isFromExport()) {
$this->runFromExport();
return;
}
$this->runFromApi();
}
private function runExport(): void
{
$this->logger->info('Exporting client services from WHMCS API...');
$this->exportManager->resetExport('client_services');
// Get client IDs from existing mappings or from the clients export
$clientIds = [];
$clientMappings = $this->state->getAllMappings('clients');
if (! empty($clientMappings)) {
$clientIds = array_map('intval', array_keys($clientMappings));
} else {
// Read from clients export
foreach ($this->exportManager->readRecords('clients') as $client) {
$id = (int) ($client['id'] ?? 0);
if ($id > 0) {
$clientIds[] = $id;
}
}
}
$totalClients = count($clientIds);
$this->logger->info("Exporting services for {$totalClients} clients");
if ($totalClients === 0) {
return;
}
$tracker = new ProgressTracker('Exporting services', $totalClients);
$exported = 0;
foreach ($clientIds as $whmcsClientId) {
try {
$response = $this->api->call('GetClientsProducts', ['clientid' => $whmcsClientId]);
if (($response['result'] ?? '') === 'success') {
$products = $response['products']['product'] ?? [];
foreach ($products as $product) {
$product['_whmcs_client_id'] = $whmcsClientId;
$this->exportManager->writeRecord('client_services', $product);
$exported++;
}
}
} catch (Throwable $e) {
$this->logger->warning("Failed to fetch services for client {$whmcsClientId}: {$e->getMessage()}");
}
$tracker->advance();
}
$tracker->finish();
$this->logger->info("Exported {$exported} service records to JSONL");
}
private function runFromExport(): void
{
if (! $this->exportManager->hasExport('client_services')) {
$this->logger->error('No client_services export found. Run --export-only first.');
return;
}
$this->markStarted();
$totalServices = $this->exportManager->countRecords('client_services');
$this->logger->info("Found {$totalServices} service records in export");
if ($totalServices === 0) {
$this->markComplete(0, 0);
return;
}
$progress = $this->state->getProgress($this->getPhaseKey());
$processedCount = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
if ($processedCount > 0) {
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
}
$tracker = new ProgressTracker('Importing services', $totalServices);
$tracker->setCurrent($processedCount);
$currentIndex = 0;
foreach ($this->exportManager->readRecords('client_services') as $product) {
$currentIndex++;
if ($currentIndex <= $processedCount) {
continue;
}
$whmcsServiceId = (int) ($product['id'] ?? 0);
$whmcsClientId = (int) ($product['_whmcs_client_id'] ?? 0);
$ezUserId = $this->state->getMapping('clients', $whmcsClientId);
if ($whmcsServiceId === 0 || $whmcsClientId === 0 || $ezUserId === null) {
$errors++;
$processedCount++;
$tracker->advance();
continue;
}
try {
if ($this->migrateService($whmcsServiceId, $whmcsClientId, $ezUserId, $product)) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate service {$whmcsServiceId}", [
'error' => $e->getMessage(),
]);
}
$processedCount++;
$tracker->advance();
if ($processedCount % 100 === 0) {
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
$this->state->save();
}
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 3 complete: {$count} services migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
private function runFromApi(): void
{
$this->markStarted();
$clientMappings = $this->state->getAllMappings('clients');
$totalClients = count($clientMappings);
$this->logger->info("Processing services for {$totalClients} mapped clients");
if ($totalClients === 0) {
$this->markComplete(0, 0);
return;
}
$progress = $this->state->getProgress($this->getPhaseKey());
$startOffset = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
if ($startOffset > 0) {
$this->logger->info("Resuming from client index {$startOffset} ({$count} services migrated, {$errors} errors so far)");
}
$clientEntries = array_values(
array_map(
fn (string $whmcsId, int $ezUserId): array => [
'whmcs_id' => (int) $whmcsId,
'ez_user_id' => $ezUserId,
],
array_keys($clientMappings),
array_values($clientMappings),
),
);
$tracker = new ProgressTracker('Migrating services', $totalClients);
$tracker->setCurrent($startOffset);
for ($i = $startOffset; $i < $totalClients; $i++) {
$whmcsClientId = $clientEntries[$i]['whmcs_id'];
$ezUserId = $clientEntries[$i]['ez_user_id'];
try {
$result = $this->migrateClientServices($whmcsClientId, $ezUserId);
$count += $result['migrated'];
$errors += $result['errors'];
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to process services for WHMCS client {$whmcsClientId}", [
'error' => $e->getMessage(),
]);
}
$this->state->setProgress($this->getPhaseKey(), 'running', $i + 1, $count, $errors);
$this->state->save();
$tracker->advance();
if (($i + 1) % 50 === 0 || $i + 1 === $totalClients) {
$this->logger->info("Progress: client " . ($i + 1) . "/{$totalClients}, {$count} services migrated, {$errors} errors");
}
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 3 complete: {$count} services migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
/**
* Migrate all services/products for a single WHMCS client.
*
* @return array{migrated: int, errors: int}
*/
private function migrateClientServices(int $whmcsClientId, int $ezUserId): array
{
$response = $this->api->call('GetClientsProducts', ['clientid' => $whmcsClientId]);
if (($response['result'] ?? '') !== 'success') {
$this->logger->warning("GetClientsProducts failed for client {$whmcsClientId}", [
'response' => $response,
]);
return ['migrated' => 0, 'errors' => 1];
}
$products = $response['products']['product'] ?? [];
$migrated = 0;
$errors = 0;
foreach ($products as $product) {
$whmcsServiceId = (int) ($product['id'] ?? 0);
if ($whmcsServiceId === 0) {
$this->logger->warning('Skipping service with no ID for client ' . $whmcsClientId);
$errors++;
continue;
}
try {
if ($this->migrateService($whmcsServiceId, $whmcsClientId, $ezUserId, $product)) {
$migrated++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate service {$whmcsServiceId} for client {$whmcsClientId}", [
'error' => $e->getMessage(),
]);
}
}
return ['migrated' => $migrated, 'errors' => $errors];
}
/**
* Migrate a single WHMCS service/product to EZSCALE subscription + service.
*/
private function migrateService(int $whmcsServiceId, int $whmcsClientId, int $ezUserId, array $product): bool
{
if ($this->state->getMapping('services', $whmcsServiceId) !== null) {
$this->logger->debug("Service {$whmcsServiceId} already mapped, skipping");
return false;
}
$pid = (int) ($product['pid'] ?? 0);
$planId = $this->state->getMapping('products', $pid);
if ($planId === null) {
$this->logger->warning("No plan mapping for WHMCS product {$pid}, skipping service {$whmcsServiceId}");
$this->logSkipped('service', "No plan mapping for product {$pid} (service {$whmcsServiceId})");
return false;
}
$plan = $this->db->queryOne(
"SELECT `slug`, `service_type` FROM `plans` WHERE `id` = :id LIMIT 1",
['id' => $planId],
);
if ($plan === null) {
$this->logger->warning("Plan {$planId} not found in database, skipping service {$whmcsServiceId}");
$this->logSkipped('service', "Plan {$planId} not found in DB (service {$whmcsServiceId})");
return false;
}
$planSlug = (string) $plan['slug'];
$serviceType = (string) $plan['service_type'];
$platform = StatusMapper::mapPlatform($serviceType); // null for mysql, backups
$stripeId = "whmcs_import_{$whmcsServiceId}";
if ($this->db->tableHasRow('subscriptions', ['stripe_id' => $stripeId])) {
$this->logger->warning("Subscription with stripe_id '{$stripeId}' already exists, skipping service {$whmcsServiceId}");
$this->logSkipped('service', "Duplicate stripe_id {$stripeId}");
return false;
}
$whmcsStatus = (string) ($product['status'] ?? 'Active');
$billingCycle = StatusMapper::mapBillingCycle((string) ($product['billingcycle'] ?? 'Monthly'));
$gateway = StatusMapper::mapGateway((string) ($product['paymentmethod'] ?? 'stripe'));
$stripeStatus = StatusMapper::mapStripeStatus($whmcsStatus);
$serviceStatus = StatusMapper::mapServiceStatus($whmcsStatus);
$regDate = $this->parseDate((string) ($product['regdate'] ?? ''));
$nextDueDate = $this->parseDate((string) ($product['nextduedate'] ?? ''));
$now = date('Y-m-d H:i:s');
$isActive = strtolower($whmcsStatus) === 'active';
$isSuspended = strtolower($whmcsStatus) === 'suspended';
$isTerminated = in_array(strtolower($whmcsStatus), ['terminated', 'cancelled'], true);
$dedicatedIp = $this->nullIfEmpty((string) ($product['dedicatedip'] ?? ''));
$domain = $this->nullIfEmpty((string) ($product['domain'] ?? ''));
if ($this->isDryRun()) {
$this->logger->info("[DRY RUN] Would create subscription + service for WHMCS service {$whmcsServiceId} (client {$whmcsClientId}, plan {$planSlug}, status {$whmcsStatus})");
return true;
}
$this->db->beginTransaction();
try {
$subscriptionId = $this->db->insert('subscriptions', [
'user_id' => $ezUserId,
'type' => $planSlug,
'plan_id' => $planId,
'gateway' => $gateway,
'stripe_id' => $stripeId,
'stripe_status' => $stripeStatus,
'stripe_price' => null,
'quantity' => 1,
'billing_cycle' => $billingCycle,
'current_period_end' => $nextDueDate,
'created_at' => $regDate ?? $now,
'updated_at' => $now,
]);
$this->db->insert('subscription_items', [
'subscription_id' => $subscriptionId,
'stripe_id' => "whmcs_si_{$whmcsServiceId}",
'stripe_product' => "whmcs_prod_{$pid}",
'stripe_price' => "whmcs_price_{$pid}",
'quantity' => 1,
]);
$serviceId = $this->db->insert('services', [
'user_id' => $ezUserId,
'subscription_id' => $subscriptionId,
'plan_id' => $planId,
'service_type' => $serviceType,
'platform' => $platform,
'status' => $serviceStatus,
'ipv4_address' => $dedicatedIp,
'domain' => $domain,
'credentials' => $this->encryptor->encryptArray([]),
'provisioned_at' => $isActive ? ($regDate ?? $now) : null,
'suspended_at' => $isSuspended ? $now : null,
'terminated_at' => $isTerminated ? $now : null,
'auto_renew' => $isActive ? 1 : 0,
'created_at' => $regDate ?? $now,
'updated_at' => $now,
]);
$this->db->commit();
$this->state->setMapping('services', $whmcsServiceId, $serviceId);
$this->state->setMapping('subscriptions', $whmcsServiceId, $subscriptionId);
$this->logger->debug("Migrated service {$whmcsServiceId} → subscription {$subscriptionId} + service {$serviceId} (plan: {$planSlug}, status: {$serviceStatus})");
return true;
} catch (Throwable $e) {
$this->db->rollback();
throw $e;
}
}
private function nullIfEmpty(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function parseDate(string $date): ?string
{
$date = trim($date);
if ($date === '' || str_starts_with($date, '0000-00-00')) {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
return $date;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date . ' 00:00:00';
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return null;
}
return date('Y-m-d H:i:s', $timestamp);
}
}

View File

@@ -0,0 +1,553 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate\Phases;
use Throwable;
use WhmcsMigrate\ProgressTracker;
use WhmcsMigrate\StatusMapper;
final class Phase4Invoices extends AbstractPhase
{
public function getPhaseNumber(): int
{
return 4;
}
public function getName(): string
{
return 'Invoices → Invoices + InvoiceItems';
}
public function validate(): bool
{
if ($this->isExportOnly()) {
return $this->validateApi();
}
if ($this->isFromExport()) {
return $this->validateDb();
}
return $this->validateDb() && $this->validateApi();
}
public function run(): void
{
if (! $this->isExportOnly() && $this->shouldSkip()) {
$this->logger->info('Phase 4 already complete, skipping');
return;
}
$this->logger->section('Phase 4: ' . $this->getName());
if ($this->isExportOnly()) {
$this->runExport();
return;
}
if ($this->isFromExport()) {
$this->runFromExport();
return;
}
$this->runFromApi();
}
private function runExport(): void
{
$this->logger->info('Exporting invoices from WHMCS API...');
$this->exportManager->resetExport('invoices');
$this->exportManager->resetExport('invoice_details');
// Get total invoice count
$probeResponse = $this->api->call('GetInvoices', ['limitstart' => 0, 'limitnum' => 1]);
$totalInvoices = (int) ($probeResponse['totalresults'] ?? 0);
$this->logger->info("Found {$totalInvoices} invoices in WHMCS");
if ($totalInvoices === 0) {
return;
}
$batchSize = $this->getBatchSize();
$offset = 0;
$exported = 0;
$tracker = new ProgressTracker('Exporting invoices', $totalInvoices);
while ($offset < $totalInvoices) {
$response = $this->api->call('GetInvoices', [
'limitstart' => $offset,
'limitnum' => $batchSize,
]);
$invoices = $response['invoices']['invoice'] ?? [];
if (empty($invoices)) {
break;
}
// Export summary data
$this->exportManager->writeBatch('invoices', $invoices);
// Fetch and export detail data (line items) for each invoice
foreach ($invoices as $invoice) {
$invoiceId = (int) ($invoice['id'] ?? 0);
if ($invoiceId === 0) {
$tracker->advance();
continue;
}
try {
$detailResponse = $this->api->call('GetInvoice', ['invoiceid' => $invoiceId]);
if (($detailResponse['result'] ?? '') === 'success') {
// Merge summary fields into detail for a complete record
$detailResponse['_summary'] = $invoice;
$this->exportManager->writeRecord('invoice_details', $detailResponse);
}
} catch (Throwable $e) {
$this->logger->warning("Failed to fetch details for invoice {$invoiceId}: {$e->getMessage()}");
}
$exported++;
$tracker->advance();
}
$offset += count($invoices);
}
$tracker->finish();
$this->logger->info("Exported {$exported} invoice records to JSONL");
}
private function runFromExport(): void
{
if (! $this->exportManager->hasExport('invoices')) {
$this->logger->error('No invoices export found. Run --export-only first.');
return;
}
$this->markStarted();
// We need both invoices (summary) and invoice_details (line items)
// If invoice_details exists, use it (has line items). Otherwise fall back to invoices.
$hasDetails = $this->exportManager->hasExport('invoice_details');
$entityName = $hasDetails ? 'invoice_details' : 'invoices';
$totalInvoices = $this->exportManager->countRecords($entityName);
$this->logger->info("Found {$totalInvoices} invoice records in export" . ($hasDetails ? ' (with details)' : ' (summary only)'));
if ($totalInvoices === 0) {
$this->markComplete(0, 0);
return;
}
$progress = $this->state->getProgress($this->getPhaseKey());
$processedCount = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
if ($processedCount > 0) {
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
}
$tracker = new ProgressTracker('Importing invoices', $totalInvoices);
$tracker->setCurrent($processedCount);
$currentIndex = 0;
foreach ($this->exportManager->readRecords($entityName) as $record) {
$currentIndex++;
if ($currentIndex <= $processedCount) {
continue;
}
if ($hasDetails) {
// invoice_details record has full detail + _summary
$invoiceData = $record['_summary'] ?? $record;
$detailData = $record;
$whmcsInvoiceId = (int) ($invoiceData['id'] ?? $record['invoiceid'] ?? 0);
} else {
$invoiceData = $record;
$detailData = null;
$whmcsInvoiceId = (int) ($invoiceData['id'] ?? 0);
}
if ($whmcsInvoiceId === 0) {
$errors++;
$processedCount++;
$tracker->advance();
continue;
}
try {
$result = $this->migrateInvoiceFromData($whmcsInvoiceId, $invoiceData, $detailData);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate invoice {$whmcsInvoiceId}", [
'error' => $e->getMessage(),
]);
}
$processedCount++;
$tracker->advance();
if ($processedCount % 100 === 0) {
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
$this->state->save();
}
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 4 complete: {$count} invoices migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
private function runFromApi(): void
{
$this->markStarted();
// Get total invoice count from WHMCS
$probeResponse = $this->api->call('GetInvoices', ['limitstart' => 0, 'limitnum' => 1]);
$totalInvoices = (int) ($probeResponse['totalresults'] ?? 0);
$this->logger->info("Found {$totalInvoices} invoices in WHMCS");
if ($totalInvoices === 0) {
$this->markComplete(0, 0);
return;
}
$progress = $this->state->getProgress($this->getPhaseKey());
$offset = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
$batchSize = $this->getBatchSize();
if ($offset > 0) {
$this->logger->info("Resuming from offset {$offset} ({$count} migrated, {$errors} errors so far)");
}
$tracker = new ProgressTracker('Migrating invoices', $totalInvoices);
$tracker->setCurrent($offset);
while ($offset < $totalInvoices) {
$this->logger->debug("Fetching invoices batch: offset={$offset}, limit={$batchSize}");
$response = $this->api->call('GetInvoices', [
'limitstart' => $offset,
'limitnum' => $batchSize,
]);
$invoices = $response['invoices']['invoice'] ?? [];
if (empty($invoices)) {
$this->logger->debug('No more invoices returned, ending pagination');
break;
}
foreach ($invoices as $invoice) {
$whmcsInvoiceId = (int) ($invoice['id'] ?? 0);
if ($whmcsInvoiceId === 0) {
$this->logger->warning('Skipping invoice with no ID', ['invoice' => $invoice]);
$errors++;
continue;
}
try {
$result = $this->migrateInvoice($whmcsInvoiceId, $invoice);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate invoice {$whmcsInvoiceId}", [
'error' => $e->getMessage(),
]);
}
$tracker->advance();
}
$offset += count($invoices);
$this->state->setProgress($this->getPhaseKey(), 'running', $offset, $count, $errors);
$this->state->save();
$this->logger->info("Batch complete: {$count} migrated, {$errors} errors, offset now {$offset}/{$totalInvoices}");
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 4 complete: {$count} invoices migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
/**
* Migrate from API — fetches invoice detail on the fly.
*/
private function migrateInvoice(int $whmcsInvoiceId, array $invoiceData): bool
{
if ($this->state->getMapping('invoices', $whmcsInvoiceId) !== null) {
$this->logger->debug("Invoice {$whmcsInvoiceId} already mapped, skipping");
return false;
}
// Fetch line items from WHMCS
$detailResponse = $this->api->call('GetInvoice', ['invoiceid' => $whmcsInvoiceId]);
if (($detailResponse['result'] ?? '') !== 'success') {
$this->logger->warning("GetInvoice failed for invoice {$whmcsInvoiceId}", [
'response' => $detailResponse,
]);
$this->logSkipped('invoice', "GetInvoice API failed for invoice {$whmcsInvoiceId}");
return false;
}
return $this->migrateInvoiceFromData($whmcsInvoiceId, $invoiceData, $detailResponse);
}
/**
* Migrate from already-loaded data (used by both API and export modes).
*/
private function migrateInvoiceFromData(int $whmcsInvoiceId, array $invoiceData, ?array $detailData): bool
{
if ($this->state->getMapping('invoices', $whmcsInvoiceId) !== null) {
$this->logger->debug("Invoice {$whmcsInvoiceId} already mapped, skipping");
return false;
}
$whmcsUserId = (int) ($invoiceData['userid'] ?? 0);
if ($whmcsUserId === 0) {
$this->logger->warning("Invoice {$whmcsInvoiceId} has no userid, skipping");
$this->logSkipped('invoice', "No userid for invoice {$whmcsInvoiceId}");
return false;
}
$ezscaleUserId = $this->state->getMapping('clients', $whmcsUserId);
if ($ezscaleUserId === null) {
$this->logger->warning("Invoice {$whmcsInvoiceId} belongs to unmapped client {$whmcsUserId}, skipping");
$this->logSkipped('invoice', "Unmapped client {$whmcsUserId} for invoice {$whmcsInvoiceId}");
return false;
}
$gatewayInvoiceId = "whmcs_{$whmcsInvoiceId}";
if ($this->db->tableHasRow('invoices', ['gateway_invoice_id' => $gatewayInvoiceId])) {
$this->logger->warning("Invoice with gateway_invoice_id '{$gatewayInvoiceId}' already exists in DB, skipping");
$this->logSkipped('invoice', "Duplicate gateway_invoice_id {$gatewayInvoiceId}");
return false;
}
$items = [];
if ($detailData !== null) {
$items = $detailData['items']['item'] ?? [];
}
$status = StatusMapper::mapInvoiceStatus((string) ($invoiceData['status'] ?? 'Unpaid'));
$gateway = StatusMapper::mapGateway((string) ($invoiceData['paymentmethod'] ?? ''));
$total = (string) ($invoiceData['total'] ?? '0.00');
$tax = bcadd(
(string) ($invoiceData['tax'] ?? '0.00'),
(string) ($invoiceData['tax2'] ?? '0.00'),
2,
);
$currency = $this->nullIfEmpty((string) ($invoiceData['currencycode'] ?? '')) ?? 'USD';
$dueDate = $this->parseDate((string) ($invoiceData['duedate'] ?? ''));
$paidAt = $this->parsePaidAt($invoiceData);
$createdAt = $this->parseDate((string) ($invoiceData['date'] ?? ''));
$now = date('Y-m-d H:i:s');
if ($this->isDryRun()) {
$this->logger->info("[DRY RUN] Would create invoice for WHMCS invoice {$whmcsInvoiceId}: user={$ezscaleUserId}, total={$total}, status={$status}, items=" . count($items));
return true;
}
$this->db->beginTransaction();
try {
$newInvoiceId = $this->db->insert('invoices', [
'user_id' => $ezscaleUserId,
'subscription_id' => null,
'gateway' => $gateway,
'gateway_invoice_id' => $gatewayInvoiceId,
'number' => "WHMCS-{$whmcsInvoiceId}",
'total' => $total,
'tax' => $tax,
'currency' => $currency,
'status' => $status,
'due_date' => $dueDate,
'paid_at' => $paidAt,
'notes' => 'Imported from WHMCS',
'created_at' => $createdAt ?? $now,
'updated_at' => $now,
]);
foreach ($items as $item) {
$description = trim((string) ($item['description'] ?? ''));
if ($description === '') {
$description = 'Invoice item';
}
if (mb_strlen($description) > 255) {
$description = mb_substr($description, 0, 252) . '...';
}
$this->db->insert('invoice_items', [
'invoice_id' => $newInvoiceId,
'description' => $description,
'amount' => (string) ($item['amount'] ?? '0.00'),
'quantity' => 1,
]);
}
$this->db->commit();
$this->state->setMapping('invoices', $whmcsInvoiceId, $newInvoiceId);
$this->logger->debug("Migrated invoice {$whmcsInvoiceId} → invoice {$newInvoiceId} (user={$ezscaleUserId}, total={$total}, items=" . count($items) . ')');
return true;
} catch (Throwable $e) {
$this->db->rollback();
throw $e;
}
}
private function parsePaidAt(array $invoiceData): ?string
{
$status = (string) ($invoiceData['status'] ?? '');
if ($status !== 'Paid') {
return null;
}
$datePaid = trim((string) ($invoiceData['datepaid'] ?? ''));
if ($datePaid === '' || str_starts_with($datePaid, '0000-00-00')) {
return null;
}
return $this->parseDate($datePaid);
}
private function validateDb(): bool
{
$result = $this->db->queryOne(
"SELECT COUNT(*) AS cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table",
['table' => 'invoices'],
);
if ($result === null || (int) $result['cnt'] === 0) {
$this->logger->error('Validation failed: "invoices" table does not exist');
return false;
}
$result = $this->db->queryOne(
"SELECT COUNT(*) AS cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table",
['table' => 'invoice_items'],
);
if ($result === null || (int) $result['cnt'] === 0) {
$this->logger->error('Validation failed: "invoice_items" table does not exist');
return false;
}
$clientCount = $this->state->getMappingCount('clients');
if ($clientCount === 0) {
$this->logger->error('Validation failed: no client mappings found — Phase 1 must run first');
return false;
}
return true;
}
private function validateApi(): bool
{
try {
$response = $this->api->call('GetInvoices', ['limitstart' => 0, 'limitnum' => 1]);
if (($response['result'] ?? '') !== 'success') {
$this->logger->error('Validation failed: WHMCS GetInvoices API returned non-success', [
'response' => $response,
]);
return false;
}
} catch (Throwable $e) {
$this->logger->error('Validation failed: cannot reach WHMCS API', [
'error' => $e->getMessage(),
]);
return false;
}
return true;
}
private function nullIfEmpty(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function parseDate(string $date): ?string
{
$date = trim($date);
if ($date === '' || str_starts_with($date, '0000-00-00')) {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
return $date;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date . ' 00:00:00';
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return null;
}
return date('Y-m-d H:i:s', $timestamp);
}
}

View File

@@ -0,0 +1,438 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate\Phases;
use Throwable;
use WhmcsMigrate\ProgressTracker;
use WhmcsMigrate\StatusMapper;
final class Phase5Transactions extends AbstractPhase
{
public function getPhaseNumber(): int
{
return 5;
}
public function getName(): string
{
return 'Transactions → PaymentTransactions';
}
public function validate(): bool
{
if ($this->isExportOnly()) {
return $this->validateApi();
}
if ($this->isFromExport()) {
return $this->validateDb();
}
return $this->validateDb() && $this->validateApi();
}
public function run(): void
{
if (! $this->isExportOnly() && $this->shouldSkip()) {
$this->logger->info('Phase 5 already complete, skipping');
return;
}
$this->logger->section('Phase 5: ' . $this->getName());
if ($this->isExportOnly()) {
$this->runExport();
return;
}
if ($this->isFromExport()) {
$this->runFromExport();
return;
}
$this->runFromApi();
}
private function runExport(): void
{
$this->logger->info('Exporting transactions from WHMCS API...');
$this->exportManager->resetExport('transactions');
$probeResponse = $this->api->call('GetTransactions', ['limitstart' => 0, 'limitnum' => 1]);
$totalTransactions = (int) ($probeResponse['totalresults'] ?? 0);
$this->logger->info("Found {$totalTransactions} transactions in WHMCS");
if ($totalTransactions === 0) {
return;
}
$batchSize = $this->getBatchSize();
$offset = 0;
$exported = 0;
$tracker = new ProgressTracker('Exporting txns', $totalTransactions);
while ($offset < $totalTransactions) {
$response = $this->api->call('GetTransactions', [
'limitstart' => $offset,
'limitnum' => $batchSize,
]);
$transactions = $response['transactions']['transaction'] ?? [];
if (empty($transactions)) {
break;
}
$this->exportManager->writeBatch('transactions', $transactions);
$exported += count($transactions);
$offset += count($transactions);
$tracker->setCurrent($offset);
}
$tracker->finish();
$this->logger->info("Exported {$exported} transaction records to JSONL");
}
private function runFromExport(): void
{
if (! $this->exportManager->hasExport('transactions')) {
$this->logger->error('No transactions export found. Run --export-only first.');
return;
}
$this->markStarted();
$totalTransactions = $this->exportManager->countRecords('transactions');
$this->logger->info("Found {$totalTransactions} transaction records in export");
if ($totalTransactions === 0) {
$this->markComplete(0, 0);
return;
}
$progress = $this->state->getProgress($this->getPhaseKey());
$processedCount = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
if ($processedCount > 0) {
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
}
$tracker = new ProgressTracker('Importing txns', $totalTransactions);
$tracker->setCurrent($processedCount);
$currentIndex = 0;
foreach ($this->exportManager->readRecords('transactions') as $transaction) {
$currentIndex++;
if ($currentIndex <= $processedCount) {
continue;
}
$whmcsTxId = (int) ($transaction['id'] ?? 0);
if ($whmcsTxId === 0) {
$errors++;
$processedCount++;
$tracker->advance();
continue;
}
try {
$result = $this->migrateTransaction($whmcsTxId, $transaction);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate transaction {$whmcsTxId}", [
'error' => $e->getMessage(),
]);
}
$processedCount++;
$tracker->advance();
if ($processedCount % 100 === 0) {
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
$this->state->save();
}
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 5 complete: {$count} transactions migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
private function runFromApi(): void
{
$this->markStarted();
$probeResponse = $this->api->call('GetTransactions', ['limitstart' => 0, 'limitnum' => 1]);
$totalTransactions = (int) ($probeResponse['totalresults'] ?? 0);
$this->logger->info("Found {$totalTransactions} transactions in WHMCS");
if ($totalTransactions === 0) {
$this->markComplete(0, 0);
return;
}
$progress = $this->state->getProgress($this->getPhaseKey());
$offset = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
$batchSize = $this->getBatchSize();
if ($offset > 0) {
$this->logger->info("Resuming from offset {$offset} ({$count} migrated, {$errors} errors so far)");
}
$tracker = new ProgressTracker('Migrating txns', $totalTransactions);
$tracker->setCurrent($offset);
while ($offset < $totalTransactions) {
$this->logger->debug("Fetching transactions batch: offset={$offset}, limit={$batchSize}");
$response = $this->api->call('GetTransactions', [
'limitstart' => $offset,
'limitnum' => $batchSize,
]);
$transactions = $response['transactions']['transaction'] ?? [];
if (empty($transactions)) {
$this->logger->debug('No more transactions returned, ending pagination');
break;
}
foreach ($transactions as $transaction) {
$whmcsTxId = (int) ($transaction['id'] ?? 0);
if ($whmcsTxId === 0) {
$this->logger->warning('Skipping transaction with no ID', ['transaction' => $transaction]);
$errors++;
continue;
}
try {
$result = $this->migrateTransaction($whmcsTxId, $transaction);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate transaction {$whmcsTxId}", [
'error' => $e->getMessage(),
]);
}
$tracker->advance();
}
$offset += count($transactions);
$this->state->setProgress($this->getPhaseKey(), 'running', $offset, $count, $errors);
$this->state->save();
$this->logger->info("Batch complete: {$count} migrated, {$errors} errors, offset now {$offset}/{$totalTransactions}");
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 5 complete: {$count} transactions migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
/**
* Migrate a single WHMCS transaction to EZSCALE.
*/
private function migrateTransaction(int $whmcsTxId, array $txData): bool
{
if ($this->state->getMapping('transactions', $whmcsTxId) !== null) {
$this->logger->debug("Transaction {$whmcsTxId} already mapped, skipping");
return false;
}
$whmcsUserId = (int) ($txData['userid'] ?? 0);
if ($whmcsUserId === 0) {
$this->logger->warning("Transaction {$whmcsTxId} has no userid (userid=0), skipping");
$this->logSkipped('transaction', "No userid for transaction {$whmcsTxId}");
return false;
}
$ezscaleUserId = $this->state->getMapping('clients', $whmcsUserId);
if ($ezscaleUserId === null) {
$this->logger->warning("Transaction {$whmcsTxId} belongs to unmapped client {$whmcsUserId}, skipping");
$this->logSkipped('transaction', "Unmapped client {$whmcsUserId} for transaction {$whmcsTxId}");
return false;
}
$amountIn = (float) ($txData['amountin'] ?? '0.00');
$amountOut = (float) ($txData['amountout'] ?? '0.00');
if ($amountIn == 0.0 && $amountOut == 0.0) {
$this->logger->debug("Transaction {$whmcsTxId} has zero amount, skipping");
$this->logSkipped('transaction', "Zero amount for transaction {$whmcsTxId}");
return false;
}
$transId = trim((string) ($txData['transid'] ?? ''));
$gatewayTransactionId = $transId !== '' ? $transId : "whmcs_tx_{$whmcsTxId}";
if ($transId !== '' && $this->db->tableHasRow('payment_transactions', ['gateway_transaction_id' => $gatewayTransactionId])) {
$this->logger->warning("Transaction with gateway_transaction_id '{$gatewayTransactionId}' already exists in DB, skipping");
$this->logSkipped('transaction', "Duplicate gateway_transaction_id {$gatewayTransactionId}");
return false;
}
$amount = round($amountIn - $amountOut, 2);
$status = $amount >= 0 ? 'succeeded' : 'refunded';
$whmcsInvoiceId = (int) ($txData['invoiceid'] ?? 0);
$ezscaleInvoiceId = $whmcsInvoiceId > 0
? $this->state->getMapping('invoices', $whmcsInvoiceId)
: null;
$gateway = StatusMapper::mapGateway((string) ($txData['gateway'] ?? ''));
$description = $this->nullIfEmpty((string) ($txData['description'] ?? ''));
$createdAt = $this->parseDate((string) ($txData['date'] ?? ''));
$now = date('Y-m-d H:i:s');
$metadata = json_encode([
'whmcs_id' => $whmcsTxId,
'whmcs_invoiceid' => $whmcsInvoiceId,
'amountin' => (string) ($txData['amountin'] ?? '0.00'),
'amountout' => (string) ($txData['amountout'] ?? '0.00'),
], JSON_THROW_ON_ERROR);
if ($this->isDryRun()) {
$this->logger->info("[DRY RUN] Would create transaction for WHMCS tx {$whmcsTxId}: user={$ezscaleUserId}, amount={$amount}, status={$status}, gateway={$gateway}");
return true;
}
$newTxId = $this->db->insert('payment_transactions', [
'user_id' => $ezscaleUserId,
'subscription_id' => null,
'invoice_id' => $ezscaleInvoiceId,
'gateway' => $gateway,
'gateway_transaction_id' => $gatewayTransactionId,
'amount' => (string) $amount,
'currency' => 'USD',
'status' => $status,
'payment_method' => null,
'description' => $description,
'metadata' => $metadata,
'created_at' => $createdAt ?? $now,
'updated_at' => $now,
]);
$this->state->setMapping('transactions', $whmcsTxId, $newTxId);
$this->logger->debug("Migrated transaction {$whmcsTxId} → payment_transaction {$newTxId} (user={$ezscaleUserId}, amount={$amount}, status={$status})");
return true;
}
private function validateDb(): bool
{
$result = $this->db->queryOne(
"SELECT COUNT(*) AS cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table",
['table' => 'payment_transactions'],
);
if ($result === null || (int) $result['cnt'] === 0) {
$this->logger->error('Validation failed: "payment_transactions" table does not exist');
return false;
}
$clientCount = $this->state->getMappingCount('clients');
if ($clientCount === 0) {
$this->logger->error('Validation failed: no client mappings found — Phase 1 must run first');
return false;
}
return true;
}
private function validateApi(): bool
{
try {
$response = $this->api->call('GetTransactions', ['limitstart' => 0, 'limitnum' => 1]);
if (($response['result'] ?? '') !== 'success') {
$this->logger->error('Validation failed: WHMCS GetTransactions API returned non-success', [
'response' => $response,
]);
return false;
}
} catch (Throwable $e) {
$this->logger->error('Validation failed: cannot reach WHMCS API', [
'error' => $e->getMessage(),
]);
return false;
}
return true;
}
private function nullIfEmpty(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function parseDate(string $date): ?string
{
$date = trim($date);
if ($date === '' || str_starts_with($date, '0000-00-00')) {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
return $date;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date . ' 00:00:00';
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return null;
}
return date('Y-m-d H:i:s', $timestamp);
}
}

View File

@@ -0,0 +1,324 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate\Phases;
use Throwable;
use WhmcsMigrate\ProgressTracker;
final class Phase6Coupons extends AbstractPhase
{
public function getPhaseNumber(): int
{
return 6;
}
public function getName(): string
{
return 'Promotions → Coupons';
}
public function validate(): bool
{
// Promotions might not be available in all WHMCS versions,
// so validation always passes — the run() method handles failures gracefully.
return true;
}
public function run(): void
{
if (! $this->isExportOnly() && $this->shouldSkip()) {
$this->logger->info('Phase 6 already complete, skipping');
return;
}
$this->logger->section('Phase 6: ' . $this->getName());
if ($this->isExportOnly()) {
$this->runExport();
return;
}
if ($this->isFromExport()) {
$this->runFromExport();
return;
}
$this->runFromApi();
}
private function runExport(): void
{
$this->logger->info('Exporting promotions from WHMCS API...');
$this->exportManager->resetExport('promotions');
try {
$response = $this->api->call('GetPromotions');
if (($response['result'] ?? '') !== 'success') {
$this->logger->warning('GetPromotions API returned non-success, skipping');
return;
}
$promotions = $response['promotions']['promotion'] ?? [];
} catch (Throwable $e) {
$this->logger->warning('GetPromotions API call failed, skipping', [
'error' => $e->getMessage(),
]);
return;
}
$totalPromotions = (int) ($response['totalresults'] ?? count($promotions));
$this->logger->info("Found {$totalPromotions} promotions in WHMCS");
if (empty($promotions)) {
return;
}
$this->exportManager->writeBatch('promotions', $promotions);
$this->logger->info("Exported {$totalPromotions} promotion records to JSONL");
}
private function runFromExport(): void
{
if (! $this->exportManager->hasExport('promotions')) {
$this->logger->warning('No promotions export found. Skipping coupon migration.');
$this->markComplete(0, 0);
return;
}
$this->markStarted();
$promotions = $this->exportManager->readAll('promotions');
$totalPromotions = count($promotions);
$this->logger->info("Found {$totalPromotions} promotions in export");
if ($totalPromotions === 0) {
$this->markComplete(0, 0);
return;
}
$count = 0;
$errors = 0;
$tracker = new ProgressTracker('Importing coupons', $totalPromotions);
foreach ($promotions as $promotion) {
$whmcsPromoId = (int) ($promotion['id'] ?? 0);
if ($whmcsPromoId === 0) {
$this->logger->warning('Skipping promotion with no ID', ['promotion' => $promotion]);
$errors++;
$tracker->advance();
continue;
}
try {
$result = $this->migratePromotion($whmcsPromoId, $promotion);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate promotion {$whmcsPromoId}", [
'error' => $e->getMessage(),
]);
}
$tracker->advance();
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 6 complete: {$count} coupons migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
private function runFromApi(): void
{
$this->markStarted();
$promotions = [];
try {
$response = $this->api->call('GetPromotions');
if (($response['result'] ?? '') !== 'success') {
$this->logger->warning('GetPromotions API returned non-success, skipping coupon migration', [
'response' => $response,
]);
$this->markComplete(0, 0);
return;
}
$promotions = $response['promotions']['promotion'] ?? [];
} catch (Throwable $e) {
$this->logger->warning('GetPromotions API call failed, skipping coupon migration', [
'error' => $e->getMessage(),
]);
$this->markComplete(0, 0);
return;
}
$totalPromotions = (int) ($response['totalresults'] ?? count($promotions));
$this->logger->info("Found {$totalPromotions} promotions in WHMCS");
if (empty($promotions)) {
$this->markComplete(0, 0);
return;
}
$count = 0;
$errors = 0;
$tracker = new ProgressTracker('Migrating coupons', $totalPromotions);
foreach ($promotions as $promotion) {
$whmcsPromoId = (int) ($promotion['id'] ?? 0);
if ($whmcsPromoId === 0) {
$this->logger->warning('Skipping promotion with no ID', ['promotion' => $promotion]);
$errors++;
$tracker->advance();
continue;
}
try {
$result = $this->migratePromotion($whmcsPromoId, $promotion);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate promotion {$whmcsPromoId}", [
'error' => $e->getMessage(),
]);
}
$tracker->advance();
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 6 complete: {$count} coupons migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
/**
* Migrate a single WHMCS promotion to an EZSCALE coupon.
*/
private function migratePromotion(int $whmcsPromoId, array $promoData): bool
{
if ($this->state->getMapping('promotions', $whmcsPromoId) !== null) {
$this->logger->debug("Promotion {$whmcsPromoId} already mapped, skipping");
return false;
}
$code = strtoupper(trim((string) ($promoData['code'] ?? '')));
if ($code === '') {
$this->logger->warning("Promotion {$whmcsPromoId} has no code, skipping");
$this->logSkipped('promotion', "No code for promotion {$whmcsPromoId}");
return false;
}
if ($this->db->tableHasRow('coupons', ['code' => $code])) {
$this->logger->warning("Coupon with code '{$code}' already exists in DB, skipping");
$this->logSkipped('promotion', "Duplicate code {$code} for promotion {$whmcsPromoId}");
return false;
}
$whmcsType = trim((string) ($promoData['type'] ?? ''));
$type = ($whmcsType === 'Percentage') ? 'percentage' : 'fixed';
$value = (string) ($promoData['value'] ?? '0.00');
$currency = ($type === 'fixed') ? 'USD' : null;
$maxUses = (int) ($promoData['maxuses'] ?? 0);
$maxUsesValue = ($maxUses > 0) ? $maxUses : null;
$timesUsed = (int) ($promoData['uses'] ?? 0);
$expirationDate = $this->parseExpirationDate((string) ($promoData['expirationdate'] ?? ''));
$active = $this->isPromotionActive($expirationDate);
$now = date('Y-m-d H:i:s');
if ($this->isDryRun()) {
$this->logger->info("[DRY RUN] Would create coupon for WHMCS promotion {$whmcsPromoId}: code={$code}, type={$type}, value={$value}, active=" . ($active ? 'yes' : 'no'));
return true;
}
$newCouponId = $this->db->insert('coupons', [
'code' => $code,
'type' => $type,
'value' => $value,
'currency' => $currency,
'applies_to' => null,
'max_uses' => $maxUsesValue,
'times_used' => $timesUsed,
'active' => $active ? 1 : 0,
'expires_at' => $expirationDate,
'created_at' => $now,
'updated_at' => $now,
]);
$this->state->setMapping('promotions', $whmcsPromoId, $newCouponId);
$this->logger->debug("Migrated promotion {$whmcsPromoId} → coupon {$newCouponId} (code={$code}, type={$type}, value={$value})");
return true;
}
private function parseExpirationDate(string $date): ?string
{
$date = trim($date);
if ($date === '' || str_starts_with($date, '0000-00-00')) {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date . ' 23:59:59';
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
return $date;
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return null;
}
return date('Y-m-d H:i:s', $timestamp);
}
private function isPromotionActive(?string $expirationDate): bool
{
if ($expirationDate === null) {
return true;
}
$expirationTimestamp = strtotime($expirationDate);
if ($expirationTimestamp === false) {
return true;
}
return $expirationTimestamp > time();
}
}

View File

@@ -0,0 +1,464 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate\Phases;
use Throwable;
use WhmcsMigrate\ProgressTracker;
use WhmcsMigrate\StatusMapper;
final class Phase7Orders extends AbstractPhase
{
/**
* WHMCS order status -> EZSCALE order status mapping.
*/
private const array STATUS_MAP = [
'Active' => 'completed',
'Pending' => 'pending',
'Fraud' => 'cancelled',
'Cancelled' => 'cancelled',
'Suspended' => 'cancelled',
];
public function getPhaseNumber(): int
{
return 7;
}
public function getName(): string
{
return 'Orders → Orders';
}
public function validate(): bool
{
if ($this->isExportOnly()) {
return $this->validateApi();
}
if ($this->isFromExport()) {
return $this->validateDb();
}
return $this->validateDb() && $this->validateApi();
}
public function run(): void
{
if (! $this->isExportOnly() && $this->shouldSkip()) {
$this->logger->info('Phase 7 already complete, skipping');
return;
}
$this->logger->section('Phase 7: ' . $this->getName());
if ($this->isExportOnly()) {
$this->runExport();
return;
}
if ($this->isFromExport()) {
$this->runFromExport();
return;
}
$this->runFromApi();
}
private function runExport(): void
{
$this->logger->info('Exporting orders from WHMCS API...');
$this->exportManager->resetExport('orders');
$probeResponse = $this->api->call('GetOrders', ['limitstart' => 0, 'limitnum' => 1]);
$totalOrders = (int) ($probeResponse['totalresults'] ?? 0);
$this->logger->info("Found {$totalOrders} orders in WHMCS");
if ($totalOrders === 0) {
return;
}
$batchSize = $this->getBatchSize();
$offset = 0;
$exported = 0;
$tracker = new ProgressTracker('Exporting orders', $totalOrders);
while ($offset < $totalOrders) {
$response = $this->api->call('GetOrders', [
'limitstart' => $offset,
'limitnum' => $batchSize,
]);
$orders = $response['orders']['order'] ?? [];
if (empty($orders)) {
break;
}
$this->exportManager->writeBatch('orders', $orders);
$exported += count($orders);
$offset += count($orders);
$tracker->setCurrent($offset);
}
$tracker->finish();
$this->logger->info("Exported {$exported} order records to JSONL");
}
private function runFromExport(): void
{
if (! $this->exportManager->hasExport('orders')) {
$this->logger->error('No orders export found. Run --export-only first.');
return;
}
$this->markStarted();
$totalOrders = $this->exportManager->countRecords('orders');
$this->logger->info("Found {$totalOrders} order records in export");
if ($totalOrders === 0) {
$this->markComplete(0, 0);
return;
}
$progress = $this->state->getProgress($this->getPhaseKey());
$processedCount = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
if ($processedCount > 0) {
$this->logger->info("Resuming from record {$processedCount} ({$count} migrated, {$errors} errors so far)");
}
$tracker = new ProgressTracker('Importing orders', $totalOrders);
$tracker->setCurrent($processedCount);
$currentIndex = 0;
foreach ($this->exportManager->readRecords('orders') as $order) {
$currentIndex++;
if ($currentIndex <= $processedCount) {
continue;
}
$whmcsOrderId = (int) ($order['id'] ?? 0);
if ($whmcsOrderId === 0) {
$errors++;
$processedCount++;
$tracker->advance();
continue;
}
try {
$result = $this->migrateOrder($whmcsOrderId, $order);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate order {$whmcsOrderId}", [
'error' => $e->getMessage(),
]);
}
$processedCount++;
$tracker->advance();
if ($processedCount % 100 === 0) {
$this->state->setProgress($this->getPhaseKey(), 'running', $processedCount, $count, $errors);
$this->state->save();
}
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 7 complete: {$count} orders migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
private function runFromApi(): void
{
$this->markStarted();
$probeResponse = $this->api->call('GetOrders', ['limitstart' => 0, 'limitnum' => 1]);
$totalOrders = (int) ($probeResponse['totalresults'] ?? 0);
$this->logger->info("Found {$totalOrders} orders in WHMCS");
if ($totalOrders === 0) {
$this->markComplete(0, 0);
return;
}
$progress = $this->state->getProgress($this->getPhaseKey());
$offset = (int) ($progress['offset'] ?? 0);
$count = (int) ($progress['count'] ?? 0);
$errors = (int) ($progress['errors'] ?? 0);
$batchSize = $this->getBatchSize();
if ($offset > 0) {
$this->logger->info("Resuming from offset {$offset} ({$count} migrated, {$errors} errors so far)");
}
$tracker = new ProgressTracker('Migrating orders', $totalOrders);
$tracker->setCurrent($offset);
while ($offset < $totalOrders) {
$this->logger->debug("Fetching orders batch: offset={$offset}, limit={$batchSize}");
$response = $this->api->call('GetOrders', [
'limitstart' => $offset,
'limitnum' => $batchSize,
]);
$orders = $response['orders']['order'] ?? [];
if (empty($orders)) {
$this->logger->debug('No more orders returned, ending pagination');
break;
}
foreach ($orders as $order) {
$whmcsOrderId = (int) ($order['id'] ?? 0);
if ($whmcsOrderId === 0) {
$this->logger->warning('Skipping order with no ID', ['order' => $order]);
$errors++;
continue;
}
try {
$result = $this->migrateOrder($whmcsOrderId, $order);
if ($result) {
$count++;
}
} catch (Throwable $e) {
$errors++;
$this->logger->error("Failed to migrate order {$whmcsOrderId}", [
'error' => $e->getMessage(),
]);
}
$tracker->advance();
}
$offset += count($orders);
$this->state->setProgress($this->getPhaseKey(), 'running', $offset, $count, $errors);
$this->state->save();
$this->logger->info("Batch complete: {$count} migrated, {$errors} errors, offset now {$offset}/{$totalOrders}");
}
$tracker->finish();
$this->markComplete($count, $errors);
$this->logger->info("Phase 7 complete: {$count} orders migrated, {$errors} errors (elapsed: {$tracker->getElapsed()})");
}
/**
* Migrate a single WHMCS order.
*/
private function migrateOrder(int $whmcsOrderId, array $orderData): bool
{
if ($this->state->getMapping('orders', $whmcsOrderId) !== null) {
$this->logger->debug("Order {$whmcsOrderId} already mapped, skipping");
return false;
}
$whmcsUserId = (int) ($orderData['userid'] ?? 0);
if ($whmcsUserId === 0) {
$this->logger->warning("Order {$whmcsOrderId} has no userid, skipping");
$this->logSkipped('order', "No userid for order {$whmcsOrderId}");
return false;
}
$ezscaleUserId = $this->state->getMapping('clients', $whmcsUserId);
if ($ezscaleUserId === null) {
$this->logger->warning("Order {$whmcsOrderId} belongs to unmapped client {$whmcsUserId}, skipping");
$this->logSkipped('order', "Unmapped client {$whmcsUserId} for order {$whmcsOrderId}");
return false;
}
$orderNum = trim((string) ($orderData['ordernum'] ?? ''));
$orderNumber = $orderNum !== '' ? "WHMCS-{$orderNum}" : "WHMCS-ORD-{$whmcsOrderId}";
if ($this->db->tableHasRow('orders', ['order_number' => $orderNumber])) {
$this->logger->warning("Order with order_number '{$orderNumber}' already exists in DB, skipping");
$this->logSkipped('order', "Duplicate order_number {$orderNumber}");
return false;
}
$planId = $this->resolvePlanId($orderData);
$whmcsInvoiceId = (int) ($orderData['invoiceid'] ?? 0);
$ezscaleInvoiceId = $whmcsInvoiceId > 0
? $this->state->getMapping('invoices', $whmcsInvoiceId)
: null;
$whmcsStatus = (string) ($orderData['status'] ?? 'Pending');
$status = self::STATUS_MAP[$whmcsStatus] ?? 'pending';
$total = (string) ($orderData['amount'] ?? '0.00');
$gateway = StatusMapper::mapGateway((string) ($orderData['paymentmethod'] ?? ''));
$notes = $this->nullIfEmpty((string) ($orderData['notes'] ?? ''));
$adminNotes = "Imported from WHMCS. Order ID: {$whmcsOrderId}" . ($notes !== null ? ". Notes: {$notes}" : '');
$createdAt = $this->parseDate((string) ($orderData['date'] ?? ''));
$now = date('Y-m-d H:i:s');
$isCompleted = $status === 'completed';
$isCancelled = $status === 'cancelled';
if ($this->isDryRun()) {
$this->logger->info("[DRY RUN] Would create order for WHMCS order {$whmcsOrderId}: user={$ezscaleUserId}, total={$total}, status={$status}");
return true;
}
$newOrderId = $this->db->insert('orders', [
'user_id' => $ezscaleUserId,
'plan_id' => $planId,
'invoice_id' => $ezscaleInvoiceId,
'service_id' => null,
'order_number' => $orderNumber,
'status' => $status,
'total' => $total,
'currency' => 'USD',
'payment_gateway' => $gateway,
'configuration' => null,
'admin_notes' => mb_strlen($adminNotes) > 65535 ? mb_substr($adminNotes, 0, 65532) . '...' : $adminNotes,
'completed_at' => $isCompleted ? ($createdAt ?? $now) : null,
'cancelled_at' => $isCancelled ? $now : null,
'created_at' => $createdAt ?? $now,
'updated_at' => $now,
]);
$this->state->setMapping('orders', $whmcsOrderId, $newOrderId);
$this->logger->debug("Migrated order {$whmcsOrderId} → order {$newOrderId} (user={$ezscaleUserId}, total={$total}, status={$status})");
return true;
}
private function resolvePlanId(array $orderData): int
{
$lineItems = $orderData['lineitems']['lineitem'] ?? [];
if (is_array($lineItems)) {
foreach ($lineItems as $item) {
$pid = (int) ($item['productid'] ?? $item['pid'] ?? 0);
if ($pid > 0) {
$planId = $this->state->getMapping('products', $pid);
if ($planId !== null) {
return $planId;
}
}
}
}
$fallback = $this->db->queryOne("SELECT `id` FROM `plans` LIMIT 1");
return $fallback !== null ? (int) $fallback['id'] : 1;
}
private function validateDb(): bool
{
$result = $this->db->queryOne(
"SELECT COUNT(*) AS cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table",
['table' => 'orders'],
);
if ($result === null || (int) $result['cnt'] === 0) {
$this->logger->error('Validation failed: "orders" table does not exist');
return false;
}
$clientCount = $this->state->getMappingCount('clients');
if ($clientCount === 0) {
$this->logger->error('Validation failed: no client mappings found — Phase 1 must run first');
return false;
}
return true;
}
private function validateApi(): bool
{
try {
$response = $this->api->call('GetOrders', ['limitstart' => 0, 'limitnum' => 1]);
if (($response['result'] ?? '') !== 'success') {
$this->logger->error('Validation failed: WHMCS GetOrders API returned non-success', [
'response' => $response,
]);
return false;
}
} catch (Throwable $e) {
$this->logger->error('Validation failed: cannot reach WHMCS GetOrders API', [
'error' => $e->getMessage(),
]);
return false;
}
return true;
}
private function nullIfEmpty(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function parseDate(string $date): ?string
{
$date = trim($date);
if ($date === '' || str_starts_with($date, '0000-00-00')) {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
return $date;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date . ' 00:00:00';
}
$timestamp = strtotime($date);
if ($timestamp === false) {
return null;
}
return date('Y-m-d H:i:s', $timestamp);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate\Phases;
interface PhaseInterface
{
/**
* Execute the migration phase.
*/
public function run(): void;
/**
* Validate prerequisites before running.
*/
public function validate(): bool;
/**
* Human-readable name for this phase.
*/
public function getName(): string;
/**
* Numeric order of this phase (1-6).
*/
public function getPhaseNumber(): int;
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
/**
* Tracks progress of long-running operations and renders a progress bar to the console.
*/
final class ProgressTracker
{
private int $total;
private int $current = 0;
private float $startTime;
private string $label;
private int $barWidth = 40;
private int $lastRenderedPercent = -1;
public function __construct(string $label, int $total)
{
$this->label = $label;
$this->total = max($total, 1);
$this->startTime = microtime(true);
}
/**
* Advance progress by the given amount.
*/
public function advance(int $amount = 1): void
{
$this->current += $amount;
$this->render();
}
/**
* Set the current progress value.
*/
public function setCurrent(int $current): void
{
$this->current = $current;
$this->render();
}
/**
* Mark the progress as complete and print a final summary line.
*/
public function finish(): void
{
$this->current = $this->total;
$this->render(force: true);
fprintf(STDOUT, "\n");
}
/**
* Render the progress bar to STDOUT.
*
* Only re-renders when the percentage changes (to avoid excessive terminal writes).
*/
private function render(bool $force = false): void
{
$percent = min(100, (int) round(($this->current / $this->total) * 100));
// Only re-render when the percentage changes or forced
if (! $force && $percent === $this->lastRenderedPercent) {
return;
}
$this->lastRenderedPercent = $percent;
$filled = (int) round(($percent / 100) * $this->barWidth);
$empty = $this->barWidth - $filled;
$bar = str_repeat('=', max(0, $filled - 1));
if ($filled > 0) {
$bar .= '>';
}
$bar .= str_repeat(' ', $empty);
// Calculate ETA
$elapsed = microtime(true) - $this->startTime;
$eta = '';
$rate = '';
if ($this->current > 0 && $elapsed > 0) {
$recordsPerSecond = $this->current / $elapsed;
$remaining = $this->total - $this->current;
$etaSeconds = $recordsPerSecond > 0 ? $remaining / $recordsPerSecond : 0;
$rate = sprintf('%.0f/s', $recordsPerSecond);
$eta = $this->formatDuration((int) $etaSeconds);
}
$line = sprintf(
"\r %s [%s] %3d%% %s/%s %s%s",
str_pad($this->label, 20),
$bar,
$percent,
number_format($this->current),
number_format($this->total),
$rate !== '' ? "({$rate})" : '',
$eta !== '' ? " ETA: {$eta}" : '',
);
// Pad to clear previous line remnants
$line = str_pad($line, 120);
fprintf(STDOUT, "%s", $line);
}
/**
* Format a duration in seconds to a human-readable string.
*/
private function formatDuration(int $seconds): string
{
if ($seconds <= 0) {
return '< 1s';
}
if ($seconds < 60) {
return "{$seconds}s";
}
$minutes = (int) floor($seconds / 60);
$remainingSeconds = $seconds % 60;
if ($minutes < 60) {
return "{$minutes}m {$remainingSeconds}s";
}
$hours = (int) floor($minutes / 60);
$remainingMinutes = $minutes % 60;
return "{$hours}h {$remainingMinutes}m";
}
/**
* Get the elapsed time since the tracker was started.
*/
public function getElapsed(): string
{
$elapsed = (int) (microtime(true) - $this->startTime);
return $this->formatDuration($elapsed);
}
/**
* Get the records per second rate.
*/
public function getRate(): float
{
$elapsed = microtime(true) - $this->startTime;
return $elapsed > 0 ? $this->current / $elapsed : 0;
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
final class StateManager
{
private string $stateDir;
/** @var array<string, array<string|int, int>> Entity type => [whmcsId => ezscaleId] */
private array $mappings = [];
/** @var array<string, array{status: string, offset: int, count: int, errors: int}> */
private array $progress = [];
public function __construct(string $stateDir)
{
$this->stateDir = $stateDir;
if (! is_dir($stateDir)) {
mkdir($stateDir, 0755, true);
}
$this->load();
}
/**
* Get the EZSCALE ID mapped to a WHMCS ID for a given entity type.
*/
public function getMapping(string $entity, int|string $whmcsId): ?int
{
$key = (string) $whmcsId;
return $this->mappings[$entity][$key] ?? null;
}
/**
* Store a WHMCS-to-EZSCALE ID mapping.
*/
public function setMapping(string $entity, int|string $whmcsId, int $ezscaleId): void
{
$key = (string) $whmcsId;
$this->mappings[$entity][$key] = $ezscaleId;
$this->save();
}
/**
* Get all mappings for a given entity type.
*
* @return array<string|int, int>
*/
public function getAllMappings(string $entity): array
{
return $this->mappings[$entity] ?? [];
}
/**
* Get the number of mappings for a given entity type.
*/
public function getMappingCount(string $entity): int
{
return count($this->mappings[$entity] ?? []);
}
/**
* Get the progress record for a phase.
*
* @return array{status: string, offset: int, count: int, errors: int}
*/
public function getProgress(string $phase): array
{
return $this->progress[$phase] ?? [
'status' => 'pending',
'offset' => 0,
'count' => 0,
'errors' => 0,
];
}
/**
* Set the progress record for a phase.
*/
public function setProgress(string $phase, string $status, int $offset = 0, int $count = 0, int $errors = 0): void
{
$this->progress[$phase] = [
'status' => $status,
'offset' => $offset,
'count' => $count,
'errors' => $errors,
];
$this->save();
}
/**
* Check if a phase has been marked complete.
*/
public function isPhaseComplete(string $phase): bool
{
return ($this->progress[$phase]['status'] ?? 'pending') === 'complete';
}
/**
* Delete all state files and reset in-memory state.
*/
public function reset(): void
{
$this->mappings = [];
$this->progress = [];
$mappingsFile = $this->stateDir . '/id_mappings.json';
$progressFile = $this->stateDir . '/progress.json';
if (file_exists($mappingsFile)) {
unlink($mappingsFile);
}
if (file_exists($progressFile)) {
unlink($progressFile);
}
}
/**
* Persist current state to disk (JSON files).
*/
public function save(): void
{
file_put_contents(
$this->stateDir . '/id_mappings.json',
json_encode($this->mappings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
);
file_put_contents(
$this->stateDir . '/progress.json',
json_encode($this->progress, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
);
}
/**
* Load state from disk if files exist.
*/
private function load(): void
{
$mappingsFile = $this->stateDir . '/id_mappings.json';
if (file_exists($mappingsFile)) {
$data = json_decode(file_get_contents($mappingsFile), true);
if (is_array($data)) {
$this->mappings = $data;
}
}
$progressFile = $this->stateDir . '/progress.json';
if (file_exists($progressFile)) {
$data = json_decode(file_get_contents($progressFile), true);
if (is_array($data)) {
$this->progress = $data;
}
}
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
final class StatusMapper
{
/**
* Map WHMCS client status to EZSCALE user status.
*/
public static function mapClientStatus(string $whmcsStatus): string
{
return match ($whmcsStatus) {
'Active' => 'active',
'Inactive' => 'suspended',
'Closed' => 'banned',
default => 'suspended',
};
}
/**
* Map WHMCS service/hosting status to EZSCALE service status.
*/
public static function mapServiceStatus(string $whmcsStatus): string
{
return match ($whmcsStatus) {
'Active' => 'active',
'Suspended' => 'suspended',
'Terminated' => 'terminated',
'Cancelled' => 'terminated',
'Pending' => 'pending',
default => 'pending',
};
}
/**
* Map WHMCS invoice status to EZSCALE invoice status.
*/
public static function mapInvoiceStatus(string $whmcsStatus): string
{
return match ($whmcsStatus) {
'Paid' => 'paid',
'Unpaid' => 'pending',
'Overdue' => 'overdue',
'Cancelled' => 'void',
'Refunded' => 'refunded',
'Draft' => 'draft',
default => 'pending',
};
}
/**
* Map WHMCS billing cycle to EZSCALE billing cycle.
*/
public static function mapBillingCycle(string $whmcsCycle): string
{
return match ($whmcsCycle) {
'Monthly' => 'monthly',
'Quarterly' => 'quarterly',
'Semi-Annually' => 'semi_annual',
'Annually' => 'annual',
'Biennially' => 'annual',
'Triennially' => 'annual',
'Free Account' => 'monthly',
'One Time' => 'monthly',
default => 'monthly',
};
}
/**
* Map WHMCS service status to Stripe subscription status.
*/
public static function mapStripeStatus(string $whmcsServiceStatus): string
{
return match ($whmcsServiceStatus) {
'Active' => 'active',
'Suspended' => 'past_due',
'Terminated' => 'canceled',
'Cancelled' => 'canceled',
'Pending' => 'incomplete',
default => 'incomplete',
};
}
/**
* Map WHMCS payment gateway module name to EZSCALE gateway identifier.
*/
public static function mapGateway(string $whmcsGateway): string
{
$normalized = strtolower(trim($whmcsGateway));
return match (true) {
str_contains($normalized, 'paypal') => 'paypal',
str_contains($normalized, 'stripe') => 'stripe',
str_contains($normalized, 'bank') => 'stripe',
str_contains($normalized, 'transfer') => 'stripe',
str_contains($normalized, 'credit') => 'stripe',
$normalized === '' => 'stripe',
default => 'stripe',
};
}
/**
* Heuristically map a WHMCS product group name (and optionally product name) to an EZSCALE service type.
*/
public static function mapServiceType(string $groupName, string $productName = ''): string
{
// Try group name first
$normalized = strtolower(trim($groupName));
if ($normalized !== '' && $normalized !== 'none') {
return match (true) {
str_contains($normalized, 'mysql') => 'mysql',
str_contains($normalized, 'veeam') || str_contains($normalized, 'backup') => 'backups',
str_contains($normalized, 'vps') || str_contains($normalized, 'cloud') || str_contains($normalized, 'virtual') => 'vps',
str_contains($normalized, 'dedicated') => 'dedicated',
str_contains($normalized, 'web') || str_contains($normalized, 'hosting') || str_contains($normalized, 'cpanel') => 'hosting',
str_contains($normalized, 'game') || str_contains($normalized, 'minecraft') || str_contains($normalized, 'rust') => 'game_server',
default => 'vps',
};
}
// Fall back to product name heuristics
$name = strtolower(trim($productName));
return match (true) {
// MySQL hosting
str_contains($name, 'mysql') => 'mysql',
// Backup services (Veeam, etc.)
str_contains($name, 'veeam') || str_contains($name, 'backup') => 'backups',
// Dedicated servers: Dell, HP, Storage Server with bay counts
str_contains($name, 'dell r') || str_contains($name, 'hp dl') || str_contains($name, 'storage server') => 'dedicated',
// VPS products
str_contains($name, 'vps') || str_contains($name, 'cloud') || str_contains($name, 'virtual') => 'vps',
// Game server related
str_contains($name, 'battlefield') || str_contains($name, 'procon') || str_contains($name, 'game') || str_contains($name, 'minecraft') => 'game_server',
// Managed dedicated
str_contains($name, 'managed dedicated') => 'dedicated',
// Web hosting tiers
str_contains($name, 'hosting') || str_contains($name, 'cpanel') => 'hosting',
default => 'other',
};
}
/**
* Map an EZSCALE service type to its provisioning platform.
*
* Returns null for service types that have no auto-provisioning platform.
*/
public static function mapPlatform(string $serviceType): ?string
{
return match ($serviceType) {
'vps' => 'virtfusion',
'dedicated' => 'synergycp',
'hosting' => 'enhance',
'game_server' => 'pterodactyl',
'mysql' => null,
'backups' => null,
default => 'virtfusion',
};
}
}

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
final class Validator
{
public function __construct(
private readonly Database $db,
private readonly Logger $logger,
) {}
/**
* Validate an array of client records from WHMCS.
*
* @param array<int, array<string, mixed>> $clients
*
* @return array{valid: int, skipped: int, errors: list<string>}
*/
public function validateClientsData(array $clients): array
{
$valid = 0;
$skipped = 0;
$errors = [];
$seenEmails = [];
foreach ($clients as $index => $client) {
$clientId = $client['id'] ?? $client['userid'] ?? "index:{$index}";
// Check required fields
if (empty($client['email'])) {
$errors[] = "Client #{$clientId}: missing email address.";
$skipped++;
continue;
}
if (empty($client['firstname'])) {
$errors[] = "Client #{$clientId}: missing first name.";
$skipped++;
continue;
}
// Check for duplicate emails in source data
$email = strtolower(trim($client['email']));
if (isset($seenEmails[$email])) {
$errors[] = "Client #{$clientId}: duplicate email '{$email}' (first seen in client #{$seenEmails[$email]}).";
$skipped++;
continue;
}
$seenEmails[$email] = $clientId;
$valid++;
}
return [
'valid' => $valid,
'skipped' => $skipped,
'errors' => $errors,
];
}
/**
* Validate an array of product records from WHMCS.
*
* @param array<int, array<string, mixed>> $products
*
* @return array{valid: int, skipped: int, errors: list<string>}
*/
public function validateProductsData(array $products): array
{
$valid = 0;
$skipped = 0;
$errors = [];
foreach ($products as $index => $product) {
$productId = $product['pid'] ?? $product['id'] ?? "index:{$index}";
if (empty($product['name'])) {
$errors[] = "Product #{$productId}: missing name.";
$skipped++;
continue;
}
// Check for pricing data — at least one pricing field should be present
$hasPricing = ! empty($product['pricing'])
|| isset($product['monthly'])
|| isset($product['quarterly'])
|| isset($product['semiannually'])
|| isset($product['annually']);
if (! $hasPricing) {
$errors[] = "Product #{$productId} ({$product['name']}): no pricing data found.";
$skipped++;
continue;
}
$valid++;
}
return [
'valid' => $valid,
'skipped' => $skipped,
'errors' => $errors,
];
}
/**
* Perform a full pre-flight validation by testing WHMCS API connectivity
* and fetching basic counts.
*/
public function validateAll(WhmcsApi $api): bool
{
$this->logger->section('Pre-flight Validation');
$allOk = true;
// Test WHMCS API connectivity
$this->logger->info('Testing WHMCS API connectivity...');
try {
$result = $api->call('GetClients', ['limitstart' => 0, 'limitnum' => 1]);
$totalClients = (int) ($result['totalresults'] ?? 0);
$this->logger->info("WHMCS API connected. Total clients: {$totalClients}");
} catch (\RuntimeException $e) {
$this->logger->error('WHMCS API connection failed: ' . $e->getMessage());
$allOk = false;
}
// Test product listing
try {
$result = $api->call('GetProducts', ['limitstart' => 0, 'limitnum' => 1]);
$totalProducts = (int) ($result['totalresults'] ?? 0);
$this->logger->info("WHMCS products found: {$totalProducts}");
} catch (\RuntimeException $e) {
$this->logger->error('WHMCS GetProducts failed: ' . $e->getMessage());
$allOk = false;
}
// Test EZSCALE database connectivity
$this->logger->info('Testing EZSCALE database connectivity...');
try {
$row = $this->db->queryOne('SELECT 1 AS ok');
if ($row !== null && ($row['ok'] ?? 0) === 1) {
$this->logger->info('EZSCALE database connected.');
} else {
$this->logger->error('EZSCALE database query returned unexpected result.');
$allOk = false;
}
} catch (\Throwable $e) {
$this->logger->error('EZSCALE database connection failed: ' . $e->getMessage());
$allOk = false;
}
// Verify essential tables exist
$requiredTables = ['users', 'user_profiles', 'plans', 'plan_prices', 'subscriptions', 'subscription_items', 'services', 'invoices', 'invoice_items', 'payment_transactions', 'coupons', 'roles', 'model_has_roles'];
foreach ($requiredTables as $table) {
try {
$this->db->query("SELECT 1 FROM `{$table}` LIMIT 0");
$this->logger->debug("Table '{$table}' exists.");
} catch (\Throwable $e) {
$this->logger->error("Required table '{$table}' is missing or inaccessible.");
$allOk = false;
}
}
if ($allOk) {
$this->logger->info('All pre-flight checks passed.');
} else {
$this->logger->error('Some pre-flight checks failed. Resolve issues before running the migration.');
}
return $allOk;
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace WhmcsMigrate;
use RuntimeException;
final class WhmcsApi
{
private string $apiUrl;
private string $identifier;
private string $secret;
private const int MAX_RETRIES = 3;
private const array BACKOFF_SECONDS = [1, 2, 4];
public function __construct(
private readonly Config $config,
private readonly Logger $logger,
) {
$this->apiUrl = $config->getRequired('WHMCS_API_URL');
$this->identifier = $config->getRequired('WHMCS_API_IDENTIFIER');
$this->secret = $config->getRequired('WHMCS_API_SECRET');
}
/**
* Call a WHMCS API action with optional parameters.
*
* Retries up to 3 times with exponential backoff on failure.
*
* @return array<string, mixed> The decoded JSON response.
*
* @throws RuntimeException On final failure after all retries.
*/
public function call(string $action, array $params = []): array
{
$postFields = array_merge($params, [
'identifier' => $this->identifier,
'secret' => $this->secret,
'action' => $action,
'responsetype' => 'json',
]);
$this->logger->debug("WHMCS API call: {$action}", [
'params' => array_diff_key($params, ['identifier' => 1, 'secret' => 1]),
]);
$lastException = null;
for ($attempt = 0; $attempt < self::MAX_RETRIES; $attempt++) {
try {
$result = $this->doRequest($postFields);
$this->logger->debug("WHMCS API response: {$action}", [
'result' => $result['result'] ?? 'unknown',
]);
return $result;
} catch (RuntimeException $e) {
$lastException = $e;
$backoff = self::BACKOFF_SECONDS[$attempt] ?? 4;
$retryMessage = sprintf(
'WHMCS API call failed (attempt %d/%d): %s, retrying in %ds',
$attempt + 1,
self::MAX_RETRIES,
$e->getMessage(),
$backoff,
);
$this->logger->warning($retryMessage);
if ($attempt < self::MAX_RETRIES - 1) {
sleep($backoff);
}
}
}
throw new RuntimeException(
"WHMCS API call '{$action}' failed after " . self::MAX_RETRIES . " attempts: " . $lastException->getMessage(),
0,
$lastException,
);
}
/**
* Execute the cURL request to the WHMCS API.
*
* @return array<string, mixed>
*
* @throws RuntimeException On cURL or JSON decode error.
*/
private function doRequest(array $postFields): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->apiUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($postFields),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'Accept: application/json',
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
$curlErrno = curl_errno($ch);
curl_close($ch);
if ($curlErrno !== 0) {
throw new RuntimeException("cURL error ({$curlErrno}): {$curlError}");
}
if ($httpCode < 200 || $httpCode >= 300) {
throw new RuntimeException("HTTP {$httpCode} response from WHMCS API");
}
if (! is_string($response) || $response === '') {
throw new RuntimeException('Empty response from WHMCS API');
}
$decoded = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Failed to decode WHMCS API response: ' . json_last_error_msg());
}
if (isset($decoded['result']) && $decoded['result'] === 'error') {
throw new RuntimeException('WHMCS API error: ' . ($decoded['message'] ?? 'Unknown error'));
}
return $decoded;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\LoginHistory;
use App\Models\TrustedDevice;
use Illuminate\Http\Request;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
/**
* Extends Fortify's 2FA redirect to skip the challenge when the
* current device is trusted (active, non-expired TrustedDevice record).
*/
class RedirectIfTwoFactorConfirmable extends RedirectIfTwoFactorAuthenticatable
{
/**
* Handle the incoming request.
*
* @param Request $request
* @param callable $next
* @return mixed
*/
public function handle($request, $next)
{
$user = $this->validateCredentials($request);
if ($this->shouldSkipTwoFactor($request, $user)) {
return $next($request);
}
return parent::handle($request, $next);
}
/**
* Determine if 2FA should be skipped because the device is trusted.
*/
protected function shouldSkipTwoFactor(Request $request, mixed $user): bool
{
if (! $user || empty($user->two_factor_secret)) {
return false;
}
$deviceHash = LoginHistory::generateDeviceHash(
$request->userAgent() ?? '',
$request->ip() ?? '127.0.0.1',
);
$trustedDevice = TrustedDevice::query()
->where('user_id', $user->getKey())
->where('device_hash', $deviceHash)
->active()
->first();
if ($trustedDevice) {
$trustedDevice->update(['last_used_at' => now()]);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\WinbackCampaign;
use App\Notifications\WinbackEmailNotification;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ProcessWinbackCampaigns extends Command
{
protected $signature = 'winback:process';
protected $description = 'Process active win-back campaigns and send scheduled emails';
public function handle(): int
{
$activeCampaigns = WinbackCampaign::active()->get();
if ($activeCampaigns->isEmpty()) {
$this->info('No active win-back campaigns found.');
return self::SUCCESS;
}
$emailsSent = 0;
foreach ($activeCampaigns as $campaign) {
$recipients = $campaign->recipients()
->where('reactivated', false)
->whereNull('unsubscribed_at')
->with('user')
->get();
foreach ($recipients as $recipient) {
if (! $recipient->isActive()) {
continue;
}
$emailSequence = $campaign->email_sequence;
// Check if all emails have been sent
if ($recipient->current_email_index >= count($emailSequence)) {
continue;
}
$currentEmail = $emailSequence[$recipient->current_email_index];
$delayDays = (int) $currentEmail['delay_days'];
// Determine the reference date (last email sent or enrollment date)
$referenceDate = $recipient->last_email_sent_at ?? $recipient->created_at;
// Check if enough time has elapsed
$daysSinceReference = (int) abs(now()->diffInDays($referenceDate));
if ($daysSinceReference < $delayDays) {
continue;
}
try {
$recipient->user->notify(new WinbackEmailNotification(
$campaign,
$recipient,
$currentEmail,
));
$recipient->update([
'current_email_index' => $recipient->current_email_index + 1,
'last_email_sent_at' => now(),
]);
$emailsSent++;
Log::info('Win-back email sent', [
'campaign_id' => $campaign->id,
'recipient_id' => $recipient->id,
'email_index' => $recipient->current_email_index,
]);
} catch (\Exception $e) {
Log::error('Failed to send win-back email', [
'campaign_id' => $campaign->id,
'recipient_id' => $recipient->id,
'error' => $e->getMessage(),
]);
}
}
}
$this->info("Win-back processing complete. {$emailsSent} email(s) sent.");
return self::SUCCESS;
}
}

View File

@@ -16,5 +16,6 @@ class SubscriptionCancelled
public function __construct( public function __construct(
public User $user, public User $user,
public Subscription $subscription, public Subscription $subscription,
public ?string $cancellationReason = null,
) {} ) {}
} }

View File

@@ -8,10 +8,15 @@ use App\Events\SubscriptionCreated;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Coupon; use App\Models\Coupon;
use App\Models\Plan; use App\Models\Plan;
use App\Models\PlanConfigGroup;
use App\Models\PlanConfigOption;
use App\Models\PlanConfigValue;
use App\Models\SubscriptionConfigSelection;
use App\Services\Billing\BillingServiceFactory; use App\Services\Billing\BillingServiceFactory;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -36,8 +41,8 @@ class CheckoutController extends Controller
if ($plan->service_type === 'vps') { if ($plan->service_type === 'vps') {
try { try {
$virtfusionService = app(\App\Services\Provisioning\VirtFusionService::class); $virtfusionService = app(\App\Services\Provisioning\VirtFusionService::class);
$hypervisorId = $plan->features['virtfusion_package_id'] ?? 1; $packageId = $plan->provisioning_config['package_id'] ?? null;
$templatesData = $virtfusionService->getTemplatesByPackage($hypervisorId); $templatesData = $virtfusionService->getTemplatesByPackage($packageId);
// Format flattened templates for backward compatibility // Format flattened templates for backward compatibility
$osTemplates = collect($templatesData['templates'])->map(fn ($t) => [ $osTemplates = collect($templatesData['templates'])->map(fn ($t) => [
@@ -68,8 +73,15 @@ class CheckoutController extends Controller
} }
} }
$configGroups = $plan->configGroups()
->active()
->with(['options' => fn ($q) => $q->active()->orderBy('sort_order'), 'options.values'])
->orderBy('sort_order')
->get();
return Inertia::render('Checkout/Show', [ return Inertia::render('Checkout/Show', [
'plan' => $plan->load('prices'), 'plan' => $plan->load('prices'),
'configGroups' => $configGroups,
'paymentMethods' => $stripeService->getPaymentMethods($user), 'paymentMethods' => $stripeService->getPaymentMethods($user),
'intent' => $user->hasStripeId() ? $user->createSetupIntent() : null, 'intent' => $user->hasStripeId() ? $user->createSetupIntent() : null,
'stripeKey' => config('cashier.key'), 'stripeKey' => config('cashier.key'),
@@ -78,6 +90,37 @@ class CheckoutController extends Controller
]); ]);
} }
public function showCustom(string $serviceType): Response
{
$plan = Plan::where('slug', "{$serviceType}-custom")->firstOrFail();
$configGroup = PlanConfigGroup::buildYourOwn()
->active()
->forServiceType($serviceType)
->with(['options' => fn ($q) => $q->active()->orderBy('sort_order'), 'options.values'])
->first();
if (! $configGroup) {
abort(404, 'Build Your Own is not available for this service type.');
}
// Use the same pattern as the existing show() method for payment methods
$stripeService = $this->billingFactory->make('stripe');
$paymentMethods = $stripeService->getPaymentMethods(request()->user());
$intent = request()->user()->hasStripeId() ? request()->user()->createSetupIntent() : null;
return Inertia::render('Checkout/Show', [
'plan' => $plan->load('prices'),
'configGroups' => [$configGroup],
'mode' => 'custom',
'paymentMethods' => $paymentMethods,
'intent' => $intent,
'stripeKey' => config('cashier.key'),
'osTemplates' => [],
'osTemplateGroups' => [],
]);
}
private function categorizeOS(string $name): string private function categorizeOS(string $name): string
{ {
$name = strtolower($name); $name = strtolower($name);
@@ -106,12 +149,12 @@ class CheckoutController extends Controller
$category = $this->categorizeOS($name); $category = $this->categorizeOS($name);
return match ($category) { return match ($category) {
'ubuntu' => 'mdi-ubuntu', 'ubuntu' => 'tabler-brand-ubuntu',
'debian' => 'mdi-debian', 'debian' => 'tabler-brand-debian',
'redhat' => 'mdi-redhat', 'redhat' => 'tabler-brand-redhat',
'fedora' => 'mdi-fedora', 'fedora' => 'tabler-brand-fedora',
'arch' => 'mdi-arch', 'arch' => 'tabler-brand-arch-linux',
default => 'mdi-linux', default => 'tabler-brand-linux',
}; };
} }
@@ -123,10 +166,14 @@ class CheckoutController extends Controller
'coupon_code' => ['nullable', 'string', 'max:50'], 'coupon_code' => ['nullable', 'string', 'max:50'],
'billing_cycle' => ['required', 'in:monthly,quarterly,semi_annual,annual'], 'billing_cycle' => ['required', 'in:monthly,quarterly,semi_annual,annual'],
'configuration' => ['nullable', 'array'], 'configuration' => ['nullable', 'array'],
'configuration.os_template_id' => ['nullable', 'integer'], 'configuration.os_template_id' => [$plan->service_type === 'vps' ? 'required' : 'nullable', 'integer'],
'configuration.auth_method' => ['nullable', 'in:password,ssh'], 'configuration.auth_method' => ['nullable', 'in:password,ssh'],
'configuration.ssh_key' => ['nullable', 'string', 'max:4096'], 'configuration.ssh_key' => ['nullable', 'string', 'max:4096'],
'configuration.additional_ipv4' => ['nullable', 'integer', 'min:0', 'max:10'], 'config_selections' => ['nullable', 'array'],
'config_selections.*.option_id' => ['required', 'integer', 'exists:plan_config_options,id'],
'config_selections.*.value_id' => ['nullable', 'integer', 'exists:plan_config_values,id'],
'config_selections.*.quantity' => ['nullable', 'integer', 'min:0'],
'config_selections.*.text_value' => ['nullable', 'string', 'max:500'],
]); ]);
if (! $plan->isAvailable()) { if (! $plan->isAvailable()) {
@@ -148,10 +195,6 @@ class CheckoutController extends Controller
$billingCycle, $billingCycle,
); );
if ($couponCode) {
$this->redeemCoupon($couponCode, $user, $plan);
}
// PayPal requires redirect to approval URL // PayPal requires redirect to approval URL
if ($gateway === 'paypal' && isset($result['approval_url'])) { if ($gateway === 'paypal' && isset($result['approval_url'])) {
return response()->json([ return response()->json([
@@ -167,14 +210,73 @@ class CheckoutController extends Controller
]); ]);
} }
$subscription = $user->subscriptions()->latest()->first(); if ($couponCode) {
$this->redeemCoupon($couponCode, $user, $plan, $billingCycle);
}
if (empty($result['subscription_id'])) {
Log::error('Checkout completed but no subscription_id returned', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'gateway' => $gateway,
]);
return redirect()->route('account.dashboard')
->with('warning', "Subscribed to {$plan->name}, but there was an issue linking your subscription. Please contact support if services aren't provisioned.");
}
$subscription = $user->subscriptions()->where('stripe_id', $result['subscription_id'])->first();
if ($subscription) { if ($subscription) {
// Store configuration on the subscription record for async provisioning // Store configuration on the subscription record for async provisioning
// Cashier's Subscription model has no array cast for provisioning_config, so encode manually
if ($request->has('configuration')) { if ($request->has('configuration')) {
$subscription->update(['provisioning_config' => json_encode($request->input('configuration'))]); $subscription->update(['provisioning_config' => json_encode($request->input('configuration'))]);
} }
// Save configurable option selections with server-side price recalculation
if ($request->has('config_selections')) {
foreach ($request->input('config_selections', []) as $selection) {
$option = PlanConfigOption::find($selection['option_id']);
if (! $option) {
continue;
}
// Recalculate price server-side instead of trusting client
$lockedPrice = 0;
$lockedHourlyPrice = null;
if (in_array($option->type, ['slider', 'quantity'])) {
$qty = (int) ($selection['quantity'] ?? 0);
$lockedPrice = $option->calculatePrice($qty, $billingCycle);
$lockedHourlyPrice = $option->hourly_price ? $option->getHourlyPrice($qty) : null;
} elseif ($selection['value_id'] ?? null) {
$value = PlanConfigValue::find($selection['value_id']);
if ($value) {
$lockedPrice = $value->getPriceForCycle($billingCycle);
$lockedHourlyPrice = $value->hourly_price > 0 ? (float) $value->hourly_price : null;
}
}
SubscriptionConfigSelection::create([
'subscription_id' => $subscription->id,
'option_id' => $selection['option_id'],
'value_id' => $selection['value_id'] ?? null,
'quantity' => $selection['quantity'] ?? null,
'text_value' => $selection['text_value'] ?? null,
'locked_price' => $lockedPrice,
'locked_hourly_price' => $lockedHourlyPrice,
'billing_cycle' => $billingCycle,
'is_custom_build' => $request->boolean('is_custom_build', false),
]);
}
}
SubscriptionCreated::dispatch($user, $subscription); SubscriptionCreated::dispatch($user, $subscription);
} else {
Log::error('Subscription not found after creation', [
'user_id' => $user->id,
'stripe_id' => $result['subscription_id'],
]);
} }
return redirect()->route('account.dashboard') return redirect()->route('account.dashboard')
@@ -189,6 +291,7 @@ class CheckoutController extends Controller
$request->validate([ $request->validate([
'code' => ['required', 'string'], 'code' => ['required', 'string'],
'plan_id' => ['required', 'exists:plans,id'], 'plan_id' => ['required', 'exists:plans,id'],
'billing_cycle' => ['nullable', 'string', 'in:monthly,quarterly,semi_annual,annual'],
]); ]);
$coupon = Coupon::where('code', $request->input('code'))->first(); $coupon = Coupon::where('code', $request->input('code'))->first();
@@ -198,12 +301,14 @@ class CheckoutController extends Controller
} }
$plan = Plan::findOrFail($request->input('plan_id')); $plan = Plan::findOrFail($request->input('plan_id'));
$discount = $this->calculateDiscount($coupon, $plan); $billingCycle = $request->input('billing_cycle', 'monthly');
$price = (float) ($plan->priceForCycle($billingCycle)?->price ?? $plan->price);
$discount = $this->calculateDiscount($coupon, $price);
return response()->json([ return response()->json([
'valid' => true, 'valid' => true,
'discount' => $discount, 'discount' => $discount,
'new_total' => max(0, $plan->price - $discount), 'new_total' => max(0, $price - $discount),
'coupon_type' => $coupon->type, 'coupon_type' => $coupon->type,
'coupon_value' => $coupon->value, 'coupon_value' => $coupon->value,
]); ]);
@@ -224,12 +329,13 @@ class CheckoutController extends Controller
->with('error', 'PayPal checkout was cancelled.'); ->with('error', 'PayPal checkout was cancelled.');
} }
private function redeemCoupon(string $code, \App\Models\User $user, Plan $plan): void private function redeemCoupon(string $code, \App\Models\User $user, Plan $plan, string $billingCycle = 'monthly'): void
{ {
$coupon = Coupon::where('code', $code)->first(); $coupon = Coupon::where('code', $code)->first();
if ($coupon && $coupon->isValid()) { if ($coupon && $coupon->isValid()) {
$discount = $this->calculateDiscount($coupon, $plan); $price = (float) ($plan->priceForCycle($billingCycle)?->price ?? $plan->price);
$discount = $this->calculateDiscount($coupon, $price);
$coupon->redemptions()->create([ $coupon->redemptions()->create([
'user_id' => $user->id, 'user_id' => $user->id,
@@ -240,11 +346,11 @@ class CheckoutController extends Controller
} }
} }
private function calculateDiscount(Coupon $coupon, Plan $plan): float private function calculateDiscount(Coupon $coupon, float $price): float
{ {
return match ($coupon->type) { return match ($coupon->type) {
'percentage' => round($plan->price * ($coupon->value / 100), 2), 'percentage' => round($price * ($coupon->value / 100), 2),
'fixed_amount' => min($coupon->value, $plan->price), 'fixed_amount' => min($coupon->value, $price),
default => 0, default => 0,
}; };
} }

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class LoginHistoryController extends Controller
{
public function index(Request $request): Response
{
$loginHistories = $request->user()
->loginHistories()
->latest()
->paginate(15);
return Inertia::render('Profile/LoginHistory', [
'loginHistories' => $loginHistories,
]);
}
}

View File

@@ -21,6 +21,16 @@ class ProfileController extends Controller
$profile = $user->profile; $profile = $user->profile;
$loginHistories = $user->loginHistories()
->latest()
->limit(10)
->get();
$trustedDevices = $user->trustedDevices()
->active()
->latest('last_used_at')
->get();
return Inertia::render('Profile/Show', [ return Inertia::render('Profile/Show', [
'user' => $user, 'user' => $user,
'profile' => $profile ? [ 'profile' => $profile ? [
@@ -36,6 +46,8 @@ class ProfileController extends Controller
'company_vat' => $profile->company_vat, 'company_vat' => $profile->company_vat,
] : null, ] : null,
'twoFactorEnabled' => (bool) $user->two_factor_secret, 'twoFactorEnabled' => (bool) $user->two_factor_secret,
'loginHistories' => $loginHistories,
'trustedDevices' => $trustedDevices,
]); ]);
} }

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class SessionController extends Controller
{
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'string'],
]);
if (! Hash::check($request->input('password'), $request->user()->password)) {
return redirect()->back()->withErrors([
'password' => 'The provided password is incorrect.',
]);
}
Auth::logoutOtherDevices($request->input('password'));
return redirect()->back()->with('success', 'All other sessions have been logged out.');
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Account;
use App\Events\SubscriptionCancelled; use App\Events\SubscriptionCancelled;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\CancellationSurvey;
use App\Models\Plan; use App\Models\Plan;
use App\Services\Billing\BillingServiceFactory; use App\Services\Billing\BillingServiceFactory;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -96,7 +97,17 @@ class SubscriptionController extends Controller
return back()->with('error', 'Failed to cancel subscription. Please try again.'); return back()->with('error', 'Failed to cancel subscription. Please try again.');
} }
SubscriptionCancelled::dispatch($request->user(), $subscription); $reason = $request->input('reason', '');
CancellationSurvey::create([
'user_id' => $request->user()->id,
'subscription_id' => $subscription->id,
'cancellation_reason' => $reason,
'cancellation_feedback' => $request->input('feedback'),
'would_return' => $request->input('would_return'),
]);
SubscriptionCancelled::dispatch($request->user(), $subscription, $reason ?: null);
return back()->with('success', 'Subscription has been cancelled.'); return back()->with('success', 'Subscription has been cancelled.');
} }

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Models\TrustedDevice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TrustedDeviceController extends Controller
{
public function destroy(Request $request, TrustedDevice $device): RedirectResponse
{
if ($device->user_id !== $request->user()->id) {
abort(403, 'You do not own this trusted device.');
}
$device->delete();
return redirect()->back()->with('success', 'Trusted device removed.');
}
public function destroyAll(Request $request): RedirectResponse
{
$request->user()->trustedDevices()->delete();
return redirect()->back()->with('success', 'All trusted devices removed.');
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\CancellationSurvey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class CancellationSurveyController extends Controller
{
public function index(Request $request): Response
{
$query = CancellationSurvey::query()
->with(['user:id,name,email', 'subscription:id,type']);
if ($request->filled('reason') && $request->input('reason') !== 'all') {
$query->where('cancellation_reason', $request->input('reason'));
}
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->input('date_from'));
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->input('date_to'));
}
$surveys = $query->orderByDesc('created_at')->paginate(25);
// Analytics: reason breakdown
$reasonBreakdown = CancellationSurvey::query()
->select('cancellation_reason', DB::raw('COUNT(*) as count'))
->groupBy('cancellation_reason')
->orderByDesc('count')
->get()
->map(fn ($row) => [
'reason' => $row->cancellation_reason,
'count' => (int) $row->count,
])
->toArray();
// Analytics: monthly trend
$monthlyTrend = CancellationSurvey::query()
->select(
DB::raw("DATE_FORMAT(created_at, '%Y-%m') as month"),
DB::raw('COUNT(*) as count'),
)
->groupBy('month')
->orderBy('month')
->limit(12)
->get()
->map(fn ($row) => [
'month' => $row->month,
'count' => (int) $row->count,
])
->toArray();
// Summary stats
$totalSurveys = CancellationSurvey::count();
$topReason = ! empty($reasonBreakdown) ? $reasonBreakdown[0]['reason'] : 'N/A';
$wouldReturnCount = CancellationSurvey::whereIn('would_return', ['yes', 'maybe'])->count();
$wouldReturnRate = $totalSurveys > 0 ? round(($wouldReturnCount / $totalSurveys) * 100, 1) : 0;
// Available reasons for filter dropdown
$availableReasons = CancellationSurvey::query()
->select('cancellation_reason')
->distinct()
->orderBy('cancellation_reason')
->pluck('cancellation_reason')
->toArray();
return Inertia::render('Admin/CancellationSurveys/Index', [
'surveys' => $surveys,
'reasonBreakdown' => $reasonBreakdown,
'monthlyTrend' => $monthlyTrend,
'totalSurveys' => $totalSurveys,
'topReason' => $topReason,
'wouldReturnRate' => $wouldReturnRate,
'availableReasons' => $availableReasons,
'filters' => $request->only(['reason', 'date_from', 'date_to']),
]);
}
}

View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreConfigGroupRequest;
use App\Models\Plan;
use App\Models\PlanConfigGroup;
use App\Models\PlanConfigOption;
use App\Models\PlanConfigValue;
use App\Models\SubscriptionConfigSelection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class ConfigGroupController extends Controller
{
public function index(Request $request): Response
{
$query = PlanConfigGroup::query()
->withCount(['options', 'plans']);
if ($request->filled('mode') && $request->input('mode') !== 'all') {
$query->where('mode', $request->input('mode'));
}
if ($request->filled('search')) {
$query->where('name', 'like', '%'.$request->input('search').'%');
}
$configGroups = $query
->orderBy('sort_order')
->orderBy('name')
->get();
return Inertia::render('Admin/ConfigGroups/Index', [
'configGroups' => $configGroups,
'filters' => [
'mode' => $request->input('mode', 'all'),
'search' => $request->input('search', ''),
],
]);
}
public function create(): Response
{
$plans = Plan::query()
->whereIn('status', ['active', 'internal'])
->orderBy('name')
->get(['id', 'name', 'service_type', 'status']);
return Inertia::render('Admin/ConfigGroups/Create', [
'plans' => $plans,
]);
}
public function store(StoreConfigGroupRequest $request): RedirectResponse
{
DB::transaction(function () use ($request): void {
$group = PlanConfigGroup::query()->create([
'name' => $request->validated('name'),
'description' => $request->validated('description'),
'mode' => $request->validated('mode'),
'service_type' => $request->validated('service_type'),
'is_active' => $request->boolean('is_active', true),
'sort_order' => $request->validated('sort_order') ?? 0,
]);
if ($request->validated('mode') === 'preset' && $request->has('plan_ids')) {
$group->plans()->sync($request->validated('plan_ids'));
}
$this->syncOptions($group, $request->validated('options'));
});
return redirect()
->route('admin.config-groups.index')
->with('success', 'Config group created successfully.');
}
public function edit(PlanConfigGroup $configGroup): Response
{
$configGroup->load(['options.values', 'plans:id,name,service_type,status']);
$plans = Plan::query()
->whereIn('status', ['active', 'internal'])
->orderBy('name')
->get(['id', 'name', 'service_type', 'status']);
return Inertia::render('Admin/ConfigGroups/Edit', [
'configGroup' => $configGroup,
'plans' => $plans,
]);
}
public function update(StoreConfigGroupRequest $request, PlanConfigGroup $configGroup): RedirectResponse
{
DB::transaction(function () use ($request, $configGroup): void {
$configGroup->update([
'name' => $request->validated('name'),
'description' => $request->validated('description'),
'mode' => $request->validated('mode'),
'service_type' => $request->validated('service_type'),
'is_active' => $request->boolean('is_active', true),
'sort_order' => $request->validated('sort_order') ?? 0,
]);
if ($request->validated('mode') === 'preset') {
$configGroup->plans()->sync($request->validated('plan_ids') ?? []);
} else {
$configGroup->plans()->detach();
}
$this->syncOptions($configGroup, $request->validated('options'));
});
return redirect()
->route('admin.config-groups.index')
->with('success', 'Config group updated successfully.');
}
public function destroy(PlanConfigGroup $configGroup): RedirectResponse
{
$hasSelections = SubscriptionConfigSelection::query()
->whereIn('option_id', $configGroup->options()->pluck('id'))
->exists();
if ($hasSelections) {
$configGroup->update(['is_active' => false]);
$configGroup->delete();
return redirect()
->route('admin.config-groups.index')
->with('success', 'Config group archived (has existing selections).');
}
$configGroup->options()->each(function (PlanConfigOption $option): void {
$option->values()->delete();
});
$configGroup->options()->delete();
$configGroup->plans()->detach();
$configGroup->forceDelete();
return redirect()
->route('admin.config-groups.index')
->with('success', 'Config group deleted permanently.');
}
/** @param array<int, array<string, mixed>> $optionsData */
private function syncOptions(PlanConfigGroup $group, array $optionsData): void
{
$existingOptionIds = $group->options()->pluck('id')->toArray();
$incomingOptionIds = [];
foreach ($optionsData as $sortOrder => $optionData) {
$optionId = $optionData['id'] ?? null;
$optionAttributes = [
'group_id' => $group->id,
'name' => $optionData['name'],
'description' => $optionData['description'] ?? null,
'type' => $optionData['type'],
'provisioning_key' => $optionData['provisioning_key'] ?? null,
'required' => $optionData['required'] ?? false,
'is_active' => $optionData['is_active'] ?? true,
'min_qty' => $optionData['min_qty'] ?? null,
'max_qty' => $optionData['max_qty'] ?? null,
'step' => $optionData['step'] ?? null,
'unit_label' => $optionData['unit_label'] ?? null,
'hourly_price' => $optionData['hourly_price'] ?? null,
'monthly_price' => $optionData['monthly_price'] ?? null,
'quarterly_price' => $optionData['quarterly_price'] ?? null,
'semi_annual_price' => $optionData['semi_annual_price'] ?? null,
'annual_price' => $optionData['annual_price'] ?? null,
'sort_order' => $sortOrder,
];
if ($optionId && in_array($optionId, $existingOptionIds)) {
$option = PlanConfigOption::query()->findOrFail($optionId);
$option->update($optionAttributes);
$incomingOptionIds[] = $optionId;
} else {
$option = PlanConfigOption::query()->create($optionAttributes);
}
$this->syncValues($option, $optionData['values'] ?? []);
}
$removedOptionIds = array_diff($existingOptionIds, $incomingOptionIds);
if (! empty($removedOptionIds)) {
PlanConfigValue::query()->whereIn('option_id', $removedOptionIds)->delete();
PlanConfigOption::query()->whereIn('id', $removedOptionIds)->delete();
}
}
/** @param array<int, array<string, mixed>> $valuesData */
private function syncValues(PlanConfigOption $option, array $valuesData): void
{
$existingValueIds = $option->values()->pluck('id')->toArray();
$incomingValueIds = [];
foreach ($valuesData as $sortOrder => $valueData) {
$valueId = $valueData['id'] ?? null;
$valueAttributes = [
'option_id' => $option->id,
'label' => $valueData['label'],
'value' => $valueData['value'] ?? null,
'hourly_price' => $valueData['hourly_price'] ?? 0,
'monthly_price' => $valueData['monthly_price'] ?? 0,
'quarterly_price' => $valueData['quarterly_price'] ?? 0,
'semi_annual_price' => $valueData['semi_annual_price'] ?? 0,
'annual_price' => $valueData['annual_price'] ?? 0,
'is_default' => $valueData['is_default'] ?? false,
'sort_order' => $sortOrder,
];
if ($valueId && in_array($valueId, $existingValueIds)) {
$value = PlanConfigValue::query()->findOrFail($valueId);
$value->update($valueAttributes);
$incomingValueIds[] = $valueId;
} else {
PlanConfigValue::query()->create($valueAttributes);
}
}
$removedValueIds = array_diff($existingValueIds, $incomingValueIds);
if (! empty($removedValueIds)) {
PlanConfigValue::query()->whereIn('id', $removedValueIds)->delete();
}
}
}

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateCustomerRequest; use App\Http\Requests\Admin\UpdateCustomerRequest;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\LoginHistory;
use App\Models\Order; use App\Models\Order;
use App\Models\Plan; use App\Models\Plan;
use App\Models\Service; use App\Models\Service;
@@ -18,6 +19,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -123,12 +125,19 @@ class CustomerController extends Controller
->orderBy('price') ->orderBy('price')
->get(['id', 'name', 'price', 'billing_cycle', 'service_type']); ->get(['id', 'name', 'price', 'billing_cycle', 'service_type']);
$loginHistories = LoginHistory::query()
->forUser($user->id)
->latest()
->limit(20)
->get();
return Inertia::render('Admin/Customers/Show', [ return Inertia::render('Admin/Customers/Show', [
'customer' => $user, 'customer' => $user,
'subscriptions' => $subscriptions, 'subscriptions' => $subscriptions,
'recentInvoices' => $recentInvoices, 'recentInvoices' => $recentInvoices,
'auditLogs' => $auditLogs, 'auditLogs' => $auditLogs,
'plans' => $plans, 'plans' => $plans,
'loginHistories' => $loginHistories,
]); ]);
} }
@@ -218,13 +227,7 @@ class CustomerController extends Controller
$userEmail = $user->email; $userEmail = $user->email;
DB::transaction(function () use ($user, $request): void { DB::transaction(function () use ($user, $request): void {
// Delete all related data // Audit log BEFORE destructive operations
$user->services()->delete();
$user->billingInvoices()->delete();
$user->orders()->delete();
$user->subscriptions()->delete();
AuditLog::query()->where('user_id', $user->id)->delete();
AuditLog::create([ AuditLog::create([
'admin_id' => $request->user()->id, 'admin_id' => $request->user()->id,
'action' => 'customer_purged', 'action' => 'customer_purged',
@@ -238,6 +241,25 @@ class CustomerController extends Controller
], ],
]); ]);
// Terminate services on provisioning platforms before purging
foreach ($user->services as $service) {
if ($service->platform_service_id) {
try {
$provisioner = \App\Services\Provisioning\ProvisioningFactory::make($service->service_type);
$provisioner->terminate($service);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error("Failed to terminate service {$service->id} during customer purge: {$e->getMessage()}");
}
}
}
// Force-delete all related data (services use SoftDeletes, but user is being permanently purged)
$user->services()->forceDelete();
$user->billingInvoices()->delete();
$user->orders()->delete();
$user->subscriptions()->delete();
AuditLog::query()->where('user_id', $user->id)->delete();
// Delete the user // Delete the user
$user->delete(); $user->delete();
}); });
@@ -370,4 +392,74 @@ class CustomerController extends Controller
return redirect()->back() return redirect()->back()
->with('success', "Order for {$plan->name} created successfully."); ->with('success', "Order for {$plan->name} created successfully.");
} }
public function forceLogout(Request $request, User $user): RedirectResponse
{
// Cycle remember token to invalidate "remember me" cookies
$user->forceFill([
'remember_token' => Str::random(60),
])->save();
// Clear trusted devices so they cannot bypass 2FA
$user->trustedDevices()->delete();
// Invalidate all Redis sessions for this user.
// Redis session keys are prefixed with the app name, and the session
// payload contains the user ID. We iterate matching keys and delete
// those belonging to the target user.
try {
$prefix = config('cache.prefix', 'laravel').'_cache:';
$connection = config('session.connection') ?? 'default';
// Use Redis SCAN to find session keys without blocking
$cursor = '0';
$sessionPrefix = config('session.cookie', 'laravel_session');
do {
$result = Redis::connection($connection)->scan($cursor, ['match' => '*', 'count' => 100]);
if ($result === false) {
break;
}
[$cursor, $keys] = $result;
foreach ($keys as $key) {
try {
$value = Redis::connection($connection)->get($key);
if ($value && is_string($value)) {
$decoded = @unserialize($decoded = $value);
if (is_array($decoded) && isset($decoded['_token'])) {
// Check if this session belongs to the target user
$loginWeb = $decoded['login_web_'.sha1('Illuminate\Auth\SessionGuard')] ?? null;
if ($loginWeb && (int) $loginWeb === $user->id) {
Redis::connection($connection)->del($key);
}
}
}
} catch (\Exception) {
// Skip keys that cannot be read
}
}
} while ($cursor !== '0');
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning("Could not clear Redis sessions during force logout: {$e->getMessage()}");
}
AuditLog::create([
'user_id' => $user->id,
'admin_id' => $request->user()->id,
'action' => 'force_logout',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
return redirect()->back()
->with('success', "All sessions for {$user->name} have been invalidated.");
}
} }

View File

@@ -7,11 +7,9 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\PaymentTransaction; use App\Models\PaymentTransaction;
use App\Models\Plan;
use App\Models\Service; use App\Models\Service;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Laravel\Cashier\Subscription; use Laravel\Cashier\Subscription;
@@ -23,29 +21,54 @@ class DashboardController extends Controller
$stats = Cache::remember('admin.dashboard.stats', 300, function () { $stats = Cache::remember('admin.dashboard.stats', 300, function () {
$totalCustomers = User::role('customer')->count(); $totalCustomers = User::role('customer')->count();
// MRR: sum of plan prices for active subscriptions $newCustomersThisMonth = User::role('customer')
$mrr = (float) Subscription::query() ->where('created_at', '>=', now()->startOfMonth())
->where('stripe_status', 'active') ->count();
->whereNotNull('plan_id')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->sum('plans.price');
// Total revenue: sum of paid invoice totals // MRR: sum of plan prices normalized to monthly
$totalRevenue = (float) Invoice::query() $mrr = $this->calculateMrr();
->where('status', 'paid')
->sum('total'); // Previous month MRR for month-over-month change
$previousMrr = $this->calculatePreviousMonthMrr();
$mrrChangePercent = $previousMrr > 0
? round((($mrr - $previousMrr) / $previousMrr) * 100, 1)
: null;
$arr = $mrr * 12;
$activeServices = Service::query() $activeServices = Service::query()
->where('status', 'active') ->where('status', 'active')
->count(); ->count();
// Pending invoices // Service breakdown by type
$pendingInvoicesCount = Invoice::query() $serviceBreakdown = Service::query()
->where('status', 'pending') ->where('status', 'active')
->count(); ->selectRaw('service_type, COUNT(*) as count')
->groupBy('service_type')
->pluck('count', 'service_type')
->toArray();
$pendingInvoicesAmount = (float) Invoice::query() // Transaction revenue and estimated processor fees
->where('status', 'pending') $transactionStats = PaymentTransaction::query()
->where('status', 'succeeded')
->selectRaw("
COALESCE(SUM(amount), 0) as total,
COALESCE(SUM(CASE
WHEN gateway = 'stripe' THEN (amount * 0.029) + 0.30
WHEN gateway = 'paypal' THEN (amount * 0.0349) + 0.49
ELSE 0
END), 0) as fees
")
->first();
$totalTransactionRevenue = (float) $transactionStats->total;
$estimatedFees = round((float) $transactionStats->fees, 2);
$netRevenue = round($totalTransactionRevenue - $estimatedFees, 2);
// Revenue this month
$revenueThisMonth = (float) Invoice::query()
->where('status', 'paid')
->where('paid_at', '>=', now()->startOfMonth())
->sum('total'); ->sum('total');
// Overdue invoices // Overdue invoices
@@ -57,97 +80,101 @@ class DashboardController extends Controller
->where('status', 'overdue') ->where('status', 'overdue')
->sum('total'); ->sum('total');
// Recent invoices with user info // Churn rate (current month)
$recentInvoices = Invoice::query() $currentChurnRate = $this->calculateCurrentChurnRate();
->with('user:id,name,email') $churnHealthStatus = match (true) {
->latest() $currentChurnRate < 3.0 => 'healthy',
->limit(10) $currentChurnRate <= 7.0 => 'watch',
->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']); default => 'high',
};
// Recent subscriptions with user and plan
$recentSubscriptions = Subscription::query()
->select([
'subscriptions.id',
'subscriptions.user_id',
'subscriptions.plan_id',
'subscriptions.type',
'subscriptions.stripe_status',
'subscriptions.gateway',
'subscriptions.created_at',
])
->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
->addSelect([
'plans.name as plan_name',
'plans.price as plan_price',
'plans.billing_cycle as plan_billing_cycle',
])
->leftJoin('users', 'subscriptions.user_id', '=', 'users.id')
->addSelect([
'users.name as user_name',
'users.email as user_email',
])
->orderByDesc('subscriptions.created_at')
->limit(10)
->get();
// Popular plans: ordered by active subscription count
$popularPlans = Plan::query()
->withCount(['services as active_services_count' => function ($query): void {
$query->where('status', 'active');
}])
->where('status', 'active')
->orderByDesc('active_services_count')
->limit(8)
->get(['id', 'name', 'service_type', 'price', 'billing_cycle']);
// Revenue by service type from plans linked through invoices
$revenueByServiceType = Invoice::query()
->where('invoices.status', 'paid')
->join('subscriptions', 'invoices.subscription_id', '=', 'subscriptions.id')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->select('plans.service_type', DB::raw('SUM(invoices.total) as revenue'), DB::raw('COUNT(invoices.id) as invoice_count'))
->groupBy('plans.service_type')
->orderByDesc('revenue')
->get();
// New customers this month
$newCustomersThisMonth = User::role('customer')
->where('created_at', '>=', now()->startOfMonth())
->count();
// Revenue this month
$revenueThisMonth = (float) Invoice::query()
->where('status', 'paid')
->where('paid_at', '>=', now()->startOfMonth())
->sum('total');
// ARR (Annual Recurring Revenue)
$arr = $mrr * 12;
// Monthly Revenue Trend (last 12 months) // Monthly Revenue Trend (last 12 months)
$revenueByMonth = PaymentTransaction::query() $revenueByMonth = PaymentTransaction::query()
->where('status', 'completed') ->where('status', 'succeeded')
->where('created_at', '>=', now()->subMonths(12)) ->where('created_at', '>=', now()->subMonths(12))
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total") ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total, COUNT(*) as transactions, ROUND(AVG(amount), 2) as avg_amount")
->groupBy('month') ->groupBy('month')
->orderBy('month') ->orderBy('month')
->get() ->get()
->map(fn ($row) => ['month' => $row->month, 'total' => (float) $row->total]); ->map(fn ($row) => [
'month' => $row->month,
'total' => (float) $row->total,
'transactions' => (int) $row->transactions,
'avg_amount' => (float) $row->avg_amount,
]);
// Customer Growth (last 12 months - new signups per month) return compact(
$customerGrowth = User::role('customer') 'totalCustomers',
->where('created_at', '>=', now()->subMonths(12)) 'newCustomersThisMonth',
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count") 'mrr',
->groupBy('month') 'mrrChangePercent',
->orderBy('month') 'arr',
->get() 'activeServices',
->map(fn ($row) => ['month' => $row->month, 'count' => (int) $row->count]); 'serviceBreakdown',
'totalTransactionRevenue',
'estimatedFees',
'netRevenue',
'revenueThisMonth',
'overdueCount',
'overdueAmount',
'currentChurnRate',
'churnHealthStatus',
'revenueByMonth',
);
});
// Churn Rate (subscriptions cancelled vs total in last 6 months) return Inertia::render('Admin/Dashboard', $stats);
$churnData = []; }
for ($i = 5; $i >= 0; $i--) {
$monthStart = now()->subMonths($i)->startOfMonth(); private function calculateMrr(): float
$monthEnd = now()->subMonths($i)->endOfMonth(); {
return (float) (Subscription::query()
->where('subscriptions.stripe_status', 'active')
->whereNotNull('subscriptions.plan_id')
->join('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
END) as mrr')
->value('mrr') ?? 0);
}
private function calculatePreviousMonthMrr(): float
{
$lastMonthEnd = now()->subMonth()->endOfMonth();
return (float) (Subscription::query()
->where('subscriptions.stripe_status', 'active')
->whereNotNull('subscriptions.plan_id')
->where('subscriptions.created_at', '<', $lastMonthEnd)
->where(function ($query) use ($lastMonthEnd): void {
$query->whereNull('subscriptions.cancelled_at')
->orWhere('subscriptions.cancelled_at', '>', $lastMonthEnd);
})
->join('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
END) as mrr')
->value('mrr') ?? 0);
}
private function calculateCurrentChurnRate(): float
{
$monthStart = now()->startOfMonth();
$monthEnd = now()->endOfMonth();
$totalAtStart = Subscription::query() $totalAtStart = Subscription::query()
->where('created_at', '<', $monthStart) ->where('created_at', '<', $monthStart)
@@ -161,44 +188,6 @@ class DashboardController extends Controller
->whereBetween('cancelled_at', [$monthStart, $monthEnd]) ->whereBetween('cancelled_at', [$monthStart, $monthEnd])
->count(); ->count();
$churnData[] = [ return $totalAtStart > 0 ? round(($cancelled / $totalAtStart) * 100, 1) : 0;
'month' => $monthStart->format('Y-m'),
'rate' => $totalAtStart > 0 ? round(($cancelled / $totalAtStart) * 100, 1) : 0,
'cancelled' => $cancelled,
];
}
// Overdue Invoices (detailed list)
$overdueInvoices = Invoice::query()
->where('status', 'overdue')
->with('user:id,name,email')
->latest('due_date')
->take(10)
->get(['id', 'user_id', 'number', 'total', 'due_date', 'status']);
return compact(
'totalCustomers',
'mrr',
'totalRevenue',
'activeServices',
'pendingInvoicesCount',
'pendingInvoicesAmount',
'overdueCount',
'overdueAmount',
'recentInvoices',
'recentSubscriptions',
'popularPlans',
'revenueByServiceType',
'newCustomersThisMonth',
'revenueThisMonth',
'arr',
'revenueByMonth',
'customerGrowth',
'churnData',
'overdueInvoices',
);
});
return Inertia::render('Admin/Dashboard', $stats);
} }
} }

View File

@@ -62,6 +62,7 @@ class PlanController extends Controller
'currency' => 'USD', 'currency' => 'USD',
'billing_cycle' => $request->validated('billing_cycle'), 'billing_cycle' => $request->validated('billing_cycle'),
'features' => $request->featuresForStorage(), 'features' => $request->featuresForStorage(),
'provisioning_config' => $request->validated('provisioning_config'),
'stock_quantity' => $request->validated('stock_quantity'), 'stock_quantity' => $request->validated('stock_quantity'),
'sort_order' => $request->validated('sort_order', 0), 'sort_order' => $request->validated('sort_order', 0),
'status' => 'active', 'status' => 'active',
@@ -95,6 +96,7 @@ class PlanController extends Controller
'price' => $request->validated('price'), 'price' => $request->validated('price'),
'billing_cycle' => $request->validated('billing_cycle'), 'billing_cycle' => $request->validated('billing_cycle'),
'features' => $request->featuresForStorage(), 'features' => $request->featuresForStorage(),
'provisioning_config' => $request->validated('provisioning_config'),
'stock_quantity' => $request->validated('stock_quantity'), 'stock_quantity' => $request->validated('stock_quantity'),
'sort_order' => $request->validated('sort_order', 0), 'sort_order' => $request->validated('sort_order', 0),
]); ]);

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\GenerateReportRequest;
use App\Services\Reports\FinancialReportService;
use App\Services\Reports\ReportExportService;
use Illuminate\Support\Carbon;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class ReportController extends Controller
{
public function __construct(
private FinancialReportService $reportService,
private ReportExportService $exportService,
) {}
public function index(): Response
{
return Inertia::render('Admin/Reports/Index');
}
public function generate(GenerateReportRequest $request): Response
{
$reportType = $request->validated('report_type');
$startDate = $request->validated('start_date') ? Carbon::parse($request->validated('start_date')) : null;
$endDate = $request->validated('end_date') ? Carbon::parse($request->validated('end_date')) : null;
$serviceType = $request->validated('service_type');
$data = $this->generateReportData($reportType, $startDate, $endDate, $serviceType);
$meta = $this->buildMeta($reportType, $startDate, $endDate, $serviceType);
return Inertia::render('Admin/Reports/Show', [
'reportType' => $reportType,
'reportData' => $data,
'reportMeta' => $meta,
]);
}
public function export(GenerateReportRequest $request): SymfonyResponse
{
$reportType = $request->validated('report_type');
$format = $request->validated('format', 'pdf');
$startDate = $request->validated('start_date') ? Carbon::parse($request->validated('start_date')) : null;
$endDate = $request->validated('end_date') ? Carbon::parse($request->validated('end_date')) : null;
$serviceType = $request->validated('service_type');
$data = $this->generateReportData($reportType, $startDate, $endDate, $serviceType);
$meta = $this->buildMeta($reportType, $startDate, $endDate, $serviceType);
return match ($format) {
'csv' => $this->exportService->toCsv($reportType, $data, $meta),
'json' => $this->exportService->toJson($reportType, $data, $meta),
default => $this->exportService->toPdf($reportType, $data, $meta),
};
}
/**
* Generate report data based on type.
*
* @return array<string, mixed>
*/
private function generateReportData(string $reportType, ?Carbon $startDate, ?Carbon $endDate, ?string $serviceType): array
{
return match ($reportType) {
'revenue' => $this->reportService->revenueReport($startDate, $endDate, $serviceType),
'profit_loss' => $this->reportService->profitLossReport($startDate, $endDate),
'tax' => $this->reportService->taxReport($startDate, $endDate),
'aging' => $this->reportService->agingReport(),
'refund' => $this->reportService->refundReport($startDate, $endDate),
'subscription' => $this->reportService->subscriptionReport($startDate, $endDate),
default => [],
};
}
/**
* Build report metadata.
*
* @return array{title: string, start_date: string|null, end_date: string|null, generated_at: string, service_type: string|null}
*/
private function buildMeta(string $reportType, ?Carbon $startDate, ?Carbon $endDate, ?string $serviceType): array
{
$titles = [
'revenue' => 'Revenue Report',
'profit_loss' => 'Profit & Loss Report',
'tax' => 'Tax Report',
'aging' => 'Aging Report',
'refund' => 'Refund Report',
'subscription' => 'Subscription Report',
];
return [
'title' => $titles[$reportType] ?? 'Report',
'start_date' => $startDate?->toDateString(),
'end_date' => $endDate?->toDateString(),
'generated_at' => now()->toIso8601String(),
'service_type' => $serviceType,
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\PaymentTransaction;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class TransactionController extends Controller
{
public function index(Request $request): Response
{
$query = PaymentTransaction::query()
->with(['user:id,name,email', 'invoice:id,number']);
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('gateway_transaction_id', 'like', "%{$search}%")
->orWhere('description', '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);
}
if ($gateway = $request->input('gateway')) {
$query->where('gateway', $gateway);
}
$transactions = $query->latest()->paginate(25)->withQueryString();
return Inertia::render('Admin/Transactions/Index', [
'transactions' => $transactions,
'filters' => [
'search' => $request->input('search', ''),
'status' => $request->input('status', ''),
'gateway' => $request->input('gateway', ''),
],
]);
}
public function show(PaymentTransaction $transaction): Response
{
$transaction->load([
'user:id,name,email,status',
'invoice:id,number,total,status,currency',
'subscription:id,type,stripe_id,stripe_status',
]);
return Inertia::render('Admin/Transactions/Show', [
'transaction' => $transaction,
]);
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreWinbackCampaignRequest;
use App\Models\WinbackCampaign;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class WinbackCampaignController extends Controller
{
public function index(Request $request): Response
{
$query = WinbackCampaign::query()
->withCount('recipients')
->withCount(['recipients as reactivated_count' => function ($q): void {
$q->where('reactivated', true);
}]);
if ($request->filled('status') && $request->input('status') !== 'all') {
$query->where('status', $request->input('status'));
}
$campaigns = $query->orderByDesc('created_at')->paginate(25);
// Compute reactivation rate for each campaign
$campaigns->getCollection()->transform(function (WinbackCampaign $campaign) {
$campaign->setAttribute(
'reactivation_rate',
$campaign->recipients_count > 0
? round(($campaign->reactivated_count / $campaign->recipients_count) * 100, 1)
: 0
);
return $campaign;
});
return Inertia::render('Admin/WinbackCampaigns/Index', [
'campaigns' => $campaigns,
'filters' => [
'status' => $request->input('status', 'all'),
],
]);
}
public function create(): Response
{
return Inertia::render('Admin/WinbackCampaigns/Create');
}
public function store(StoreWinbackCampaignRequest $request): RedirectResponse
{
WinbackCampaign::query()->create($request->validated());
return redirect()
->route('winback-campaigns.index')
->with('success', 'Win-back campaign created successfully.');
}
public function show(WinbackCampaign $winbackCampaign): Response
{
$winbackCampaign->loadCount('recipients');
$winbackCampaign->loadCount(['recipients as reactivated_count' => function ($q): void {
$q->where('reactivated', true);
}]);
$winbackCampaign->loadCount(['recipients as unsubscribed_count' => function ($q): void {
$q->whereNotNull('unsubscribed_at');
}]);
$totalEmailsSent = $winbackCampaign->recipients()->sum('current_email_index');
$totalOpens = $winbackCampaign->recipients()->sum('opened_count');
$totalClicks = $winbackCampaign->recipients()->sum('clicked_count');
$recipients = $winbackCampaign->recipients()
->with('user:id,name,email')
->orderByDesc('created_at')
->paginate(25);
$reactivationRate = $winbackCampaign->recipients_count > 0
? round(($winbackCampaign->reactivated_count / $winbackCampaign->recipients_count) * 100, 1)
: 0;
$openRate = $totalEmailsSent > 0
? round(($totalOpens / $totalEmailsSent) * 100, 1)
: 0;
return Inertia::render('Admin/WinbackCampaigns/Show', [
'campaign' => $winbackCampaign,
'recipients' => $recipients,
'analytics' => [
'total_recipients' => $winbackCampaign->recipients_count,
'total_emails_sent' => (int) $totalEmailsSent,
'open_rate' => $openRate,
'reactivation_rate' => $reactivationRate,
'reactivated_count' => $winbackCampaign->reactivated_count,
'unsubscribed_count' => $winbackCampaign->unsubscribed_count,
'total_opens' => (int) $totalOpens,
'total_clicks' => (int) $totalClicks,
],
]);
}
public function edit(WinbackCampaign $winbackCampaign): Response
{
return Inertia::render('Admin/WinbackCampaigns/Edit', [
'campaign' => $winbackCampaign,
]);
}
public function update(StoreWinbackCampaignRequest $request, WinbackCampaign $winbackCampaign): RedirectResponse
{
$winbackCampaign->update($request->validated());
return redirect()
->route('winback-campaigns.index')
->with('success', 'Win-back campaign updated successfully.');
}
public function destroy(WinbackCampaign $winbackCampaign): RedirectResponse
{
$winbackCampaign->update(['status' => 'archived']);
return redirect()
->route('winback-campaigns.index')
->with('success', 'Win-back campaign archived successfully.');
}
}

View File

@@ -24,12 +24,22 @@ class AdminAnalyticsController extends Controller
{ {
$totalCustomers = User::role('customer')->count(); $totalCustomers = User::role('customer')->count();
// MRR: sum of plan prices for active subscriptions // MRR: sum of plan prices normalized to monthly
$mrr = (float) Subscription::query() $mrr = (float) Subscription::query()
->where('stripe_status', 'active') ->where('subscriptions.stripe_status', 'active')
->whereNotNull('plan_id') ->whereNotNull('subscriptions.plan_id')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id') ->join('plan_prices', function ($join): void {
->sum('plans.price'); $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
END) as mrr')
->value('mrr') ?? 0;
// ARR (Annual Recurring Revenue) // ARR (Annual Recurring Revenue)
$arr = $mrr * 12; $arr = $mrr * 12;
@@ -74,13 +84,18 @@ class AdminAnalyticsController extends Controller
// Monthly Revenue Trend (last 12 months) // Monthly Revenue Trend (last 12 months)
$revenueByMonth = PaymentTransaction::query() $revenueByMonth = PaymentTransaction::query()
->where('status', 'completed') ->where('status', 'succeeded')
->where('created_at', '>=', now()->subMonths(12)) ->where('created_at', '>=', now()->subMonths(12))
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total") ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total, COUNT(*) as transactions, ROUND(AVG(amount), 2) as avg_amount")
->groupBy('month') ->groupBy('month')
->orderBy('month') ->orderBy('month')
->get() ->get()
->map(fn ($row) => ['month' => $row->month, 'total' => (float) $row->total]); ->map(fn ($row) => [
'month' => $row->month,
'total' => (float) $row->total,
'transactions' => (int) $row->transactions,
'avg_amount' => (float) $row->avg_amount,
]);
// Customer Growth (last 12 months - new signups per month) // Customer Growth (last 12 months - new signups per month)
$customerGrowth = User::role('customer') $customerGrowth = User::role('customer')

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class GenerateReportRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'report_type' => ['required', Rule::in(['revenue', 'profit_loss', 'tax', 'aging', 'refund', 'subscription'])],
'start_date' => ['nullable', 'date', 'required_unless:report_type,aging'],
'end_date' => ['nullable', 'date', 'required_unless:report_type,aging', 'after_or_equal:start_date'],
'service_type' => ['nullable', Rule::in(['vps', 'dedicated', 'hosting', 'mysql', 'game', 'backups'])],
'format' => ['nullable', Rule::in(['pdf', 'csv', 'json'])],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'report_type.required' => 'Please select a report type.',
'report_type.in' => 'Invalid report type selected.',
'start_date.required_unless' => 'Start date is required for this report type.',
'end_date.required_unless' => 'End date is required for this report type.',
'end_date.after_or_equal' => 'End date must be on or after the start date.',
'service_type.in' => 'Invalid service type selected.',
'format.in' => 'Export format must be pdf, csv, or json.',
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreConfigGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'mode' => ['required', Rule::in(['preset', 'build_your_own'])],
'service_type' => ['nullable', 'required_if:mode,build_your_own', Rule::in(['vps', 'dedicated', 'hosting', 'mysql', 'game', 'backups'])],
'is_active' => ['boolean'],
'sort_order' => ['nullable', 'integer', 'min:0'],
'plan_ids' => ['nullable', 'array'],
'plan_ids.*' => ['exists:plans,id'],
'options' => ['required', 'array', 'min:1'],
'options.*.name' => ['required', 'string', 'max:255'],
'options.*.description' => ['nullable', 'string'],
'options.*.type' => ['required', Rule::in(['dropdown', 'radio', 'quantity', 'checkbox', 'text', 'slider'])],
'options.*.provisioning_key' => ['nullable', 'string', 'max:100'],
'options.*.required' => ['boolean'],
'options.*.is_active' => ['boolean'],
'options.*.min_qty' => ['nullable', 'integer', 'min:0'],
'options.*.max_qty' => ['nullable', 'integer', 'min:0'],
'options.*.step' => ['nullable', 'integer', 'min:1'],
'options.*.unit_label' => ['nullable', 'string', 'max:50'],
'options.*.hourly_price' => ['nullable', 'numeric', 'min:0'],
'options.*.monthly_price' => ['nullable', 'numeric', 'min:0'],
'options.*.quarterly_price' => ['nullable', 'numeric', 'min:0'],
'options.*.semi_annual_price' => ['nullable', 'numeric', 'min:0'],
'options.*.annual_price' => ['nullable', 'numeric', 'min:0'],
'options.*.values' => ['nullable', 'array'],
'options.*.values.*.label' => ['required', 'string', 'max:255'],
'options.*.values.*.value' => ['nullable', 'string', 'max:255'],
'options.*.values.*.hourly_price' => ['nullable', 'numeric', 'min:0'],
'options.*.values.*.monthly_price' => ['nullable', 'numeric', 'min:0'],
'options.*.values.*.quarterly_price' => ['nullable', 'numeric', 'min:0'],
'options.*.values.*.semi_annual_price' => ['nullable', 'numeric', 'min:0'],
'options.*.values.*.annual_price' => ['nullable', 'numeric', 'min:0'],
'options.*.values.*.is_default' => ['boolean'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'name.required' => 'Config group name is required.',
'mode.required' => 'Mode is required.',
'mode.in' => 'Invalid mode selected.',
'service_type.required_if' => 'Service type is required for Build Your Own mode.',
'options.required' => 'At least one option is required.',
'options.min' => 'At least one option is required.',
'options.*.name.required' => 'Each option must have a name.',
'options.*.type.required' => 'Each option must have a type.',
'options.*.type.in' => 'Invalid option type selected.',
'options.*.values.*.label.required' => 'Each value must have a label.',
];
}
}

View File

@@ -27,12 +27,15 @@ class StorePlanRequest extends FormRequest
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', $uniqueSlugRule], 'slug' => ['required', 'string', 'max:255', $uniqueSlugRule],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'service_type' => ['required', Rule::in(['vps', 'dedicated', 'hosting', 'mysql', 'game_server'])], 'service_type' => ['required', Rule::in(['vps', 'dedicated', 'hosting', 'mysql', 'game', 'backups'])],
'price' => ['required', 'numeric', 'min:0'], 'price' => ['required', 'numeric', 'min:0'],
'billing_cycle' => ['required', Rule::in(['monthly', 'quarterly', 'semi_annual', 'annual'])], 'billing_cycle' => ['required', Rule::in(['monthly', 'quarterly', 'semi_annual', 'annual'])],
'features' => ['nullable', 'array'], 'features' => ['nullable', 'array'],
'features.*.key' => ['required_with:features', 'string', 'max:255'], 'features.*.key' => ['required_with:features', 'string', 'max:255'],
'features.*.value' => ['required_with:features', 'string', 'max:255'], 'features.*.value' => ['required_with:features', 'string', 'max:255'],
'provisioning_config' => ['nullable', 'array'],
'provisioning_config.package_id' => ['nullable', 'integer', Rule::requiredIf(fn () => in_array($this->input('service_type'), ['vps', 'dedicated']))],
'provisioning_config.hypervisor_id' => ['nullable', 'integer'],
'stock_quantity' => ['nullable', 'integer', 'min:0'], 'stock_quantity' => ['nullable', 'integer', 'min:0'],
'sort_order' => ['integer', 'min:0'], 'sort_order' => ['integer', 'min:0'],
]; ];

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreWinbackCampaignRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'cancellation_reason' => ['nullable', 'string', 'max:100'],
'email_sequence' => ['required', 'array', 'min:1'],
'email_sequence.*.delay_days' => ['required', 'integer', 'min:0'],
'email_sequence.*.subject' => ['required', 'string', 'max:255'],
'email_sequence.*.body' => ['required', 'string'],
'offer_type' => ['required', Rule::in(['discount', 'credit', 'free_upgrade', 'none'])],
'offer_value' => ['nullable', 'numeric', 'min:0'],
'offer_duration_days' => ['nullable', 'integer', 'min:1'],
'coupon_code' => ['nullable', 'string', 'max:50'],
'status' => ['required', Rule::in(['active', 'paused', 'archived'])],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'name.required' => 'Campaign name is required.',
'email_sequence.required' => 'At least one email must be defined.',
'email_sequence.min' => 'At least one email must be defined.',
'email_sequence.*.delay_days.required' => 'Delay days is required for each email.',
'email_sequence.*.delay_days.min' => 'Delay days must be at least 0.',
'email_sequence.*.subject.required' => 'Subject is required for each email.',
'email_sequence.*.body.required' => 'Body is required for each email.',
'offer_type.required' => 'Offer type is required.',
'offer_type.in' => 'Offer type must be discount, credit, free_upgrade, or none.',
'status.required' => 'Status is required.',
'status.in' => 'Status must be active, paused, or archived.',
];
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Listeners; namespace App\Listeners;
use App\Events\SubscriptionCancelled; use App\Events\SubscriptionCancelled;
use App\Models\WinbackCampaign;
use App\Models\WinbackRecipient;
use App\Notifications\SubscriptionCancelledNotification; use App\Notifications\SubscriptionCancelledNotification;
use App\Services\Provisioning\ProvisioningFactory; use App\Services\Provisioning\ProvisioningFactory;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@@ -30,24 +32,44 @@ class HandleSubscriptionCancelled implements ShouldQueue
// Terminate associated services (VirtFusion handles delay) // Terminate associated services (VirtFusion handles delay)
$services = $event->user->services() $services = $event->user->services()
->where('subscription_id', $event->subscription->id) ->where('subscription_id', $event->subscription->id)
->whereIn('status', ['active', 'suspended']) ->whereIn('status', ['active', 'suspended', 'failed', 'pending'])
->where(function ($query): void {
// Exclude pending services without a platform ID — they may be mid-provisioning
// and will be cleaned up by RetryProvisioningCommand if they fail
$query->where('status', '!=', 'pending')
->orWhereNotNull('platform_service_id');
})
->get(); ->get();
foreach ($services as $service) { foreach ($services as $service) {
try { try {
$planId = $event->subscription->plan_id; // If service was never provisioned (no platform ID), just mark it terminated
if ($planId) { if (! $service->platform_service_id) {
$plan = \App\Models\Plan::find($planId); $service->update([
'status' => 'terminated',
'terminated_at' => now(),
]);
if ($plan) { Log::info('Service marked terminated (no platform service)', [
$provisioner = $this->provisioningFactory->make($plan->service_type); 'service_id' => $service->id,
'subscription_id' => $event->subscription->id,
]);
} elseif ($service->service_type) {
// Use service_type directly from the Service record — avoids dependency on plan_id
$provisioner = $this->provisioningFactory->make($service->service_type);
$provisioner->terminate($service); $provisioner->terminate($service);
Log::info('Service terminated successfully', [ Log::info('Service terminated successfully', [
'service_id' => $service->id, 'service_id' => $service->id,
'subscription_id' => $event->subscription->id, 'subscription_id' => $event->subscription->id,
'service_type' => $service->service_type,
]);
} else {
Log::warning('Subscription cancelled but could not determine service type for termination', [
'subscription_id' => $event->subscription->id,
'service_id' => $service->id,
'platform_service_id' => $service->platform_service_id,
]); ]);
}
} }
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Service termination exception', [ Log::error('Service termination exception', [
@@ -59,5 +81,38 @@ class HandleSubscriptionCancelled implements ShouldQueue
} }
$event->user->notify(new SubscriptionCancelledNotification($event->subscription)); $event->user->notify(new SubscriptionCancelledNotification($event->subscription));
// Enroll in matching win-back campaign
$this->enrollInWinbackCampaign($event);
}
private function enrollInWinbackCampaign(SubscriptionCancelled $event): void
{
try {
if ($event->cancellationReason) {
$campaign = WinbackCampaign::active()->forReason($event->cancellationReason)->first();
} else {
$campaign = WinbackCampaign::active()->whereNull('cancellation_reason')->first();
}
if ($campaign) {
WinbackRecipient::firstOrCreate([
'campaign_id' => $campaign->id,
'user_id' => $event->user->id,
'subscription_id' => $event->subscription->id,
]);
Log::info('User enrolled in win-back campaign', [
'user_id' => $event->user->id,
'campaign_id' => $campaign->id,
'reason' => $event->cancellationReason,
]);
}
} catch (\Exception $e) {
Log::error('Failed to enroll user in win-back campaign', [
'user_id' => $event->user->id,
'error' => $e->getMessage(),
]);
}
} }
} }

View File

@@ -6,6 +6,7 @@ namespace App\Listeners;
use App\Events\ServiceProvisioned; use App\Events\ServiceProvisioned;
use App\Events\SubscriptionCreated; use App\Events\SubscriptionCreated;
use App\Models\WinbackRecipient;
use App\Notifications\SubscriptionCreatedNotification; use App\Notifications\SubscriptionCreatedNotification;
use App\Services\Provisioning\ProvisioningFactory; use App\Services\Provisioning\ProvisioningFactory;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@@ -24,6 +25,16 @@ class HandleSubscriptionCreated implements ShouldQueue
'type' => $event->subscription->type, 'type' => $event->subscription->type,
]); ]);
// Mark any active win-back recipients as reactivated
WinbackRecipient::query()
->where('user_id', $event->user->id)
->where('reactivated', false)
->whereNull('unsubscribed_at')
->update([
'reactivated' => true,
'reactivated_at' => now(),
]);
// Send notification first — provisioning may fail but user should always know about the subscription // Send notification first — provisioning may fail but user should always know about the subscription
$event->user->notify(new SubscriptionCreatedNotification($event->subscription)); $event->user->notify(new SubscriptionCreatedNotification($event->subscription));

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Models\LoginHistory;
use App\Models\TrustedDevice;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
class HandleTwoFactorAuthenticated
{
public function handle(ValidTwoFactorAuthenticationCodeProvided $event): void
{
$request = request();
if (! $request->boolean('trust_device')) {
return;
}
$user = $event->user;
$ip = $request->ip() ?? '127.0.0.1';
$userAgent = $request->userAgent() ?? '';
$deviceHash = LoginHistory::generateDeviceHash($userAgent, $ip);
// Parse a friendly device name from the user agent
$deviceName = $this->resolveDeviceName($userAgent);
TrustedDevice::query()->updateOrCreate(
[
'user_id' => $user->id,
'device_hash' => $deviceHash,
],
[
'device_name' => $deviceName,
'ip_address' => $ip,
'last_used_at' => now(),
'expires_at' => now()->addDays(30),
]
);
}
private function resolveDeviceName(string $userAgent): string
{
// Extract browser
$browser = 'Unknown Browser';
$browserPatterns = [
'Edge' => '/Edg[e\/]/',
'Chrome' => '/Chrome\//',
'Firefox' => '/Firefox\//',
'Safari' => '/Safari\//',
'Opera' => '/OPR\//',
];
foreach ($browserPatterns as $name => $pattern) {
if (preg_match($pattern, $userAgent)) {
$browser = $name;
break;
}
}
// Extract OS
$os = 'Unknown OS';
$osPatterns = [
'Windows' => '/Windows/',
'macOS' => '/Macintosh/',
'Linux' => '/Linux/',
'Android' => '/Android/',
'iOS' => '/iPhone|iPad/',
];
foreach ($osPatterns as $name => $pattern) {
if (preg_match($pattern, $userAgent)) {
$os = $name;
break;
}
}
return "{$browser} on {$os}";
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Models\LoginHistory;
use App\Models\User;
use Illuminate\Auth\Events\Failed;
use Illuminate\Support\Facades\Log;
use Jenssegers\Agent\Agent;
use Stevebauman\Location\Facades\Location;
class RecordFailedLogin
{
public function handle(Failed $event): void
{
// Look up user by email — may not exist
$email = $event->credentials['email'] ?? null;
if (! $email) {
return;
}
$user = $event->user ?? User::query()->where('email', $email)->first();
if (! $user) {
return;
}
$request = request();
$ip = $request->ip() ?? '127.0.0.1';
$userAgentString = $request->userAgent() ?? '';
// Parse user agent
$agent = new Agent;
$agent->setUserAgent($userAgentString);
$deviceType = $this->resolveDeviceType($agent);
$browser = $agent->browser() ?: null;
$os = $agent->platform() ?: null;
// GeoIP lookup
$country = null;
$city = null;
try {
$position = Location::get($ip);
if ($position) {
$country = $position->countryName ?? null;
$city = $position->cityName ?? null;
}
} catch (\Exception $e) {
Log::debug('GeoIP lookup failed for failed login history', [
'ip' => $ip,
'error' => $e->getMessage(),
]);
}
$deviceHash = LoginHistory::generateDeviceHash($userAgentString, $ip);
LoginHistory::query()->create([
'user_id' => $user->id,
'ip_address' => $ip,
'user_agent' => $userAgentString,
'device_type' => $deviceType,
'browser' => $browser,
'os' => $os,
'location_country' => $country,
'location_city' => $city,
'success' => false,
'two_factor_used' => false,
'is_new_device' => false,
'device_hash' => $deviceHash,
]);
}
private function resolveDeviceType(Agent $agent): string
{
if ($agent->isRobot()) {
return 'Robot';
}
if ($agent->isPhone()) {
return 'Phone';
}
if ($agent->isTablet()) {
return 'Tablet';
}
if ($agent->isDesktop()) {
return 'Desktop';
}
return 'Other';
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Models\LoginHistory;
use App\Notifications\NewDeviceLoginNotification;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Log;
use Jenssegers\Agent\Agent;
use Stevebauman\Location\Facades\Location;
class RecordLoginHistory
{
public function handle(Login $event): void
{
$user = $event->user;
$request = request();
$ip = $request->ip() ?? '127.0.0.1';
$userAgentString = $request->userAgent() ?? '';
// Parse user agent
$agent = new Agent;
$agent->setUserAgent($userAgentString);
$deviceType = $this->resolveDeviceType($agent);
$browser = $agent->browser() ?: null;
$os = $agent->platform() ?: null;
// GeoIP lookup
$country = null;
$city = null;
try {
$position = Location::get($ip);
if ($position) {
$country = $position->countryName ?? null;
$city = $position->cityName ?? null;
}
} catch (\Exception $e) {
Log::debug('GeoIP lookup failed for login history', [
'ip' => $ip,
'error' => $e->getMessage(),
]);
}
// Device hash and new device detection
$deviceHash = LoginHistory::generateDeviceHash($userAgentString, $ip);
$hasExistingLogins = LoginHistory::query()
->forUser($user->id)
->exists();
$isNewDevice = false;
if ($hasExistingLogins) {
$existingDevice = LoginHistory::query()
->forUser($user->id)
->where('device_hash', $deviceHash)
->exists();
$isNewDevice = ! $existingDevice;
}
// Check if 2FA was used (user has 2FA enabled means it was used for this login)
$twoFactorUsed = ! empty($user->two_factor_secret);
$loginHistory = LoginHistory::query()->create([
'user_id' => $user->id,
'ip_address' => $ip,
'user_agent' => $userAgentString,
'device_type' => $deviceType,
'browser' => $browser,
'os' => $os,
'location_country' => $country,
'location_city' => $city,
'success' => true,
'two_factor_used' => $twoFactorUsed,
'is_new_device' => $isNewDevice,
'device_hash' => $deviceHash,
]);
// Notify on new device (only if user has previous logins)
if ($isNewDevice) {
$user->notify(new NewDeviceLoginNotification($loginHistory));
}
}
private function resolveDeviceType(Agent $agent): string
{
if ($agent->isRobot()) {
return 'Robot';
}
if ($agent->isPhone()) {
return 'Phone';
}
if ($agent->isTablet()) {
return 'Tablet';
}
if ($agent->isDesktop()) {
return 'Desktop';
}
return 'Other';
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\Subscription;
class CancellationSurvey extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'subscription_id',
'cancellation_reason',
'cancellation_feedback',
'would_return',
];
protected function casts(): array
{
return [
'created_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LoginHistory extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'ip_address',
'user_agent',
'device_type',
'browser',
'os',
'location_country',
'location_city',
'success',
'two_factor_used',
'is_new_device',
'device_hash',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'success' => 'boolean',
'two_factor_used' => 'boolean',
'is_new_device' => 'boolean',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Generate a device hash from user agent and first 3 octets of IP.
*/
public static function generateDeviceHash(string $userAgent, string $ip): string
{
$ipPrefix = self::getIpPrefix($ip);
return hash('sha256', $userAgent.$ipPrefix);
}
/**
* Extract the first 3 octets of an IPv4 address, or first 3 groups of an IPv6 address.
*/
private static function getIpPrefix(string $ip): string
{
if (str_contains($ip, '.')) {
// IPv4: take first 3 octets
$parts = explode('.', $ip);
return implode('.', array_slice($parts, 0, 3));
}
// IPv6: take first 3 groups
$parts = explode(':', $ip);
return implode(':', array_slice($parts, 0, 3));
}
/** @param Builder<LoginHistory> $query */
public function scopeSuccessful(Builder $query): void
{
$query->where('success', true);
}
/** @param Builder<LoginHistory> $query */
public function scopeFailed(Builder $query): void
{
$query->where('success', false);
}
/** @param Builder<LoginHistory> $query */
public function scopeForUser(Builder $query, int $userId): void
{
$query->where('user_id', $userId);
}
}

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class Plan extends Model class Plan extends Model
@@ -24,6 +26,7 @@ class Plan extends Model
'stripe_product_id', 'stripe_product_id',
'paypal_plan_id', 'paypal_plan_id',
'features', 'features',
'provisioning_config',
'stock_quantity', 'stock_quantity',
'status', 'status',
'sort_order', 'sort_order',
@@ -34,6 +37,7 @@ class Plan extends Model
return [ return [
'price' => 'decimal:2', 'price' => 'decimal:2',
'features' => 'array', 'features' => 'array',
'provisioning_config' => 'array',
'stock_quantity' => 'integer', 'stock_quantity' => 'integer',
'sort_order' => 'integer', 'sort_order' => 'integer',
]; ];
@@ -54,6 +58,11 @@ class Plan extends Model
return $this->hasMany(PlanPrice::class); return $this->hasMany(PlanPrice::class);
} }
public function configGroups(): BelongsToMany
{
return $this->belongsToMany(PlanConfigGroup::class, 'plan_config_group_plan');
}
public function priceForCycle(string $cycle): ?PlanPrice public function priceForCycle(string $cycle): ?PlanPrice
{ {
return $this->prices()->where('billing_cycle', $cycle)->first(); return $this->prices()->where('billing_cycle', $cycle)->first();
@@ -61,7 +70,7 @@ class Plan extends Model
public function isAvailable(): bool public function isAvailable(): bool
{ {
if ($this->status !== 'active') { if (! in_array($this->status, ['active', 'internal'])) {
return false; return false;
} }
@@ -71,4 +80,9 @@ class Plan extends Model
return true; return true;
} }
public function scopePublic(Builder $query): Builder
{
return $query->whereNotIn('status', ['hidden', 'internal', 'inactive']);
}
} }

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class PlanConfigGroup extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name',
'description',
'mode',
'service_type',
'is_active',
'sort_order',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}
public function options(): HasMany
{
return $this->hasMany(PlanConfigOption::class, 'group_id')->orderBy('sort_order');
}
public function plans(): BelongsToMany
{
return $this->belongsToMany(Plan::class, 'plan_config_group_plan');
}
public function scopePreset(Builder $query): Builder
{
return $query->where('mode', 'preset');
}
public function scopeBuildYourOwn(Builder $query): Builder
{
return $query->where('mode', 'build_your_own');
}
public function scopeForServiceType(Builder $query, string $type): Builder
{
return $query->where('service_type', $type);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Models;
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\HasMany;
class PlanConfigOption extends Model
{
use HasFactory;
protected $fillable = [
'group_id',
'name',
'description',
'type',
'provisioning_key',
'required',
'is_active',
'min_qty',
'max_qty',
'step',
'unit_label',
'hourly_price',
'monthly_price',
'quarterly_price',
'semi_annual_price',
'annual_price',
'sort_order',
];
protected function casts(): array
{
return [
'required' => 'boolean',
'is_active' => 'boolean',
'min_qty' => 'integer',
'max_qty' => 'integer',
'step' => 'integer',
'hourly_price' => 'decimal:4',
'monthly_price' => 'decimal:2',
'quarterly_price' => 'decimal:2',
'semi_annual_price' => 'decimal:2',
'annual_price' => 'decimal:2',
'sort_order' => 'integer',
];
}
public function group(): BelongsTo
{
return $this->belongsTo(PlanConfigGroup::class, 'group_id');
}
public function values(): HasMany
{
return $this->hasMany(PlanConfigValue::class, 'option_id')->orderBy('sort_order');
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function isSlider(): bool
{
return $this->type === 'slider';
}
public function isQuantity(): bool
{
return $this->type === 'quantity';
}
public function isDropdown(): bool
{
return $this->type === 'dropdown';
}
public function isRadio(): bool
{
return $this->type === 'radio';
}
public function isCheckbox(): bool
{
return $this->type === 'checkbox';
}
public function isText(): bool
{
return $this->type === 'text';
}
public function calculatePrice(int $quantity, string $cycle): float
{
$priceField = match ($cycle) {
'hourly' => 'hourly_price',
'monthly' => 'monthly_price',
'quarterly' => 'quarterly_price',
'semi_annual' => 'semi_annual_price',
'annual' => 'annual_price',
default => 'monthly_price',
};
return (float) (($this->{$priceField} ?? 0) * $quantity);
}
public function getHourlyPrice(int $quantity): float
{
return (float) (($this->hourly_price ?? 0) * $quantity);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlanConfigValue extends Model
{
use HasFactory;
protected $fillable = [
'option_id',
'label',
'value',
'hourly_price',
'monthly_price',
'quarterly_price',
'semi_annual_price',
'annual_price',
'is_default',
'sort_order',
];
protected function casts(): array
{
return [
'hourly_price' => 'decimal:4',
'monthly_price' => 'decimal:2',
'quarterly_price' => 'decimal:2',
'semi_annual_price' => 'decimal:2',
'annual_price' => 'decimal:2',
'is_default' => 'boolean',
'sort_order' => 'integer',
];
}
public function option(): BelongsTo
{
return $this->belongsTo(PlanConfigOption::class, 'option_id');
}
public function getPriceForCycle(string $cycle): float
{
$priceField = match ($cycle) {
'hourly' => 'hourly_price',
'monthly' => 'monthly_price',
'quarterly' => 'quarterly_price',
'semi_annual' => 'semi_annual_price',
'annual' => 'annual_price',
default => 'monthly_price',
};
return (float) ($this->{$priceField} ?? 0);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\Subscription;
class SubscriptionConfigSelection extends Model
{
protected $fillable = [
'subscription_id',
'option_id',
'value_id',
'quantity',
'text_value',
'locked_price',
'locked_hourly_price',
'billing_cycle',
'is_custom_build',
];
protected function casts(): array
{
return [
'quantity' => 'integer',
'locked_price' => 'decimal:4',
'locked_hourly_price' => 'decimal:4',
'is_custom_build' => 'boolean',
];
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function option(): BelongsTo
{
return $this->belongsTo(PlanConfigOption::class, 'option_id');
}
public function value(): BelongsTo
{
return $this->belongsTo(PlanConfigValue::class, 'value_id');
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TrustedDevice extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'device_hash',
'device_name',
'ip_address',
'last_used_at',
'expires_at',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @param Builder<TrustedDevice> $query */
public function scopeActive(Builder $query): void
{
$query->where('expires_at', '>', now());
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
}

View File

@@ -91,6 +91,16 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(CouponRedemption::class); return $this->hasMany(CouponRedemption::class);
} }
public function loginHistories(): HasMany
{
return $this->hasMany(LoginHistory::class);
}
public function trustedDevices(): HasMany
{
return $this->hasMany(TrustedDevice::class);
}
public function isAdmin(): bool public function isAdmin(): bool
{ {
return $this->hasRole('admin'); return $this->hasRole('admin');

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class WinbackCampaign extends Model
{
use HasFactory;
protected $fillable = [
'name',
'cancellation_reason',
'email_sequence',
'offer_type',
'offer_value',
'offer_duration_days',
'coupon_code',
'status',
];
protected function casts(): array
{
return [
'email_sequence' => 'array',
'offer_value' => 'decimal:2',
'offer_duration_days' => 'integer',
];
}
public function recipients(): HasMany
{
return $this->hasMany(WinbackRecipient::class, 'campaign_id');
}
/** @param Builder<self> $query */
public function scopeActive(Builder $query): Builder
{
return $query->where('status', 'active');
}
/** @param Builder<self> $query */
public function scopeForReason(Builder $query, string $reason): Builder
{
return $query->where('cancellation_reason', $reason);
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\Subscription;
class WinbackRecipient extends Model
{
use HasFactory;
protected $fillable = [
'campaign_id',
'user_id',
'subscription_id',
'current_email_index',
'last_email_sent_at',
'opened_count',
'clicked_count',
'reactivated',
'reactivated_at',
'unsubscribed_at',
];
protected function casts(): array
{
return [
'current_email_index' => 'integer',
'opened_count' => 'integer',
'clicked_count' => 'integer',
'reactivated' => 'boolean',
'last_email_sent_at' => 'datetime',
'reactivated_at' => 'datetime',
'unsubscribed_at' => 'datetime',
];
}
public function campaign(): BelongsTo
{
return $this->belongsTo(WinbackCampaign::class, 'campaign_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function isActive(): bool
{
if ($this->unsubscribed_at !== null) {
return false;
}
if ($this->reactivated) {
return false;
}
return $this->campaign->status === 'active';
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\LoginHistory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class NewDeviceLoginNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public LoginHistory $loginHistory,
) {}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
$location = $this->formatLocation();
$deviceInfo = $this->formatDeviceInfo();
$profileUrl = 'https://'.config('app.domains.account').'/profile';
return (new MailMessage)
->subject('New sign-in to your EZSCALE account')
->greeting("Hello {$notifiable->name}!")
->line("We noticed a new sign-in to your account from a device we don't recognize.")
->line("**Device:** {$deviceInfo}")
->line("**Location:** {$location}")
->line("**IP Address:** {$this->loginHistory->ip_address}")
->line('If this was you, you can safely ignore this email. If you did not sign in, please change your password immediately and enable two-factor authentication.')
->action('Review Account Security', $profileUrl)
->line('Thank you for choosing EZSCALE!');
}
/** @return array<string, mixed> */
public function toArray(object $notifiable): array
{
return [
'type' => 'new_device_login',
'login_history_id' => $this->loginHistory->id,
'ip_address' => $this->loginHistory->ip_address,
'browser' => $this->loginHistory->browser,
'os' => $this->loginHistory->os,
'device_type' => $this->loginHistory->device_type,
'location' => $this->formatLocation(),
'message' => "New sign-in from {$this->formatDeviceInfo()} ({$this->formatLocation()})",
];
}
private function formatDeviceInfo(): string
{
$parts = array_filter([
$this->loginHistory->browser,
$this->loginHistory->os,
]);
if (empty($parts)) {
return 'Unknown device';
}
return implode(' on ', $parts);
}
private function formatLocation(): string
{
$parts = array_filter([
$this->loginHistory->location_city,
$this->loginHistory->location_country,
]);
if (empty($parts)) {
return $this->loginHistory->ip_address;
}
return implode(', ', $parts);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\WinbackCampaign;
use App\Models\WinbackRecipient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\URL;
class WinbackEmailNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* @param array{delay_days: int, subject: string, body: string} $emailConfig
*/
public function __construct(
public WinbackCampaign $campaign,
public WinbackRecipient $recipient,
public array $emailConfig,
) {}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$unsubscribeUrl = URL::signedRoute('winback.unsubscribe', [
'recipient' => $this->recipient->id,
]);
$pricingUrl = 'https://'.config('app.domains.marketing').'/pricing';
$mail = (new MailMessage)
->subject($this->emailConfig['subject'])
->greeting("Hello {$notifiable->name},")
->line($this->emailConfig['body']);
if ($this->campaign->offer_type !== 'none' && $this->campaign->offer_value) {
$offerDescription = $this->buildOfferDescription();
$mail->line('');
$mail->line("**Special Offer:** {$offerDescription}");
if ($this->campaign->coupon_code) {
$mail->line("Use coupon code: **{$this->campaign->coupon_code}**");
}
if ($this->campaign->offer_duration_days) {
$mail->line("This offer is valid for {$this->campaign->offer_duration_days} days.");
}
}
$mail->action('View Plans', $pricingUrl);
$mail->line('');
$mail->line("[Unsubscribe from these emails]({$unsubscribeUrl})");
return $mail;
}
private function buildOfferDescription(): string
{
return match ($this->campaign->offer_type) {
'discount' => number_format((float) $this->campaign->offer_value, 0).'% off your next subscription',
'credit' => '$'.number_format((float) $this->campaign->offer_value, 2).' account credit',
'free_upgrade' => 'Free upgrade for '.((int) $this->campaign->offer_duration_days).' days',
default => '',
};
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\RedirectIfTwoFactorConfirmable;
use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword; use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation; use App\Actions\Fortify\UpdateUserProfileInformation;
@@ -15,6 +16,11 @@ use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Fortify\Actions\AttemptToAuthenticate;
use Laravel\Fortify\Actions\CanonicalizeUsername;
use Laravel\Fortify\Actions\EnsureLoginIsNotThrottled;
use Laravel\Fortify\Actions\PrepareAuthenticatedSession;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify; use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider class FortifyServiceProvider extends ServiceProvider
@@ -31,6 +37,16 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class); Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
// Custom authentication pipeline: replaces the default 2FA redirect
// with our trusted-device-aware version that skips 2FA for trusted devices.
Fortify::authenticateThrough(fn (Request $request): array => array_filter([
config('fortify.limiters.login') ? null : EnsureLoginIsNotThrottled::class,
config('fortify.lowercase_usernames') ? CanonicalizeUsername::class : null,
Features::enabled(Features::twoFactorAuthentication()) ? RedirectIfTwoFactorConfirmable::class : null,
AttemptToAuthenticate::class,
PrepareAuthenticatedSession::class,
]));
Fortify::loginView(function () { Fortify::loginView(function () {
$selectedPlan = $this->getSelectedPlanFromIntendedUrl(); $selectedPlan = $this->getSelectedPlanFromIntendedUrl();

View File

@@ -6,6 +6,7 @@ namespace App\Services\Billing;
use App\Models\Coupon; use App\Models\Coupon;
use App\Models\Plan; use App\Models\Plan;
use App\Models\Service;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -26,17 +27,22 @@ class StripeBillingService implements BillingServiceInterface
$planPrice = $plan->priceForCycle($billingCycle); $planPrice = $plan->priceForCycle($billingCycle);
$stripePriceId = $planPrice?->stripe_price_id ?? $plan->stripe_price_id; $stripePriceId = $planPrice?->stripe_price_id ?? $plan->stripe_price_id;
if (! $stripePriceId) {
throw new \RuntimeException("Plan \"{$plan->name}\" does not have a Stripe price configured for the {$billingCycle} cycle.");
}
$subscription = $user->newSubscription($plan->slug, $stripePriceId); $subscription = $user->newSubscription($plan->slug, $stripePriceId);
if ($couponCode) { if ($couponCode) {
$coupon = Coupon::where('code', $couponCode)->first(); $coupon = Coupon::where('code', $couponCode)->first();
if ($coupon?->isValid()) { if ($coupon?->isValid()) {
$subscription->withPromotionCode($couponCode); $subscription->withCoupon($couponCode);
} }
} }
try { try {
$result = DB::transaction(function () use ($subscription, $plan, $billingCycle) { $result = DB::transaction(function () use ($subscription, $plan, $billingCycle, $stripePriceId) {
$cashierSubscription = $subscription->create(); $cashierSubscription = $subscription->create();
$cashierSubscription->update([ $cashierSubscription->update([
@@ -115,6 +121,12 @@ class StripeBillingService implements BillingServiceInterface
'current_period_end' => $this->calculatePeriodEnd($billingCycle), 'current_period_end' => $this->calculatePeriodEnd($billingCycle),
]); ]);
// After the swap, update the service's plan_id
$service = Service::where('subscription_id', $subscription->id)->first();
if ($service) {
$service->update(['plan_id' => $newPlan->id]);
}
return [ return [
'subscription_id' => $subscription->stripe_id, 'subscription_id' => $subscription->stripe_id,
'status' => $subscription->stripe_status, 'status' => $subscription->stripe_status,

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\SubscriptionConfigSelection;
use Illuminate\Support\Collection;
use Laravel\Cashier\Subscription;
class ConfigSelectionService
{
public function getSelections(Subscription $subscription): Collection
{
return SubscriptionConfigSelection::query()
->where('subscription_id', $subscription->id)
->with(['option', 'value'])
->get();
}
public function totalConfigPrice(Subscription $subscription, string $cycle): float
{
return (float) SubscriptionConfigSelection::query()
->where('subscription_id', $subscription->id)
->where('billing_cycle', $cycle)
->sum('locked_price');
}
public function totalHourlyPrice(Subscription $subscription): float
{
return (float) SubscriptionConfigSelection::query()
->where('subscription_id', $subscription->id)
->whereNotNull('locked_hourly_price')
->sum('locked_hourly_price');
}
}

View File

@@ -102,10 +102,13 @@ class VirtFusionService implements ProvisioningServiceInterface
// Ensure user exists on VirtFusion panel // Ensure user exists on VirtFusion panel
$virtfusionUserId = $this->ensureUserExists($user); $virtfusionUserId = $this->ensureUserExists($user);
// Get custom specs from plan // Get provisioning config from the plan (package_id, hypervisor_id, etc.)
$specs = $this->getPlanSpecs($plan); $provConfig = $plan->provisioning_config ?? [];
if (! $specs) { $packageId = $provConfig['package_id'] ?? null;
throw new RuntimeException('Plan does not have valid specifications.'); $hypervisorId = $provConfig['hypervisor_id'] ?? 1;
if (! $packageId) {
throw new RuntimeException('Plan does not have a VirtFusion package configured. Set package_id in the plan\'s provisioning config.');
} }
// Get configuration for OS template and SSH keys from the subscription record // Get configuration for OS template and SSH keys from the subscription record
@@ -128,14 +131,11 @@ class VirtFusionService implements ProvisioningServiceInterface
} }
} }
// Step 2: Create server with custom specs (no packageId needed if we provide specs directly) // Step 2: Create server using VirtFusion package (specs are defined in the package)
$createPayload = [ $createPayload = [
'userId' => $virtfusionUserId, 'userId' => $virtfusionUserId,
'hypervisorId' => $plan->features['virtfusion_hypervisor_id'] ?? 1, 'packageId' => $packageId,
'cpuCores' => $specs['cpu'], 'hypervisorId' => $hypervisorId,
'memory' => $specs['memory'],
'storage' => $specs['disk'],
'traffic' => $specs['bandwidth'] ?? 1000,
]; ];
$createResponse = $this->client()->post('/servers', $createPayload); $createResponse = $this->client()->post('/servers', $createPayload);
@@ -155,7 +155,7 @@ class VirtFusionService implements ProvisioningServiceInterface
Log::info('VirtFusion server created', [ Log::info('VirtFusion server created', [
'service_id' => $service->id, 'service_id' => $service->id,
'server_id' => $serverId, 'server_id' => $serverId,
'specs' => $specs, 'package_id' => $packageId,
]); ]);
// Step 3: Build server with OS template and SSH keys // Step 3: Build server with OS template and SSH keys
@@ -201,7 +201,7 @@ class VirtFusionService implements ProvisioningServiceInterface
'credentials' => array_merge($existingCredentials, [ 'credentials' => array_merge($existingCredentials, [
'os_template_id' => $operatingSystemId, 'os_template_id' => $operatingSystemId,
'auth_method' => $config['auth_method'] ?? 'password', 'auth_method' => $config['auth_method'] ?? 'password',
'specs' => $specs, 'package_id' => $packageId,
'ssh_key_ids' => $sshKeyIds, 'ssh_key_ids' => $sshKeyIds,
]), ]),
]); ]);
@@ -785,10 +785,10 @@ class VirtFusionService implements ProvisioningServiceInterface
* *
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
public function getAllTemplates(int $hypervisorId = 1): array public function getAllTemplates(int $packageId = 1): array
{ {
// Deprecated: use getTemplatesByPackage instead // Deprecated: use getTemplatesByPackage instead
return $this->getTemplatesByPackage($hypervisorId); return $this->getTemplatesByPackage($packageId);
} }
/** /**
@@ -828,57 +828,59 @@ class VirtFusionService implements ProvisioningServiceInterface
/** /**
* Ensure user exists on VirtFusion panel, create if not. * Ensure user exists on VirtFusion panel, create if not.
* Uses extRelationId (our local user ID) to link VF users to our system.
*/ */
private function ensureUserExists(\App\Models\User $user): int private function ensureUserExists(\App\Models\User $user): int
{ {
// Check if user already has a VirtFusion user ID stored // Fast path: already have the VF user ID cached locally
if ($user->virtfusion_user_id) { if ($user->virtfusion_user_id) {
return $user->virtfusion_user_id; return $user->virtfusion_user_id;
} }
// Try to find user by email // Try to find user by extRelationId (our local user ID)
try { $existingUserId = $this->findUserByExtRelation($user->id);
$response = $this->client()->get('/users', [ if ($existingUserId) {
'email' => $user->email, $user->update(['virtfusion_user_id' => $existingUserId]);
]);
if ($response->successful()) { return $existingUserId;
$data = $response->json();
$users = $data['data'] ?? [];
// If user found, store the ID and return it
foreach ($users as $vfUser) {
if ($vfUser['email'] === $user->email) {
$userId = (int) $vfUser['id'];
$user->update(['virtfusion_user_id' => $userId]);
return $userId;
}
}
}
} catch (\Exception $e) {
Log::warning('Failed to search for VirtFusion user', [
'email' => $user->email,
'error' => $e->getMessage(),
]);
} }
// User doesn't exist, create them // User doesn't exist, create them with extRelationId set to our local user ID
try { try {
$response = $this->client()->post('/users', [ $response = $this->client()->post('/users', [
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'password' => bin2hex(random_bytes(16)), // Random password, user will reset via VF panel 'password' => bin2hex(random_bytes(16)),
'confirmed' => true, 'confirmed' => true,
'extRelationId' => $user->id,
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
Log::error('Failed to create VirtFusion user', [ $responseBody = $response->body();
// If user already exists (email conflict), try to look them up by extRelationId
if (str_contains($responseBody, 'already exists')) {
// The user exists but wasn't linked via extRelationId — try all VF users
// Since there's no email search endpoint, we need to set the extRelationId
// on the existing VF user. For now, log and throw a helpful error.
Log::warning('VirtFusion user exists but not linked via extRelationId', [
'email' => $user->email, 'email' => $user->email,
'response' => $response->body(), 'local_user_id' => $user->id,
]); ]);
throw new RuntimeException("Failed to create VirtFusion user: {$response->body()}"); throw new RuntimeException(
"VirtFusion user with email {$user->email} already exists but is not linked. "
.'Please set extRelationId to '.$user->id.' on the VirtFusion user, '
.'or delete the VirtFusion user and retry.'
);
}
Log::error('Failed to create VirtFusion user', [
'email' => $user->email,
'response' => $responseBody,
]);
throw new RuntimeException("Failed to create VirtFusion user: {$responseBody}");
} }
$data = $response->json(); $data = $response->json();
@@ -889,6 +891,7 @@ class VirtFusionService implements ProvisioningServiceInterface
Log::info('Created VirtFusion user', [ Log::info('Created VirtFusion user', [
'email' => $user->email, 'email' => $user->email,
'virtfusion_user_id' => $userId, 'virtfusion_user_id' => $userId,
'extRelationId' => $user->id,
]); ]);
return $userId; return $userId;
@@ -908,40 +911,23 @@ class VirtFusionService implements ProvisioningServiceInterface
} }
/** /**
* Get VirtFusion package specs from plan. * Look up a VirtFusion user by extRelationId (our local user ID).
*
* @return array<string, mixed>|null
*/ */
private function getPlanSpecs(\App\Models\Plan $plan): ?array private function findUserByExtRelation(int $extRelationId): ?int
{ {
// Extract specs from plan features or use defaults based on plan name try {
$features = $plan->features ?? []; $response = $this->client()->get("/users/{$extRelationId}/byExtRelation");
// If specs are explicitly defined in features, use them if ($response->successful()) {
if (isset($features['cpu']) && isset($features['memory']) && isset($features['disk'])) { $data = $response->json();
return [
'cpu' => $features['cpu'], return (int) ($data['data']['id'] ?? 0) ?: null;
'memory' => $features['memory'], // in MB
'disk' => $features['disk'], // in GB
'bandwidth' => $features['bandwidth'] ?? 1000, // in GB
];
}
// Otherwise, parse from plan name (e.g., "Nano" -> 1 CPU, 1GB RAM, 25GB SSD)
$nameToSpecs = [
'nano' => ['cpu' => 1, 'memory' => 1024, 'disk' => 25, 'bandwidth' => 1000],
'micro' => ['cpu' => 1, 'memory' => 2048, 'disk' => 50, 'bandwidth' => 2000],
'mini' => ['cpu' => 2, 'memory' => 4096, 'disk' => 75, 'bandwidth' => 3000],
'standard' => ['cpu' => 2, 'memory' => 8192, 'disk' => 100, 'bandwidth' => 4000],
'plus' => ['cpu' => 4, 'memory' => 16384, 'disk' => 150, 'bandwidth' => 5000],
'pro' => ['cpu' => 6, 'memory' => 32768, 'disk' => 200, 'bandwidth' => 6000],
];
$planName = strtolower($plan->name);
foreach ($nameToSpecs as $key => $specs) {
if (str_contains($planName, $key)) {
return $specs;
} }
} catch (\Exception $e) {
Log::warning('Failed to find VirtFusion user by extRelation', [
'extRelationId' => $extRelationId,
'error' => $e->getMessage(),
]);
} }
return null; return null;

View File

@@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace App\Services\Reports;
use App\Models\Invoice;
use App\Models\PaymentTransaction;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Subscription;
class FinancialReportService
{
private const CACHE_TTL = 300; // 5 minutes
/**
* Revenue report: transactions grouped by period, service type, plan, and gateway.
*
* @return array{total_revenue: float, by_period: array<int, array{period: string, amount: float}>, by_service_type: array<int, array{type: string, amount: float}>, by_gateway: array<int, array{gateway: string, amount: float}>, by_plan: array<int, array{plan: string, amount: float}>}
*/
public function revenueReport(Carbon $startDate, Carbon $endDate, ?string $serviceType = null): array
{
$cacheKey = "report.revenue.{$startDate->toDateString()}.{$endDate->toDateString()}.{$serviceType}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($startDate, $endDate, $serviceType): array {
$baseQuery = PaymentTransaction::query()
->where('payment_transactions.status', 'succeeded')
->whereBetween('payment_transactions.created_at', [$startDate->startOfDay(), $endDate->endOfDay()]);
if ($serviceType) {
$baseQuery->join('invoices', 'payment_transactions.invoice_id', '=', 'invoices.id')
->join('subscriptions', 'invoices.subscription_id', '=', 'subscriptions.id')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->where('plans.service_type', $serviceType);
}
$totalRevenue = (float) (clone $baseQuery)->sum('payment_transactions.amount');
$byPeriod = (clone $baseQuery)
->selectRaw("DATE_FORMAT(payment_transactions.created_at, '%Y-%m') as period, SUM(payment_transactions.amount) as amount")
->groupBy('period')
->orderBy('period')
->get()
->map(fn ($row) => ['period' => $row->period, 'amount' => (float) $row->amount])
->values()
->toArray();
// For service type breakdown, we always need joins
$typeQuery = PaymentTransaction::query()
->where('payment_transactions.status', 'succeeded')
->whereBetween('payment_transactions.created_at', [$startDate->startOfDay(), $endDate->endOfDay()])
->join('invoices', 'payment_transactions.invoice_id', '=', 'invoices.id')
->join('subscriptions', 'invoices.subscription_id', '=', 'subscriptions.id')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id');
if ($serviceType) {
$typeQuery->where('plans.service_type', $serviceType);
}
$byServiceType = (clone $typeQuery)
->selectRaw('plans.service_type as type, SUM(payment_transactions.amount) as amount')
->groupBy('plans.service_type')
->orderByDesc('amount')
->get()
->map(fn ($row) => ['type' => $row->type, 'amount' => (float) $row->amount])
->values()
->toArray();
$byGateway = PaymentTransaction::query()
->where('status', 'succeeded')
->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()])
->selectRaw('gateway, SUM(amount) as amount')
->groupBy('gateway')
->orderByDesc('amount')
->get()
->map(fn ($row) => ['gateway' => $row->gateway, 'amount' => (float) $row->amount])
->values()
->toArray();
$byPlan = (clone $typeQuery)
->selectRaw('plans.name as plan, SUM(payment_transactions.amount) as amount')
->groupBy('plans.name')
->orderByDesc('amount')
->get()
->map(fn ($row) => ['plan' => $row->plan, 'amount' => (float) $row->amount])
->values()
->toArray();
return [
'total_revenue' => $totalRevenue,
'by_period' => $byPeriod,
'by_service_type' => $byServiceType,
'by_gateway' => $byGateway,
'by_plan' => $byPlan,
];
});
}
/**
* Profit & Loss report: Revenue - Refunds - Gateway fees - Infrastructure cost.
*
* @return array{revenue: float, refunds: float, gateway_fees: float, infrastructure_cost: float, net_profit: float, margin_percentage: float}
*/
public function profitLossReport(Carbon $startDate, Carbon $endDate): array
{
$cacheKey = "report.profit_loss.{$startDate->toDateString()}.{$endDate->toDateString()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($startDate, $endDate): array {
$stats = PaymentTransaction::query()
->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()])
->selectRaw("
COALESCE(SUM(CASE WHEN status = 'succeeded' THEN amount ELSE 0 END), 0) as revenue,
COALESCE(SUM(CASE WHEN status = 'refunded' THEN amount ELSE 0 END), 0) as refunds,
COALESCE(SUM(CASE
WHEN status = 'succeeded' AND gateway = 'stripe' THEN (amount * 0.029) + 0.30
WHEN status = 'succeeded' AND gateway = 'paypal' THEN (amount * 0.0349) + 0.49
ELSE 0
END), 0) as gateway_fees
")
->first();
$revenue = (float) $stats->revenue;
$refunds = (float) $stats->refunds;
$gatewayFees = round((float) $stats->gateway_fees, 2);
// Infrastructure cost estimated at 40% of revenue (configurable)
$infrastructureCostPercent = (float) config('billing.infrastructure_cost_percent', 40);
$infrastructureCost = round($revenue * ($infrastructureCostPercent / 100), 2);
$netProfit = round($revenue - $refunds - $gatewayFees - $infrastructureCost, 2);
$marginPercentage = $revenue > 0 ? round(($netProfit / $revenue) * 100, 1) : 0;
return [
'revenue' => $revenue,
'refunds' => $refunds,
'gateway_fees' => $gatewayFees,
'infrastructure_cost' => $infrastructureCost,
'net_profit' => $netProfit,
'margin_percentage' => $marginPercentage,
];
});
}
/**
* Tax report: paid invoices grouped by user billing country/region.
*
* @return array{total_tax: float, by_country: array<int, array{country: string, amount: float, invoice_count: int}>, by_region: array<int, array{region: string, country: string, amount: float}>}
*/
public function taxReport(Carbon $startDate, Carbon $endDate): array
{
$cacheKey = "report.tax.{$startDate->toDateString()}.{$endDate->toDateString()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($startDate, $endDate): array {
$totalTax = (float) Invoice::query()
->where('status', 'paid')
->whereBetween('paid_at', [$startDate->startOfDay(), $endDate->endOfDay()])
->where('tax', '>', 0)
->sum('tax');
$byCountry = Invoice::query()
->where('invoices.status', 'paid')
->whereBetween('invoices.paid_at', [$startDate->startOfDay(), $endDate->endOfDay()])
->where('invoices.tax', '>', 0)
->join('user_profiles', 'invoices.user_id', '=', 'user_profiles.user_id')
->selectRaw('COALESCE(user_profiles.billing_country, "Unknown") as country, SUM(invoices.tax) as amount, COUNT(invoices.id) as invoice_count')
->groupBy('country')
->orderByDesc('amount')
->get()
->map(fn ($row) => [
'country' => $row->country,
'amount' => (float) $row->amount,
'invoice_count' => (int) $row->invoice_count,
])
->values()
->toArray();
$byRegion = Invoice::query()
->where('invoices.status', 'paid')
->whereBetween('invoices.paid_at', [$startDate->startOfDay(), $endDate->endOfDay()])
->where('invoices.tax', '>', 0)
->join('user_profiles', 'invoices.user_id', '=', 'user_profiles.user_id')
->selectRaw('COALESCE(user_profiles.billing_state, "Unknown") as region, COALESCE(user_profiles.billing_country, "Unknown") as country, SUM(invoices.tax) as amount')
->groupBy('region', 'country')
->orderByDesc('amount')
->get()
->map(fn ($row) => [
'region' => $row->region,
'country' => $row->country,
'amount' => (float) $row->amount,
])
->values()
->toArray();
return [
'total_tax' => $totalTax,
'by_country' => $byCountry,
'by_region' => $byRegion,
];
});
}
/**
* Aging report: pending/overdue invoices grouped by age brackets.
*
* @return array{current: array{count: int, amount: float}, days_31_60: array{count: int, amount: float}, days_61_90: array{count: int, amount: float}, days_90_plus: array{count: int, amount: float}, total: array{count: int, amount: float}, invoices: array<int, array{id: int, invoice_number: string, customer: string, amount: float, due_date: string, days_overdue: int}>}
*/
public function agingReport(): array
{
$cacheKey = 'report.aging';
return Cache::remember($cacheKey, self::CACHE_TTL, function (): array {
$invoices = Invoice::query()
->whereIn('status', ['pending', 'overdue'])
->with('user')
->get();
$buckets = [
'current' => ['count' => 0, 'amount' => 0.0],
'days_31_60' => ['count' => 0, 'amount' => 0.0],
'days_61_90' => ['count' => 0, 'amount' => 0.0],
'days_90_plus' => ['count' => 0, 'amount' => 0.0],
];
$total = ['count' => 0, 'amount' => 0.0];
$invoiceDetails = [];
foreach ($invoices as $invoice) {
$dueDate = $invoice->due_date ?? $invoice->created_at;
$daysOverdue = max(0, (int) now()->diffInDays($dueDate, false) * -1);
$amount = (float) $invoice->total;
$bucket = match (true) {
$daysOverdue <= 30 => 'current',
$daysOverdue <= 60 => 'days_31_60',
$daysOverdue <= 90 => 'days_61_90',
default => 'days_90_plus',
};
$buckets[$bucket]['count']++;
$buckets[$bucket]['amount'] = round($buckets[$bucket]['amount'] + $amount, 2);
$total['count']++;
$total['amount'] = round($total['amount'] + $amount, 2);
$invoiceDetails[] = [
'id' => $invoice->id,
'invoice_number' => $invoice->number,
'customer' => $invoice->user?->name ?? 'Unknown',
'amount' => $amount,
'due_date' => $dueDate->toDateString(),
'days_overdue' => $daysOverdue,
];
}
// Sort invoices by days overdue descending
usort($invoiceDetails, fn ($a, $b) => $b['days_overdue'] <=> $a['days_overdue']);
return [
'current' => $buckets['current'],
'days_31_60' => $buckets['days_31_60'],
'days_61_90' => $buckets['days_61_90'],
'days_90_plus' => $buckets['days_90_plus'],
'total' => $total,
'invoices' => $invoiceDetails,
];
});
}
/**
* Refund report: transactions with status=refunded grouped by gateway and month.
*
* @return array{total_refunds: float, refund_count: int, by_gateway: array<int, array{gateway: string, amount: float, count: int}>, by_month: array<int, array{month: string, amount: float, count: int}>}
*/
public function refundReport(Carbon $startDate, Carbon $endDate): array
{
$cacheKey = "report.refund.{$startDate->toDateString()}.{$endDate->toDateString()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($startDate, $endDate): array {
$baseQuery = PaymentTransaction::query()
->where('status', 'refunded')
->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()]);
$totals = (clone $baseQuery)
->selectRaw('COALESCE(SUM(amount), 0) as total_refunds, COUNT(*) as refund_count')
->first();
$byGateway = (clone $baseQuery)
->selectRaw('gateway, SUM(amount) as amount, COUNT(*) as count')
->groupBy('gateway')
->orderByDesc('amount')
->get()
->map(fn ($row) => [
'gateway' => $row->gateway,
'amount' => (float) $row->amount,
'count' => (int) $row->count,
])
->values()
->toArray();
$byMonth = (clone $baseQuery)
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as amount, COUNT(*) as count")
->groupBy('month')
->orderBy('month')
->get()
->map(fn ($row) => [
'month' => $row->month,
'amount' => (float) $row->amount,
'count' => (int) $row->count,
])
->values()
->toArray();
return [
'total_refunds' => (float) $totals->total_refunds,
'refund_count' => (int) $totals->refund_count,
'by_gateway' => $byGateway,
'by_month' => $byMonth,
];
});
}
/**
* Subscription report: new/cancelled subs, churn rate, MRR start/end.
*
* @return array{new_subscriptions: int, cancelled_subscriptions: int, churn_rate: float, mrr_start: float, mrr_end: float, mrr_change: float, by_plan: array<int, array{plan: string, new: int, cancelled: int, active: int}>}
*/
public function subscriptionReport(Carbon $startDate, Carbon $endDate): array
{
$cacheKey = "report.subscription.{$startDate->toDateString()}.{$endDate->toDateString()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($startDate, $endDate): array {
$newSubscriptions = Subscription::query()
->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()])
->count();
$cancelledSubscriptions = Subscription::query()
->whereBetween('cancelled_at', [$startDate->startOfDay(), $endDate->endOfDay()])
->count();
// Total at start of period
$totalAtStart = Subscription::query()
->where('created_at', '<', $startDate->startOfDay())
->where(function ($query) use ($startDate): void {
$query->whereNull('cancelled_at')
->orWhere('cancelled_at', '>', $startDate->startOfDay());
})
->count();
$churnRate = $totalAtStart > 0
? round(($cancelledSubscriptions / $totalAtStart) * 100, 1)
: 0;
$mrrStart = $this->calculateMrrAtDate($startDate);
$mrrEnd = $this->calculateMrrAtDate($endDate);
$mrrChange = round($mrrEnd - $mrrStart, 2);
// Breakdown by plan
$byPlan = DB::table('plans')
->leftJoin('subscriptions as new_subs', function ($join) use ($startDate, $endDate): void {
$join->on('plans.id', '=', 'new_subs.plan_id')
->whereBetween('new_subs.created_at', [$startDate->startOfDay(), $endDate->endOfDay()]);
})
->leftJoin('subscriptions as cancelled_subs', function ($join) use ($startDate, $endDate): void {
$join->on('plans.id', '=', 'cancelled_subs.plan_id')
->whereBetween('cancelled_subs.cancelled_at', [$startDate->startOfDay(), $endDate->endOfDay()]);
})
->leftJoin('subscriptions as active_subs', function ($join): void {
$join->on('plans.id', '=', 'active_subs.plan_id')
->where('active_subs.stripe_status', 'active');
})
->select(
'plans.name as plan',
DB::raw('COUNT(DISTINCT new_subs.id) as new_count'),
DB::raw('COUNT(DISTINCT cancelled_subs.id) as cancelled_count'),
DB::raw('COUNT(DISTINCT active_subs.id) as active_count'),
)
->groupBy('plans.id', 'plans.name')
->having(DB::raw('COUNT(DISTINCT new_subs.id) + COUNT(DISTINCT cancelled_subs.id) + COUNT(DISTINCT active_subs.id)'), '>', 0)
->orderByDesc('active_count')
->get()
->map(fn ($row) => [
'plan' => $row->plan,
'new' => (int) $row->new_count,
'cancelled' => (int) $row->cancelled_count,
'active' => (int) $row->active_count,
])
->values()
->toArray();
return [
'new_subscriptions' => $newSubscriptions,
'cancelled_subscriptions' => $cancelledSubscriptions,
'churn_rate' => $churnRate,
'mrr_start' => $mrrStart,
'mrr_end' => $mrrEnd,
'mrr_change' => $mrrChange,
'by_plan' => $byPlan,
];
});
}
/**
* Calculate MRR at a specific date.
*/
private function calculateMrrAtDate(Carbon $date): float
{
return (float) (Subscription::query()
->where('subscriptions.stripe_status', 'active')
->whereNotNull('subscriptions.plan_id')
->where('subscriptions.created_at', '<=', $date->endOfDay())
->where(function ($query) use ($date): void {
$query->whereNull('subscriptions.cancelled_at')
->orWhere('subscriptions.cancelled_at', '>', $date->endOfDay());
})
->join('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
END) as mrr')
->value('mrr') ?? 0);
}
}

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Services\Reports;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReportExportService
{
/**
* Map of report types to their Blade view names.
*
* @var array<string, string>
*/
private const VIEW_MAP = [
'revenue' => 'reports.revenue',
'profit_loss' => 'reports.profit_loss',
'tax' => 'reports.tax',
'aging' => 'reports.aging',
'refund' => 'reports.refund',
'subscription' => 'reports.subscription',
];
/**
* Export report as PDF using DomPDF.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $meta
*/
public function toPdf(string $reportType, array $data, array $meta): Response
{
$view = self::VIEW_MAP[$reportType] ?? 'reports.revenue';
$pdf = Pdf::loadView($view, [
'data' => $data,
'meta' => $meta,
]);
$filename = $this->generateFilename($reportType, $meta, 'pdf');
return $pdf->download($filename);
}
/**
* Export report as CSV.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $meta
*/
public function toCsv(string $reportType, array $data, array $meta): StreamedResponse
{
$filename = $this->generateFilename($reportType, $meta, 'csv');
return new StreamedResponse(function () use ($reportType, $data, $meta): void {
$handle = fopen('php://output', 'w');
if ($handle === false) {
return;
}
// Header row with report info
fputcsv($handle, ['Report', $meta['title'] ?? ucfirst(str_replace('_', ' ', $reportType))]);
if (! empty($meta['start_date'])) {
fputcsv($handle, ['Date Range', ($meta['start_date'] ?? '').' to '.($meta['end_date'] ?? '')]);
}
fputcsv($handle, ['Generated', $meta['generated_at'] ?? now()->toIso8601String()]);
fputcsv($handle, []);
$this->writeCsvData($handle, $reportType, $data);
fclose($handle);
}, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
]);
}
/**
* Export report as JSON.
*
* @param array<string, mixed> $data
* @param array<string, mixed> $meta
*/
public function toJson(string $reportType, array $data, array $meta): JsonResponse
{
return response()->json([
'report_type' => $reportType,
'meta' => $meta,
'data' => $data,
]);
}
/**
* Generate a filename for the report export.
*
* @param array<string, mixed> $meta
*/
private function generateFilename(string $reportType, array $meta, string $extension): string
{
$parts = ['ezscale', str_replace('_', '-', $reportType), 'report'];
if (! empty($meta['start_date'])) {
$parts[] = $meta['start_date'];
$parts[] = 'to';
$parts[] = $meta['end_date'] ?? 'now';
}
return implode('-', $parts).'.'.$extension;
}
/**
* Write report-specific CSV data rows.
*
* @param resource $handle
* @param array<string, mixed> $data
*/
private function writeCsvData($handle, string $reportType, array $data): void
{
match ($reportType) {
'revenue' => $this->writeCsvRevenue($handle, $data),
'profit_loss' => $this->writeCsvProfitLoss($handle, $data),
'tax' => $this->writeCsvTax($handle, $data),
'aging' => $this->writeCsvAging($handle, $data),
'refund' => $this->writeCsvRefund($handle, $data),
'subscription' => $this->writeCsvSubscription($handle, $data),
default => null,
};
}
/**
* @param resource $handle
* @param array<string, mixed> $data
*/
private function writeCsvRevenue($handle, array $data): void
{
fputcsv($handle, ['Total Revenue', number_format($data['total_revenue'] ?? 0, 2)]);
fputcsv($handle, []);
fputcsv($handle, ['Revenue by Period']);
fputcsv($handle, ['Period', 'Amount']);
foreach ($data['by_period'] ?? [] as $row) {
fputcsv($handle, [$row['period'], number_format($row['amount'], 2)]);
}
fputcsv($handle, []);
fputcsv($handle, ['Revenue by Service Type']);
fputcsv($handle, ['Type', 'Amount']);
foreach ($data['by_service_type'] ?? [] as $row) {
fputcsv($handle, [$row['type'], number_format($row['amount'], 2)]);
}
fputcsv($handle, []);
fputcsv($handle, ['Revenue by Gateway']);
fputcsv($handle, ['Gateway', 'Amount']);
foreach ($data['by_gateway'] ?? [] as $row) {
fputcsv($handle, [$row['gateway'], number_format($row['amount'], 2)]);
}
fputcsv($handle, []);
fputcsv($handle, ['Revenue by Plan']);
fputcsv($handle, ['Plan', 'Amount']);
foreach ($data['by_plan'] ?? [] as $row) {
fputcsv($handle, [$row['plan'], number_format($row['amount'], 2)]);
}
}
/**
* @param resource $handle
* @param array<string, mixed> $data
*/
private function writeCsvProfitLoss($handle, array $data): void
{
fputcsv($handle, ['Item', 'Amount']);
fputcsv($handle, ['Revenue', number_format($data['revenue'] ?? 0, 2)]);
fputcsv($handle, ['Refunds', number_format($data['refunds'] ?? 0, 2)]);
fputcsv($handle, ['Gateway Fees', number_format($data['gateway_fees'] ?? 0, 2)]);
fputcsv($handle, ['Infrastructure Cost', number_format($data['infrastructure_cost'] ?? 0, 2)]);
fputcsv($handle, []);
fputcsv($handle, ['Net Profit', number_format($data['net_profit'] ?? 0, 2)]);
fputcsv($handle, ['Margin %', ($data['margin_percentage'] ?? 0).'%']);
}
/**
* @param resource $handle
* @param array<string, mixed> $data
*/
private function writeCsvTax($handle, array $data): void
{
fputcsv($handle, ['Total Tax Collected', number_format($data['total_tax'] ?? 0, 2)]);
fputcsv($handle, []);
fputcsv($handle, ['Tax by Country']);
fputcsv($handle, ['Country', 'Amount', 'Invoice Count']);
foreach ($data['by_country'] ?? [] as $row) {
fputcsv($handle, [$row['country'], number_format($row['amount'], 2), $row['invoice_count']]);
}
fputcsv($handle, []);
fputcsv($handle, ['Tax by Region']);
fputcsv($handle, ['Region', 'Country', 'Amount']);
foreach ($data['by_region'] ?? [] as $row) {
fputcsv($handle, [$row['region'], $row['country'], number_format($row['amount'], 2)]);
}
}
/**
* @param resource $handle
* @param array<string, mixed> $data
*/
private function writeCsvAging($handle, array $data): void
{
fputcsv($handle, ['Aging Summary']);
fputcsv($handle, ['Bracket', 'Count', 'Amount']);
fputcsv($handle, ['0-30 days', $data['current']['count'] ?? 0, number_format($data['current']['amount'] ?? 0, 2)]);
fputcsv($handle, ['31-60 days', $data['days_31_60']['count'] ?? 0, number_format($data['days_31_60']['amount'] ?? 0, 2)]);
fputcsv($handle, ['61-90 days', $data['days_61_90']['count'] ?? 0, number_format($data['days_61_90']['amount'] ?? 0, 2)]);
fputcsv($handle, ['90+ days', $data['days_90_plus']['count'] ?? 0, number_format($data['days_90_plus']['amount'] ?? 0, 2)]);
fputcsv($handle, []);
fputcsv($handle, ['Total', $data['total']['count'] ?? 0, number_format($data['total']['amount'] ?? 0, 2)]);
fputcsv($handle, []);
fputcsv($handle, ['Invoice Details']);
fputcsv($handle, ['Invoice #', 'Customer', 'Amount', 'Due Date', 'Days Overdue']);
foreach ($data['invoices'] ?? [] as $inv) {
fputcsv($handle, [$inv['invoice_number'], $inv['customer'], number_format($inv['amount'], 2), $inv['due_date'], $inv['days_overdue']]);
}
}
/**
* @param resource $handle
* @param array<string, mixed> $data
*/
private function writeCsvRefund($handle, array $data): void
{
fputcsv($handle, ['Total Refunds', number_format($data['total_refunds'] ?? 0, 2)]);
fputcsv($handle, ['Refund Count', $data['refund_count'] ?? 0]);
fputcsv($handle, []);
fputcsv($handle, ['Refunds by Gateway']);
fputcsv($handle, ['Gateway', 'Amount', 'Count']);
foreach ($data['by_gateway'] ?? [] as $row) {
fputcsv($handle, [$row['gateway'], number_format($row['amount'], 2), $row['count']]);
}
fputcsv($handle, []);
fputcsv($handle, ['Refunds by Month']);
fputcsv($handle, ['Month', 'Amount', 'Count']);
foreach ($data['by_month'] ?? [] as $row) {
fputcsv($handle, [$row['month'], number_format($row['amount'], 2), $row['count']]);
}
}
/**
* @param resource $handle
* @param array<string, mixed> $data
*/
private function writeCsvSubscription($handle, array $data): void
{
fputcsv($handle, ['Metric', 'Value']);
fputcsv($handle, ['New Subscriptions', $data['new_subscriptions'] ?? 0]);
fputcsv($handle, ['Cancelled Subscriptions', $data['cancelled_subscriptions'] ?? 0]);
fputcsv($handle, ['Churn Rate', ($data['churn_rate'] ?? 0).'%']);
fputcsv($handle, ['MRR Start', number_format($data['mrr_start'] ?? 0, 2)]);
fputcsv($handle, ['MRR End', number_format($data['mrr_end'] ?? 0, 2)]);
fputcsv($handle, ['MRR Change', number_format($data['mrr_change'] ?? 0, 2)]);
fputcsv($handle, []);
fputcsv($handle, ['Breakdown by Plan']);
fputcsv($handle, ['Plan', 'New', 'Cancelled', 'Active']);
foreach ($data['by_plan'] ?? [] as $row) {
fputcsv($handle, [$row['plan'], $row['new'], $row['cancelled'], $row['active']]);
}
}
}

View File

@@ -35,13 +35,13 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'webhooks/*', 'webhooks/*',
'api/*', 'api/*',
'stripe/webhook',
'oauth/*', 'oauth/*',
// Admin API endpoints for testing // Admin API endpoints for testing
'*/settings/test-*', '*/settings/test-*',
]); ]);
$middleware->web(append: [ $middleware->web(append: [
\Illuminate\Session\Middleware\AuthenticateSession::class,
\App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\HandleInertiaRequests::class,
\App\Http\Middleware\ScreenshotAuthMiddleware::class, \App\Http\Middleware\ScreenshotAuthMiddleware::class,
]); ]);

View File

@@ -12,6 +12,7 @@
"php": "^8.2", "php": "^8.2",
"barryvdh/laravel-dompdf": "^3.1", "barryvdh/laravel-dompdf": "^3.1",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^2.0",
"jenssegers/agent": "^2.6",
"laravel/cashier": "^16.2", "laravel/cashier": "^16.2",
"laravel/fortify": "^1.34", "laravel/fortify": "^1.34",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
@@ -20,6 +21,7 @@
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"spatie/laravel-permission": "^6.24", "spatie/laravel-permission": "^6.24",
"srmklive/paypal": "^3.0", "srmklive/paypal": "^3.0",
"stevebauman/location": "^7.6",
"webklex/php-imap": "^6.2" "webklex/php-imap": "^6.2"
}, },
"require-dev": { "require-dev": {

513
website/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "087f780f9db61d870cf4fb516369a71a", "content-hash": "58faeefce96f5a565ceecd0869605b38",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -267,6 +267,78 @@
], ],
"time": "2024-02-09T16:56:22+00:00" "time": "2024-02-09T16:56:22+00:00"
}, },
{
"name": "composer/ca-bundle",
"version": "1.5.10",
"source": {
"type": "git",
"url": "https://github.com/composer/ca-bundle.git",
"reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63",
"reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-pcre": "*",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8 || ^9",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\CaBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.",
"keywords": [
"cabundle",
"cacert",
"certificate",
"ssl",
"tls"
],
"support": {
"irc": "irc://irc.freenode.org/composer",
"issues": "https://github.com/composer/ca-bundle/issues",
"source": "https://github.com/composer/ca-bundle/tree/1.5.10"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-12-08T15:06:51+00:00"
},
{ {
"name": "dasprid/enum", "name": "dasprid/enum",
"version": "1.0.7", "version": "1.0.7",
@@ -1046,6 +1118,64 @@
], ],
"time": "2025-12-03T09:33:47+00:00" "time": "2025-12-03T09:33:47+00:00"
}, },
{
"name": "geoip2/geoip2",
"version": "v3.3.0",
"source": {
"type": "git",
"url": "https://github.com/maxmind/GeoIP2-php.git",
"reference": "49fceddd694295e76e970a32848e03bb19e56b42"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/GeoIP2-php/zipball/49fceddd694295e76e970a32848e03bb19e56b42",
"reference": "49fceddd694295e76e970a32848e03bb19e56b42",
"shasum": ""
},
"require": {
"ext-json": "*",
"maxmind-db/reader": "^1.13.0",
"maxmind/web-service-common": "~0.11",
"php": ">=8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpstan/phpstan": "*",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "4.*"
},
"type": "library",
"autoload": {
"psr-4": {
"GeoIp2\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Gregory J. Oschwald",
"email": "goschwald@maxmind.com",
"homepage": "https://www.maxmind.com/"
}
],
"description": "MaxMind GeoIP2 PHP API",
"homepage": "https://github.com/maxmind/GeoIP2-php",
"keywords": [
"IP",
"geoip",
"geoip2",
"geolocation",
"maxmind"
],
"support": {
"issues": "https://github.com/maxmind/GeoIP2-php/issues",
"source": "https://github.com/maxmind/GeoIP2-php/tree/v3.3.0"
},
"time": "2025-11-20T18:50:15+00:00"
},
{ {
"name": "graham-campbell/result-type", "name": "graham-campbell/result-type",
"version": "v1.1.4", "version": "v1.1.4",
@@ -1589,6 +1719,141 @@
}, },
"time": "2026-01-13T15:29:20+00:00" "time": "2026-01-13T15:29:20+00:00"
}, },
{
"name": "jaybizzle/crawler-detect",
"version": "v1.3.7",
"source": {
"type": "git",
"url": "https://github.com/JayBizzle/Crawler-Detect.git",
"reference": "7f7a45b5d5df9c95ba6b2008544e6cf8e66de6f5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/7f7a45b5d5df9c95ba6b2008544e6cf8e66de6f5",
"reference": "7f7a45b5d5df9c95ba6b2008544e6cf8e66de6f5",
"shasum": ""
},
"require": {
"php": ">=7.1.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8|^5.5|^6.5|^7.5|^8.5|^9.4"
},
"type": "library",
"autoload": {
"psr-4": {
"Jaybizzle\\CrawlerDetect\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Beech",
"email": "m@rkbee.ch",
"role": "Developer"
}
],
"description": "CrawlerDetect is a PHP class for detecting bots/crawlers/spiders via the user agent",
"homepage": "https://github.com/JayBizzle/Crawler-Detect/",
"keywords": [
"crawler",
"crawler detect",
"crawler detector",
"crawlerdetect",
"php crawler detect"
],
"support": {
"issues": "https://github.com/JayBizzle/Crawler-Detect/issues",
"source": "https://github.com/JayBizzle/Crawler-Detect/tree/v1.3.7"
},
"time": "2026-02-02T19:15:54+00:00"
},
{
"name": "jenssegers/agent",
"version": "v2.6.4",
"source": {
"type": "git",
"url": "https://github.com/jenssegers/agent.git",
"reference": "daa11c43729510b3700bc34d414664966b03bffe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jenssegers/agent/zipball/daa11c43729510b3700bc34d414664966b03bffe",
"reference": "daa11c43729510b3700bc34d414664966b03bffe",
"shasum": ""
},
"require": {
"jaybizzle/crawler-detect": "^1.2",
"mobiledetect/mobiledetectlib": "^2.7.6",
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5.0|^6.0|^7.0"
},
"suggest": {
"illuminate/support": "Required for laravel service providers"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Agent": "Jenssegers\\Agent\\Facades\\Agent"
},
"providers": [
"Jenssegers\\Agent\\AgentServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Jenssegers\\Agent\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jens Segers",
"homepage": "https://jenssegers.com"
}
],
"description": "Desktop/mobile user agent parser with support for Laravel, based on Mobiledetect",
"homepage": "https://github.com/jenssegers/agent",
"keywords": [
"Agent",
"browser",
"desktop",
"laravel",
"mobile",
"platform",
"user agent",
"useragent"
],
"support": {
"issues": "https://github.com/jenssegers/agent/issues",
"source": "https://github.com/jenssegers/agent/tree/v2.6.4"
},
"funding": [
{
"url": "https://github.com/jenssegers",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/jenssegers/agent",
"type": "tidelift"
}
],
"time": "2020-06-13T08:05:20+00:00"
},
{ {
"name": "laravel/cashier", "name": "laravel/cashier",
"version": "v16.2.0", "version": "v16.2.0",
@@ -3220,6 +3485,183 @@
}, },
"time": "2025-07-25T09:04:22+00:00" "time": "2025-07-25T09:04:22+00:00"
}, },
{
"name": "maxmind-db/reader",
"version": "v1.13.1",
"source": {
"type": "git",
"url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git",
"reference": "2194f58d0f024ce923e685cdf92af3daf9951908"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/2194f58d0f024ce923e685cdf92af3daf9951908",
"reference": "2194f58d0f024ce923e685cdf92af3daf9951908",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"conflict": {
"ext-maxminddb": "<1.11.1 || >=2.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpstan/phpstan": "*",
"phpunit/phpunit": ">=8.0.0,<10.0.0",
"squizlabs/php_codesniffer": "4.*"
},
"suggest": {
"ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder",
"ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder",
"ext-maxminddb": "A C-based database decoder that provides significantly faster lookups",
"maxmind-db/reader-ext": "C extension for significantly faster IP lookups (install via PIE: pie install maxmind-db/reader-ext)"
},
"type": "library",
"autoload": {
"psr-4": {
"MaxMind\\Db\\": "src/MaxMind/Db"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Gregory J. Oschwald",
"email": "goschwald@maxmind.com",
"homepage": "https://www.maxmind.com/"
}
],
"description": "MaxMind DB Reader API",
"homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php",
"keywords": [
"database",
"geoip",
"geoip2",
"geolocation",
"maxmind"
],
"support": {
"issues": "https://github.com/maxmind/MaxMind-DB-Reader-php/issues",
"source": "https://github.com/maxmind/MaxMind-DB-Reader-php/tree/v1.13.1"
},
"time": "2025-11-21T22:24:26+00:00"
},
{
"name": "maxmind/web-service-common",
"version": "v0.11.1",
"source": {
"type": "git",
"url": "https://github.com/maxmind/web-service-common-php.git",
"reference": "c309236b5a5555b96cf560089ec3cead12d845d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/web-service-common-php/zipball/c309236b5a5555b96cf560089ec3cead12d845d2",
"reference": "c309236b5a5555b96cf560089ec3cead12d845d2",
"shasum": ""
},
"require": {
"composer/ca-bundle": "^1.0.3",
"ext-curl": "*",
"ext-json": "*",
"php": ">=8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpstan/phpstan": "*",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "4.*"
},
"type": "library",
"autoload": {
"psr-4": {
"MaxMind\\Exception\\": "src/Exception",
"MaxMind\\WebService\\": "src/WebService"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Gregory Oschwald",
"email": "goschwald@maxmind.com"
}
],
"description": "Internal MaxMind Web Service API",
"homepage": "https://github.com/maxmind/web-service-common-php",
"support": {
"issues": "https://github.com/maxmind/web-service-common-php/issues",
"source": "https://github.com/maxmind/web-service-common-php/tree/v0.11.1"
},
"time": "2026-01-13T17:56:03+00:00"
},
{
"name": "mobiledetect/mobiledetectlib",
"version": "2.8.45",
"source": {
"type": "git",
"url": "https://github.com/serbanghita/Mobile-Detect.git",
"reference": "96aaebcf4f50d3d2692ab81d2c5132e425bca266"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/96aaebcf4f50d3d2692ab81d2c5132e425bca266",
"reference": "96aaebcf4f50d3d2692ab81d2c5132e425bca266",
"shasum": ""
},
"require": {
"php": ">=5.0.0"
},
"require-dev": {
"phpunit/phpunit": "~4.8.36"
},
"type": "library",
"autoload": {
"psr-0": {
"Detection": "namespaced/"
},
"classmap": [
"Mobile_Detect.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Serban Ghita",
"email": "serbanghita@gmail.com",
"homepage": "http://mobiledetect.net",
"role": "Developer"
}
],
"description": "Mobile_Detect is a lightweight PHP class for detecting mobile devices. It uses the User-Agent string combined with specific HTTP headers to detect the mobile environment.",
"homepage": "https://github.com/serbanghita/Mobile-Detect",
"keywords": [
"detect mobile devices",
"mobile",
"mobile detect",
"mobile detector",
"php mobile detect"
],
"support": {
"issues": "https://github.com/serbanghita/Mobile-Detect/issues",
"source": "https://github.com/serbanghita/Mobile-Detect/tree/2.8.45"
},
"funding": [
{
"url": "https://github.com/serbanghita",
"type": "github"
}
],
"time": "2023-11-07T21:57:25+00:00"
},
{ {
"name": "moneyphp/money", "name": "moneyphp/money",
"version": "v4.8.0", "version": "v4.8.0",
@@ -5277,6 +5719,75 @@
}, },
"time": "2025-02-25T21:38:18+00:00" "time": "2025-02-25T21:38:18+00:00"
}, },
{
"name": "stevebauman/location",
"version": "v7.6.2",
"source": {
"type": "git",
"url": "https://github.com/stevebauman/location.git",
"reference": "2f7686b685b924f193d6a6b82074861b8cc5aa3a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stevebauman/location/zipball/2f7686b685b924f193d6a6b82074861b8cc5aa3a",
"reference": "2f7686b685b924f193d6a6b82074861b8cc5aa3a",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"geoip2/geoip2": "^2.0|^3.0",
"guzzlehttp/guzzle": "^7.0",
"illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0",
"php": ">=8.1"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0",
"pestphp/pest": "^1.0|^2.0|^3.7"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Location": "Stevebauman\\Location\\Facades\\Location"
},
"providers": [
"Stevebauman\\Location\\LocationServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Stevebauman\\Location\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Steve Bauman",
"email": "steven_bauman@outlook.com"
}
],
"description": "Retrieve a user's location by their IP Address",
"keywords": [
"IP",
"geo",
"geo-location",
"geoip",
"laravel",
"location",
"php"
],
"support": {
"issues": "https://github.com/stevebauman/location/issues",
"source": "https://github.com/stevebauman/location/tree/v7.6.2"
},
"time": "2026-02-11T16:06:17+00:00"
},
{ {
"name": "stripe/stripe-php", "name": "stripe/stripe-php",
"version": "v17.6.0", "version": "v17.6.0",

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Cashier\Subscription;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\CancellationSurvey>
*/
class CancellationSurveyFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'subscription_id' => Subscription::factory(),
'cancellation_reason' => fake()->randomElement([
'Too expensive',
'No longer needed',
'Switching to competitor',
'Poor performance',
'Missing features',
'Technical issues',
'Other',
]),
'cancellation_feedback' => null,
'would_return' => null,
];
}
public function withFeedback(): static
{
return $this->state(fn (array $attributes) => [
'cancellation_feedback' => fake()->sentence(),
]);
}
public function wouldReturn(): static
{
return $this->state(fn (array $attributes) => [
'would_return' => fake()->randomElement(['yes', 'maybe', 'no']),
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\LoginHistory;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<LoginHistory> */
class LoginHistoryFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
$userAgent = fake()->userAgent();
$ip = fake()->ipv4();
return [
'user_id' => User::factory(),
'ip_address' => $ip,
'user_agent' => $userAgent,
'device_type' => fake()->randomElement(['Desktop', 'Phone', 'Tablet']),
'browser' => fake()->randomElement(['Chrome', 'Firefox', 'Safari', 'Edge']),
'os' => fake()->randomElement(['Windows 10', 'macOS', 'Ubuntu', 'iOS', 'Android']),
'location_country' => fake()->country(),
'location_city' => fake()->city(),
'success' => true,
'two_factor_used' => false,
'is_new_device' => false,
'device_hash' => LoginHistory::generateDeviceHash($userAgent, $ip),
];
}
public function failed(): static
{
return $this->state(fn (array $attributes): array => [
'success' => false,
]);
}
public function newDevice(): static
{
return $this->state(fn (array $attributes): array => [
'is_new_device' => true,
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\PlanConfigGroup;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<PlanConfigGroup>
*/
class PlanConfigGroupFactory extends Factory
{
protected $model = PlanConfigGroup::class;
/** @return array<string, mixed> */
public function definition(): array
{
return [
'name' => fake()->words(3, true),
'description' => fake()->sentence(),
'mode' => 'preset',
'service_type' => fake()->randomElement(['vps', 'dedicated', 'hosting', 'game']),
'is_active' => true,
'sort_order' => fake()->numberBetween(0, 100),
];
}
public function buildYourOwn(?string $serviceType = null): static
{
return $this->state(fn (array $attributes) => [
'mode' => 'build_your_own',
'service_type' => $serviceType ?? $attributes['service_type'],
]);
}
public function inactive(): static
{
return $this->state(fn () => [
'is_active' => false,
]);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\PlanConfigGroup;
use App\Models\PlanConfigOption;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<PlanConfigOption>
*/
class PlanConfigOptionFactory extends Factory
{
protected $model = PlanConfigOption::class;
/** @return array<string, mixed> */
public function definition(): array
{
return [
'group_id' => PlanConfigGroup::factory(),
'name' => fake()->words(2, true),
'description' => fake()->sentence(),
'type' => 'dropdown',
'provisioning_key' => fake()->optional()->word(),
'required' => false,
'is_active' => true,
'min_qty' => null,
'max_qty' => null,
'step' => 1,
'unit_label' => null,
'hourly_price' => null,
'monthly_price' => null,
'quarterly_price' => null,
'semi_annual_price' => null,
'annual_price' => null,
'sort_order' => fake()->numberBetween(0, 100),
];
}
public function slider(float $monthlyPerUnit = 5.00, float $hourlyPerUnit = 0.0075): static
{
return $this->state(fn () => [
'type' => 'slider',
'min_qty' => 1,
'max_qty' => 64,
'step' => 1,
'unit_label' => 'GB',
'hourly_price' => $hourlyPerUnit,
'monthly_price' => $monthlyPerUnit,
'quarterly_price' => round($monthlyPerUnit * 3 * 0.95, 2),
'semi_annual_price' => round($monthlyPerUnit * 6 * 0.90, 2),
'annual_price' => round($monthlyPerUnit * 12 * 0.85, 2),
]);
}
public function quantity(): static
{
return $this->state(fn () => [
'type' => 'quantity',
'min_qty' => 0,
'max_qty' => 100,
'step' => 1,
]);
}
public function checkbox(): static
{
return $this->state(fn () => [
'type' => 'checkbox',
]);
}
public function required(): static
{
return $this->state(fn () => [
'required' => true,
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\PlanConfigOption;
use App\Models\PlanConfigValue;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<PlanConfigValue>
*/
class PlanConfigValueFactory extends Factory
{
protected $model = PlanConfigValue::class;
/** @return array<string, mixed> */
public function definition(): array
{
return [
'option_id' => PlanConfigOption::factory(),
'label' => fake()->words(2, true),
'value' => fake()->optional()->word(),
'hourly_price' => 0,
'monthly_price' => 0,
'quarterly_price' => 0,
'semi_annual_price' => 0,
'annual_price' => 0,
'is_default' => false,
'sort_order' => fake()->numberBetween(0, 100),
];
}
public function asDefault(): static
{
return $this->state(fn () => [
'is_default' => true,
]);
}
public function withPrice(float $monthly = 10.00, float $hourly = 0.015): static
{
return $this->state(fn () => [
'hourly_price' => $hourly,
'monthly_price' => $monthly,
'quarterly_price' => round($monthly * 3 * 0.95, 2),
'semi_annual_price' => round($monthly * 6 * 0.90, 2),
'annual_price' => round($monthly * 12 * 0.85, 2),
]);
}
}

View File

@@ -19,7 +19,7 @@ class PlanFactory extends Factory
'name' => ucwords($name), 'name' => ucwords($name),
'slug' => Str::slug($name), 'slug' => Str::slug($name),
'description' => fake()->sentence(), 'description' => fake()->sentence(),
'service_type' => fake()->randomElement(['vps', 'dedicated', 'hosting', 'game_server']), 'service_type' => fake()->randomElement(['vps', 'dedicated', 'hosting', 'mysql', 'game', 'backups']),
'price' => fake()->randomFloat(2, 5, 500), 'price' => fake()->randomFloat(2, 5, 500),
'currency' => 'USD', 'currency' => 'USD',
'billing_cycle' => fake()->randomElement(['monthly', 'quarterly', 'annual']), 'billing_cycle' => fake()->randomElement(['monthly', 'quarterly', 'annual']),

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\LoginHistory;
use App\Models\TrustedDevice;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<TrustedDevice> */
class TrustedDeviceFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
$userAgent = fake()->userAgent();
$ip = fake()->ipv4();
return [
'user_id' => User::factory(),
'device_hash' => LoginHistory::generateDeviceHash($userAgent, $ip),
'device_name' => fake()->randomElement(['Chrome on Windows', 'Safari on macOS', 'Firefox on Linux', 'Chrome on Android']),
'ip_address' => $ip,
'last_used_at' => now(),
'expires_at' => now()->addDays(30),
];
}
public function expired(): static
{
return $this->state(fn (array $attributes): array => [
'expires_at' => now()->subDay(),
]);
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WinbackCampaign> */
class WinbackCampaignFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'name' => fake()->words(3, true).' Campaign',
'cancellation_reason' => fake()->randomElement([
'Too expensive',
'No longer needed',
'Switching to competitor',
'Poor performance',
'Missing features',
'Technical issues',
null,
]),
'email_sequence' => [
[
'delay_days' => 1,
'subject' => 'We miss you!',
'body' => 'We noticed you cancelled your subscription. We would love to have you back.',
],
[
'delay_days' => 7,
'subject' => 'A special offer just for you',
'body' => 'Come back and enjoy a special discount on your next subscription.',
],
],
'offer_type' => fake()->randomElement(['discount', 'credit', 'free_upgrade', 'none']),
'offer_value' => fake()->optional(0.7)->randomFloat(2, 5, 50),
'offer_duration_days' => fake()->optional(0.5)->numberBetween(7, 90),
'coupon_code' => fake()->optional(0.3)->bothify('WINBACK-##??'),
'status' => 'active',
];
}
public function active(): static
{
return $this->state(fn (array $attributes): array => [
'status' => 'active',
]);
}
public function paused(): static
{
return $this->state(fn (array $attributes): array => [
'status' => 'paused',
]);
}
public function archived(): static
{
return $this->state(fn (array $attributes): array => [
'status' => 'archived',
]);
}
public function forReason(string $reason): static
{
return $this->state(fn (array $attributes): array => [
'cancellation_reason' => $reason,
]);
}
public function catchAll(): static
{
return $this->state(fn (array $attributes): array => [
'cancellation_reason' => null,
]);
}
public function withDiscount(float $value = 20.00): static
{
return $this->state(fn (array $attributes): array => [
'offer_type' => 'discount',
'offer_value' => $value,
'offer_duration_days' => 30,
'coupon_code' => 'COMEBACK20',
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\User;
use App\Models\WinbackCampaign;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WinbackRecipient> */
class WinbackRecipientFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'campaign_id' => WinbackCampaign::factory(),
'user_id' => User::factory(),
'subscription_id' => null,
'current_email_index' => 0,
'last_email_sent_at' => null,
'opened_count' => 0,
'clicked_count' => 0,
'reactivated' => false,
'reactivated_at' => null,
'unsubscribed_at' => null,
];
}
public function reactivated(): static
{
return $this->state(fn (array $attributes): array => [
'reactivated' => true,
'reactivated_at' => now(),
]);
}
public function unsubscribed(): static
{
return $this->state(fn (array $attributes): array => [
'unsubscribed_at' => now(),
]);
}
public function withEmailsSent(int $count = 1): static
{
return $this->state(fn (array $attributes): array => [
'current_email_index' => $count,
'last_email_sent_at' => now()->subDays(2),
]);
}
}

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('plans', function (Blueprint $table): void {
$table->json('provisioning_config')->nullable()->after('features');
});
// Migrate existing virtfusion config from features to provisioning_config
DB::table('plans')->whereNotNull('features')->get()->each(function ($plan) {
$features = json_decode($plan->features, true) ?? [];
$provisioningConfig = [];
if (isset($features['virtfusion_package_id'])) {
$provisioningConfig['package_id'] = (int) $features['virtfusion_package_id'];
unset($features['virtfusion_package_id']);
}
if (isset($features['virtfusion_hypervisor_id'])) {
$provisioningConfig['hypervisor_id'] = (int) $features['virtfusion_hypervisor_id'];
unset($features['virtfusion_hypervisor_id']);
}
if (! empty($provisioningConfig)) {
DB::table('plans')->where('id', $plan->id)->update([
'provisioning_config' => json_encode($provisioningConfig),
'features' => json_encode($features),
]);
}
});
}
public function down(): void
{
// Move provisioning_config back into features before dropping
DB::table('plans')->whereNotNull('provisioning_config')->get()->each(function ($plan) {
$features = json_decode($plan->features, true) ?? [];
$config = json_decode($plan->provisioning_config, true) ?? [];
// Intentionally lossy: up() casts string→int, down() casts back to string.
// Original values were stored as strings in the features JSON.
if (isset($config['package_id'])) {
$features['virtfusion_package_id'] = (string) $config['package_id'];
}
if (isset($config['hypervisor_id'])) {
$features['virtfusion_hypervisor_id'] = (string) $config['hypervisor_id'];
}
DB::table('plans')->where('id', $plan->id)->update([
'features' => json_encode($features),
]);
});
Schema::table('plans', function (Blueprint $table): void {
$table->dropColumn('provisioning_config');
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cancellation_surveys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('subscription_id')->constrained()->cascadeOnDelete();
$table->string('cancellation_reason', 100);
$table->text('cancellation_feedback')->nullable();
$table->enum('would_return', ['yes', 'maybe', 'no'])->nullable();
$table->timestamp('created_at')->nullable();
$table->index('cancellation_reason');
$table->index('user_id');
$table->index('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cancellation_surveys');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('login_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('ip_address', 45);
$table->text('user_agent')->nullable();
$table->string('device_type', 50)->nullable();
$table->string('browser', 100)->nullable();
$table->string('os', 100)->nullable();
$table->string('location_country', 100)->nullable();
$table->string('location_city', 100)->nullable();
$table->boolean('success')->default(true);
$table->boolean('two_factor_used')->default(false);
$table->boolean('is_new_device')->default(false);
$table->string('device_hash', 64)->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['user_id', 'created_at']);
$table->index(['user_id', 'device_hash']);
$table->index('ip_address');
});
}
public function down(): void
{
Schema::dropIfExists('login_histories');
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('winback_campaigns', function (Blueprint $table): void {
$table->id();
$table->string('name', 255);
$table->string('cancellation_reason', 100)->nullable();
$table->json('email_sequence');
$table->enum('offer_type', ['discount', 'credit', 'free_upgrade', 'none']);
$table->decimal('offer_value', 10, 2)->nullable();
$table->unsignedInteger('offer_duration_days')->nullable();
$table->string('coupon_code', 50)->nullable();
$table->enum('status', ['active', 'paused', 'archived'])->default('active');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('winback_campaigns');
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('winback_recipients', function (Blueprint $table): void {
$table->id();
$table->foreignId('campaign_id')->constrained('winback_campaigns')->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('subscription_id')->nullable()->constrained()->cascadeOnDelete();
$table->integer('current_email_index')->default(0);
$table->timestamp('last_email_sent_at')->nullable();
$table->integer('opened_count')->default(0);
$table->integer('clicked_count')->default(0);
$table->boolean('reactivated')->default(false);
$table->timestamp('reactivated_at')->nullable();
$table->timestamp('unsubscribed_at')->nullable();
$table->timestamps();
$table->unique(['campaign_id', 'user_id', 'subscription_id'], 'winback_recipients_unique');
});
}
public function down(): void
{
Schema::dropIfExists('winback_recipients');
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('trusted_devices', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('device_hash', 64);
$table->string('device_name', 255)->nullable();
$table->string('ip_address', 45);
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at');
$table->timestamp('created_at')->nullable();
$table->unique(['user_id', 'device_hash']);
});
}
public function down(): void
{
Schema::dropIfExists('trusted_devices');
}
};

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('plan_config_groups', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('mode', 20)->default('preset');
$table->string('service_type', 50)->nullable();
$table->boolean('is_active')->default(true);
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->softDeletes();
$table->index(['mode', 'service_type']);
$table->index('is_active');
});
}
public function down(): void
{
Schema::dropIfExists('plan_config_groups');
}
};

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('plan_config_group_plan', function (Blueprint $table) {
$table->foreignId('plan_config_group_id')->constrained()->cascadeOnDelete();
$table->foreignId('plan_id')->constrained()->cascadeOnDelete();
$table->unique(['plan_config_group_id', 'plan_id'], 'config_group_plan_unique');
});
}
public function down(): void
{
Schema::dropIfExists('plan_config_group_plan');
}
};

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('plan_config_options', function (Blueprint $table) {
$table->id();
$table->foreignId('group_id')->constrained('plan_config_groups')->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->string('type', 50);
$table->string('provisioning_key', 100)->nullable();
$table->boolean('required')->default(false);
$table->boolean('is_active')->default(true);
$table->integer('min_qty')->nullable();
$table->integer('max_qty')->nullable();
$table->integer('step')->nullable()->default(1);
$table->string('unit_label', 50)->nullable();
$table->decimal('hourly_price', 10, 4)->nullable();
$table->decimal('monthly_price', 10, 2)->nullable();
$table->decimal('quarterly_price', 10, 2)->nullable();
$table->decimal('semi_annual_price', 10, 2)->nullable();
$table->decimal('annual_price', 10, 2)->nullable();
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->index(['group_id', 'sort_order']);
$table->index('is_active');
});
}
public function down(): void
{
Schema::dropIfExists('plan_config_options');
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('plan_config_values', function (Blueprint $table) {
$table->id();
$table->foreignId('option_id')->constrained('plan_config_options')->cascadeOnDelete();
$table->string('label');
$table->string('value')->nullable();
$table->decimal('hourly_price', 10, 4)->default(0);
$table->decimal('monthly_price', 10, 2)->default(0);
$table->decimal('quarterly_price', 10, 2)->default(0);
$table->decimal('semi_annual_price', 10, 2)->default(0);
$table->decimal('annual_price', 10, 2)->default(0);
$table->boolean('is_default')->default(false);
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->index(['option_id', 'sort_order']);
});
}
public function down(): void
{
Schema::dropIfExists('plan_config_values');
}
};

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