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,148 +80,114 @@ 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,
// Customer Growth (last 12 months - new signups per month) 'total' => (float) $row->total,
$customerGrowth = User::role('customer') 'transactions' => (int) $row->transactions,
->where('created_at', '>=', now()->subMonths(12)) 'avg_amount' => (float) $row->avg_amount,
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count") ]);
->groupBy('month')
->orderBy('month')
->get()
->map(fn ($row) => ['month' => $row->month, 'count' => (int) $row->count]);
// Churn Rate (subscriptions cancelled vs total in last 6 months)
$churnData = [];
for ($i = 5; $i >= 0; $i--) {
$monthStart = now()->subMonths($i)->startOfMonth();
$monthEnd = now()->subMonths($i)->endOfMonth();
$totalAtStart = Subscription::query()
->where('created_at', '<', $monthStart)
->where(function ($query) use ($monthStart): void {
$query->whereNull('cancelled_at')
->orWhere('cancelled_at', '>', $monthStart);
})
->count();
$cancelled = Subscription::query()
->whereBetween('cancelled_at', [$monthStart, $monthEnd])
->count();
$churnData[] = [
'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( return compact(
'totalCustomers', 'totalCustomers',
'newCustomersThisMonth',
'mrr', 'mrr',
'totalRevenue', 'mrrChangePercent',
'arr',
'activeServices', 'activeServices',
'pendingInvoicesCount', 'serviceBreakdown',
'pendingInvoicesAmount', 'totalTransactionRevenue',
'estimatedFees',
'netRevenue',
'revenueThisMonth',
'overdueCount', 'overdueCount',
'overdueAmount', 'overdueAmount',
'recentInvoices', 'currentChurnRate',
'recentSubscriptions', 'churnHealthStatus',
'popularPlans',
'revenueByServiceType',
'newCustomersThisMonth',
'revenueThisMonth',
'arr',
'revenueByMonth', 'revenueByMonth',
'customerGrowth',
'churnData',
'overdueInvoices',
); );
}); });
return Inertia::render('Admin/Dashboard', $stats); return Inertia::render('Admin/Dashboard', $stats);
} }
private function calculateMrr(): float
{
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()
->where('created_at', '<', $monthStart)
->where(function ($query) use ($monthStart): void {
$query->whereNull('cancelled_at')
->orWhere('cancelled_at', '>', $monthStart);
})
->count();
$cancelled = Subscription::query()
->whereBetween('cancelled_at', [$monthStart, $monthEnd])
->count();
return $totalAtStart > 0 ? round(($cancelled / $totalAtStart) * 100, 1) : 0;
}
} }

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,
$provisioner->terminate($service); '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);
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()) {
$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,
'local_user_id' => $user->id,
]);
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', [ Log::error('Failed to create VirtFusion user', [
'email' => $user->email, 'email' => $user->email,
'response' => $response->body(), 'response' => $responseBody,
]); ]);
throw new RuntimeException("Failed to create VirtFusion user: {$response->body()}"); 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'],
'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) return (int) ($data['data']['id'] ?? 0) ?: null;
$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