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:
@@ -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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.superpowers/
|
||||||
|
.mcp.json
|
||||||
|
.claude/settings.local.json
|
||||||
|
ezscale-discovery-*/
|
||||||
1377
docs/superpowers/plans/2026-03-14-vps-pricing-overhaul.md
Normal file
1377
docs/superpowers/plans/2026-03-14-vps-pricing-overhaul.md
Normal file
File diff suppressed because it is too large
Load Diff
2022
docs/superpowers/plans/2026-03-16-configurable-options.md
Normal file
2022
docs/superpowers/plans/2026-03-16-configurable-options.md
Normal file
File diff suppressed because it is too large
Load Diff
306
docs/superpowers/specs/2026-03-14-vps-pricing-overhaul-design.md
Normal file
306
docs/superpowers/specs/2026-03-14-vps-pricing-overhaul-design.md
Normal 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)
|
||||||
@@ -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
|
||||||
15
scripts/whmcs-migrate/.env.example
Normal file
15
scripts/whmcs-migrate/.env.example
Normal 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
6
scripts/whmcs-migrate/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/vendor/
|
||||||
|
.env
|
||||||
|
state/
|
||||||
|
logs/
|
||||||
|
exports/
|
||||||
|
composer.lock
|
||||||
14
scripts/whmcs-migrate/composer.json
Normal file
14
scripts/whmcs-migrate/composer.json
Normal 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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
scripts/whmcs-migrate/migrate.php
Normal file
222
scripts/whmcs-migrate/migrate.php
Normal 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;
|
||||||
|
}
|
||||||
46
scripts/whmcs-migrate/plan_mapping.json
Normal file
46
scripts/whmcs-migrate/plan_mapping.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
124
scripts/whmcs-migrate/src/Config.php
Normal file
124
scripts/whmcs-migrate/src/Config.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
scripts/whmcs-migrate/src/Database.php
Normal file
202
scripts/whmcs-migrate/src/Database.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
scripts/whmcs-migrate/src/Encryptor.php
Normal file
64
scripts/whmcs-migrate/src/Encryptor.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
318
scripts/whmcs-migrate/src/ExportManager.php
Normal file
318
scripts/whmcs-migrate/src/ExportManager.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
79
scripts/whmcs-migrate/src/Logger.php
Normal file
79
scripts/whmcs-migrate/src/Logger.php
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
277
scripts/whmcs-migrate/src/MigrationRunner.php
Normal file
277
scripts/whmcs-migrate/src/MigrationRunner.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
98
scripts/whmcs-migrate/src/Phases/AbstractPhase.php
Normal file
98
scripts/whmcs-migrate/src/Phases/AbstractPhase.php
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
554
scripts/whmcs-migrate/src/Phases/Phase1Clients.php
Normal file
554
scripts/whmcs-migrate/src/Phases/Phase1Clients.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
479
scripts/whmcs-migrate/src/Phases/Phase2Products.php
Normal file
479
scripts/whmcs-migrate/src/Phases/Phase2Products.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
494
scripts/whmcs-migrate/src/Phases/Phase3Services.php
Normal file
494
scripts/whmcs-migrate/src/Phases/Phase3Services.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
553
scripts/whmcs-migrate/src/Phases/Phase4Invoices.php
Normal file
553
scripts/whmcs-migrate/src/Phases/Phase4Invoices.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
438
scripts/whmcs-migrate/src/Phases/Phase5Transactions.php
Normal file
438
scripts/whmcs-migrate/src/Phases/Phase5Transactions.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
324
scripts/whmcs-migrate/src/Phases/Phase6Coupons.php
Normal file
324
scripts/whmcs-migrate/src/Phases/Phase6Coupons.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
464
scripts/whmcs-migrate/src/Phases/Phase7Orders.php
Normal file
464
scripts/whmcs-migrate/src/Phases/Phase7Orders.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
scripts/whmcs-migrate/src/Phases/PhaseInterface.php
Normal file
28
scripts/whmcs-migrate/src/Phases/PhaseInterface.php
Normal 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;
|
||||||
|
}
|
||||||
162
scripts/whmcs-migrate/src/ProgressTracker.php
Normal file
162
scripts/whmcs-migrate/src/ProgressTracker.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
scripts/whmcs-migrate/src/StateManager.php
Normal file
160
scripts/whmcs-migrate/src/StateManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
scripts/whmcs-migrate/src/StatusMapper.php
Normal file
163
scripts/whmcs-migrate/src/StatusMapper.php
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
182
scripts/whmcs-migrate/src/Validator.php
Normal file
182
scripts/whmcs-migrate/src/Validator.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
146
scripts/whmcs-migrate/src/WhmcsApi.php
Normal file
146
scripts/whmcs-migrate/src/WhmcsApi.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
website/app/Console/Commands/ProcessWinbackCampaigns.php
Normal file
94
website/app/Console/Commands/ProcessWinbackCampaigns.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
website/app/Http/Controllers/Account/SessionController.php
Normal file
31
website/app/Http/Controllers/Account/SessionController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
website/app/Http/Controllers/Admin/ConfigGroupController.php
Normal file
236
website/app/Http/Controllers/Admin/ConfigGroupController.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
|
|||||||
106
website/app/Http/Controllers/Admin/ReportController.php
Normal file
106
website/app/Http/Controllers/Admin/ReportController.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
website/app/Http/Controllers/Admin/TransactionController.php
Normal file
63
website/app/Http/Controllers/Admin/TransactionController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
website/app/Http/Controllers/Admin/WinbackCampaignController.php
Normal file
132
website/app/Http/Controllers/Admin/WinbackCampaignController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
42
website/app/Http/Requests/GenerateReportRequest.php
Normal file
42
website/app/Http/Requests/GenerateReportRequest.php
Normal 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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
73
website/app/Http/Requests/StoreConfigGroupRequest.php
Normal file
73
website/app/Http/Requests/StoreConfigGroupRequest.php
Normal 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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
];
|
];
|
||||||
|
|||||||
52
website/app/Http/Requests/StoreWinbackCampaignRequest.php
Normal file
52
website/app/Http/Requests/StoreWinbackCampaignRequest.php
Normal 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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
82
website/app/Listeners/HandleTwoFactorAuthenticated.php
Normal file
82
website/app/Listeners/HandleTwoFactorAuthenticated.php
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
99
website/app/Listeners/RecordFailedLogin.php
Normal file
99
website/app/Listeners/RecordFailedLogin.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
109
website/app/Listeners/RecordLoginHistory.php
Normal file
109
website/app/Listeners/RecordLoginHistory.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
42
website/app/Models/CancellationSurvey.php
Normal file
42
website/app/Models/CancellationSurvey.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
website/app/Models/LoginHistory.php
Normal file
94
website/app/Models/LoginHistory.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
website/app/Models/PlanConfigGroup.php
Normal file
64
website/app/Models/PlanConfigGroup.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
website/app/Models/PlanConfigOption.php
Normal file
117
website/app/Models/PlanConfigOption.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
website/app/Models/PlanConfigValue.php
Normal file
59
website/app/Models/PlanConfigValue.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
website/app/Models/SubscriptionConfigSelection.php
Normal file
49
website/app/Models/SubscriptionConfigSelection.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
52
website/app/Models/TrustedDevice.php
Normal file
52
website/app/Models/TrustedDevice.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
52
website/app/Models/WinbackCampaign.php
Normal file
52
website/app/Models/WinbackCampaign.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
website/app/Models/WinbackRecipient.php
Normal file
69
website/app/Models/WinbackRecipient.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
87
website/app/Notifications/NewDeviceLoginNotification.php
Normal file
87
website/app/Notifications/NewDeviceLoginNotification.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
website/app/Notifications/WinbackEmailNotification.php
Normal file
77
website/app/Notifications/WinbackEmailNotification.php
Normal 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 => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
36
website/app/Services/ConfigSelectionService.php
Normal file
36
website/app/Services/ConfigSelectionService.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
428
website/app/Services/Reports/FinancialReportService.php
Normal file
428
website/app/Services/Reports/FinancialReportService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
277
website/app/Services/Reports/ReportExportService.php
Normal file
277
website/app/Services/Reports/ReportExportService.php
Normal 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']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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
513
website/composer.lock
generated
@@ -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",
|
||||||
|
|||||||
51
website/database/factories/CancellationSurveyFactory.php
Normal file
51
website/database/factories/CancellationSurveyFactory.php
Normal 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']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
website/database/factories/LoginHistoryFactory.php
Normal file
49
website/database/factories/LoginHistoryFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
website/database/factories/PlanConfigGroupFactory.php
Normal file
44
website/database/factories/PlanConfigGroupFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
website/database/factories/PlanConfigOptionFactory.php
Normal file
81
website/database/factories/PlanConfigOptionFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
website/database/factories/PlanConfigValueFactory.php
Normal file
52
website/database/factories/PlanConfigValueFactory.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']),
|
||||||
|
|||||||
37
website/database/factories/TrustedDeviceFactory.php
Normal file
37
website/database/factories/TrustedDeviceFactory.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
website/database/factories/WinbackCampaignFactory.php
Normal file
90
website/database/factories/WinbackCampaignFactory.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
website/database/factories/WinbackRecipientFactory.php
Normal file
53
website/database/factories/WinbackRecipientFactory.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
Reference in New Issue
Block a user