update email template
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class ClientData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $uuid,
|
||||
public readonly ?string $fullname,
|
||||
public readonly ?string $company,
|
||||
public readonly ?string $address,
|
||||
public readonly ?string $state,
|
||||
public readonly ?string $city,
|
||||
public readonly ?int $saler,
|
||||
public readonly ?string $country,
|
||||
public readonly ?string $phone,
|
||||
public readonly ?string $email,
|
||||
public readonly ?string $password,
|
||||
public readonly ?string $note,
|
||||
public readonly int $credit,
|
||||
public readonly int $status,
|
||||
public readonly int $emailMarketing,
|
||||
public readonly ?string $createdAt,
|
||||
public readonly ?string $updatedAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class CustomerDomainData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $legacyId,
|
||||
public readonly ?int $customerId,
|
||||
public readonly int $orderId,
|
||||
public readonly string $name,
|
||||
public readonly int $firstPayment,
|
||||
public readonly int $nextPayment,
|
||||
public readonly ?int $nextDueDate,
|
||||
public readonly ?int $nextInvoiceDate,
|
||||
public readonly ?int $registrarId,
|
||||
public readonly int $status,
|
||||
public readonly ?string $note,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class InvoiceData
|
||||
{
|
||||
/** @param InvoiceItemData[] $items */
|
||||
public function __construct(
|
||||
public readonly int $legacyId,
|
||||
public readonly string $invoiceNumber,
|
||||
public readonly ?int $customerId,
|
||||
public readonly ?int $orderId,
|
||||
public readonly ?string $date,
|
||||
public readonly int $subTotal,
|
||||
public readonly int $discountAmount,
|
||||
public readonly int $tax,
|
||||
public readonly int $total,
|
||||
public readonly int $status,
|
||||
public readonly ?string $paymentMethod,
|
||||
public readonly ?string $dueDate,
|
||||
public readonly ?string $paidAt,
|
||||
public readonly ?string $note,
|
||||
public readonly ?string $createdAt,
|
||||
public readonly array $items,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class InvoiceItemData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $legacyId,
|
||||
public readonly string $type,
|
||||
public readonly ?string $description,
|
||||
public readonly int $unitPrice,
|
||||
public readonly int $totalPrice,
|
||||
public readonly ?int $productId = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class OrderData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $legacyId,
|
||||
public readonly string $orderNum,
|
||||
public readonly ?int $customerId,
|
||||
public readonly ?string $date,
|
||||
public readonly int $total,
|
||||
public readonly int $status,
|
||||
public readonly ?string $paymentMethod,
|
||||
public readonly ?string $note,
|
||||
public readonly ?string $clientIp,
|
||||
public readonly ?string $createdAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class OrderItemData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $legacyId,
|
||||
public readonly ?int $orderId,
|
||||
public readonly ?int $productId,
|
||||
public readonly ?int $productPricingId,
|
||||
public readonly ?string $serverName,
|
||||
public readonly ?string $domainName,
|
||||
public readonly string $billingCycle,
|
||||
public readonly int $months,
|
||||
public readonly int $quantity,
|
||||
public readonly int $price,
|
||||
public readonly ?string $createdAt,
|
||||
public readonly ?string $updatedAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class ProductAddonData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $legacyId,
|
||||
public readonly string $code,
|
||||
public readonly string $name,
|
||||
public readonly string $type,
|
||||
public readonly int $price,
|
||||
public readonly bool $status,
|
||||
public readonly int $sortOrder,
|
||||
public readonly ?string $createdAt,
|
||||
public readonly ?string $updatedAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class ProductCategoryData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $legacyId,
|
||||
public readonly string $name,
|
||||
public readonly string $slug,
|
||||
public readonly ?string $type,
|
||||
public readonly bool $isActive,
|
||||
public readonly int $sortOrder,
|
||||
public readonly ?string $createdAt,
|
||||
public readonly ?string $updatedAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class ProductData
|
||||
{
|
||||
/** @param array<int, array{billing_cycle: string, price: float}> $pricings */
|
||||
public function __construct(
|
||||
public readonly int $legacyId,
|
||||
public readonly ?int $categoryId,
|
||||
public readonly string $name,
|
||||
public readonly string $slug,
|
||||
public readonly ?string $shortDescription,
|
||||
public readonly ?string $description,
|
||||
public readonly ?int $stock,
|
||||
public readonly bool $isUnlimited,
|
||||
public readonly float $setupFee,
|
||||
public readonly bool $isActive,
|
||||
public readonly int $sortOrder,
|
||||
public readonly ?string $createdAt,
|
||||
public readonly ?string $updatedAt,
|
||||
public readonly array $pricings,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\DTOs;
|
||||
|
||||
class ServiceData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $legacyId,
|
||||
public readonly ?int $customerId,
|
||||
public readonly ?int $productId,
|
||||
public readonly int $legacyProductId,
|
||||
public readonly ?int $orderItemId,
|
||||
public readonly ?string $serverName,
|
||||
public readonly string $status,
|
||||
public readonly bool $autoRenew,
|
||||
public readonly ?string $startedAt,
|
||||
public readonly ?string $expiresAt,
|
||||
public readonly ?string $cancelledAt,
|
||||
public readonly ?string $note,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Extractors;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
class AddonExtractor
|
||||
{
|
||||
public function extract(): LazyCollection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tbladdons')->orderBy('id')->cursor();
|
||||
}
|
||||
|
||||
/** tblpricing row for this addon, if any (type='addon'). */
|
||||
public function pricing(int $addonId): ?object
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblpricing')
|
||||
->where('type', 'addon')
|
||||
->where('relid', $addonId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Extractors;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
class ClientExtractor
|
||||
{
|
||||
public function extract(): LazyCollection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblclients')->orderBy('id')->cursor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Extractors;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
class CustomerDomainExtractor
|
||||
{
|
||||
public function extract(): LazyCollection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tbldomains')->orderBy('id')->cursor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Extractors;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
class InvoiceExtractor
|
||||
{
|
||||
public function extract(): LazyCollection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblinvoices')->orderBy('id')->cursor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Line items for a single invoice (tblinvoiceitems has no per-invoice cursor of its own,
|
||||
* items are pulled per-invoice while its job runs).
|
||||
*/
|
||||
public function items(int $invoiceId): \Illuminate\Support\Collection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblinvoiceitems')->where('invoiceid', $invoiceId)->get();
|
||||
}
|
||||
|
||||
public function orderIdFor(int $invoiceId): ?int
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblorders')->where('invoiceid', $invoiceId)->value('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Extractors;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
class OrderExtractor
|
||||
{
|
||||
public function extract(): LazyCollection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblorders')->orderBy('id')->cursor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Extractors;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
class OrderItemExtractor
|
||||
{
|
||||
/**
|
||||
* Each `tblhosting` row (a provisioned service instance) is the closest WHMCS equivalent
|
||||
* of an order line item — WHMCS orders have no real line-item table of their own.
|
||||
*/
|
||||
public function extract(): LazyCollection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblhosting')->orderBy('id')->cursor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Extractors;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
class ProductExtractor
|
||||
{
|
||||
public function extract(): LazyCollection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblproducts')->orderBy('id')->cursor();
|
||||
}
|
||||
|
||||
/** tblpricing row for this product, if any (type='product'). */
|
||||
public function pricing(int $productId): ?object
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblpricing')
|
||||
->where('type', 'product')
|
||||
->where('relid', $productId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Extractors;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
class ProductGroupExtractor
|
||||
{
|
||||
public function extract(): LazyCollection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblproductgroups')->orderBy('id')->cursor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Extractors;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
|
||||
class ServiceExtractor
|
||||
{
|
||||
public function extract(): LazyCollection
|
||||
{
|
||||
return DB::connection('whmcs')->table('tblhosting')->orderBy('id')->cursor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Resolves a WHMCS `tblclients.id` to the matching local `customers.id`,
|
||||
* bridged through the uuid both sides already share (see ClientWriter).
|
||||
*/
|
||||
class CustomerIdResolver
|
||||
{
|
||||
/** @var array<int, int>|null whmcs client id => local customer id */
|
||||
private static ?array $map = null;
|
||||
|
||||
public function resolve(int $whmcsClientId): ?int
|
||||
{
|
||||
self::$map ??= $this->build();
|
||||
|
||||
return self::$map[$whmcsClientId] ?? null;
|
||||
}
|
||||
|
||||
private function build(): array
|
||||
{
|
||||
$uuidsByWhmcsId = DB::connection('whmcs')->table('tblclients')->pluck('uuid', 'id');
|
||||
$customerIdsByUuid = DB::table('customers')->pluck('id', 'uuid');
|
||||
|
||||
$map = [];
|
||||
foreach ($uuidsByWhmcsId as $whmcsId => $uuid) {
|
||||
if (isset($customerIdsByUuid[$uuid])) {
|
||||
$map[$whmcsId] = $customerIdsByUuid[$uuid];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Ensures a WHMCS customer domain also exists in the DNS manager (dns_manager_domain) and is
|
||||
* linked to the customer (dns_domain_user) — that pivot is what the customer's "Domains" panel
|
||||
* in the admin actually reads (fetchCustomerDnsDomains), not module_domain.
|
||||
*/
|
||||
class DnsDomainLinker
|
||||
{
|
||||
private static ?int $canonicalServerId = null;
|
||||
|
||||
public function link(string $domainName, int $customerId, ?int $registrarId = null): void
|
||||
{
|
||||
$domainId = DB::table('dns_manager_domain')->whereRaw('LOWER(domain_name) = ?', [mb_strtolower($domainName)])->value('id');
|
||||
|
||||
if (! $domainId) {
|
||||
$serverId = $this->canonicalServerId();
|
||||
if (! $serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$domainId = DB::table('dns_manager_domain')->insertGetId([
|
||||
'domain_name' => $domainName,
|
||||
'dns_manager_server_id' => $serverId,
|
||||
// not a real provisioned DNS zone, just a billing placeholder created from WHMCS
|
||||
'is_created' => 0,
|
||||
'registrar_id' => $registrarId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} elseif ($registrarId !== null) {
|
||||
// don't clobber an existing registrar with an unresolved (null) one from a later re-sync
|
||||
DB::table('dns_manager_domain')->where('id', $domainId)->update(['registrar_id' => $registrarId]);
|
||||
}
|
||||
|
||||
DB::table('dns_domain_user')->updateOrInsert(
|
||||
['domain_id' => $domainId, 'user_id' => $customerId],
|
||||
['created_at' => now(), 'updated_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
private function canonicalServerId(): ?int
|
||||
{
|
||||
return self::$canonicalServerId ??= DB::table('dns_manager_servers')
|
||||
->orderByRaw('CASE WHEN id = 1 THEN 0 ELSE 1 END')
|
||||
->orderBy('id')
|
||||
->value('id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Resolves a WHMCS `tblorders.id` to the matching local `orders.id` via `orders.legacy_id`.
|
||||
* Orders must be synced (whmcs:sync-orders) before invoices for this to find anything.
|
||||
*/
|
||||
class OrderIdResolver
|
||||
{
|
||||
/** @var array<int, int>|null legacy order id => local order id */
|
||||
private static ?array $map = null;
|
||||
|
||||
public function resolve(int $whmcsOrderId): ?int
|
||||
{
|
||||
self::$map ??= DB::table('orders')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all();
|
||||
|
||||
return self::$map[$whmcsOrderId] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Resolves a WHMCS `tblhosting.id` to the local `order_items.id` via `legacy_id` (1:1, same source row). */
|
||||
class OrderItemIdResolver
|
||||
{
|
||||
/** @var array<int, int>|null */
|
||||
private static ?array $map = null;
|
||||
|
||||
public function resolve(int $legacyHostingId): ?int
|
||||
{
|
||||
self::$map ??= DB::table('order_items')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all();
|
||||
|
||||
return self::$map[$legacyHostingId] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Resolves a WHMCS `tblproductgroups.id` to the local `product_categories.id` via `legacy_id`. */
|
||||
class ProductCategoryIdResolver
|
||||
{
|
||||
/** @var array<int, int>|null */
|
||||
private static ?array $map = null;
|
||||
|
||||
public function resolve(int $legacyGroupId): ?int
|
||||
{
|
||||
self::$map ??= DB::table('product_categories')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all();
|
||||
|
||||
return self::$map[$legacyGroupId] ?? null;
|
||||
}
|
||||
|
||||
public function type(int $localCategoryId): ?string
|
||||
{
|
||||
return DB::table('product_categories')->where('id', $localCategoryId)->value('type');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Resolves a WHMCS `tblproducts.id` to the local `products.id` via `legacy_id`. */
|
||||
class ProductIdResolver
|
||||
{
|
||||
/** @var array<int, int>|null */
|
||||
private static ?array $map = null;
|
||||
|
||||
public function resolve(int $legacyProductId): ?int
|
||||
{
|
||||
self::$map ??= DB::table('products')->whereNotNull('legacy_id')->pluck('id', 'legacy_id')->all();
|
||||
|
||||
return self::$map[$legacyProductId] ?? null;
|
||||
}
|
||||
|
||||
public function categoryTypeOf(int $localProductId): ?string
|
||||
{
|
||||
return DB::table('products')
|
||||
->join('product_categories', 'product_categories.id', '=', 'products.product_categories_id')
|
||||
->where('products.id', $localProductId)
|
||||
->value('product_categories.type');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Resolves a WHMCS registrar module slug (e.g. 'nhanhoa', 'PA') to the local `registrars.id`, matched case-insensitively on short_name. */
|
||||
class RegistrarIdResolver
|
||||
{
|
||||
/** @var array<string, int>|null lowercase short_name => registrar id */
|
||||
private static ?array $map = null;
|
||||
|
||||
public function resolve(string $whmcsRegistrar): ?int
|
||||
{
|
||||
$whmcsRegistrar = trim($whmcsRegistrar);
|
||||
if ($whmcsRegistrar === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
self::$map ??= DB::table('registrars')->pluck('id', 'short_name')
|
||||
->mapWithKeys(fn ($id, $shortName) => [mb_strtolower($shortName) => $id])
|
||||
->all();
|
||||
|
||||
return self::$map[mb_strtolower($whmcsRegistrar)] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Slugifies a candidate and appends -N until it's unique in the given column, excluding the row's own legacy_id. */
|
||||
class SlugUniquifier
|
||||
{
|
||||
public function unique(string $table, string $column, string $candidate, int $legacyId): string
|
||||
{
|
||||
$base = Str::slug($candidate) ?: 'item';
|
||||
$slug = $base;
|
||||
$suffix = 1;
|
||||
|
||||
while (DB::table($table)->where($column, $slug)->where('legacy_id', '!=', $legacyId)->exists()) {
|
||||
$slug = "{$base}-" . ++$suffix;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ClientData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClientTransformer
|
||||
{
|
||||
/** @var array<string, int>|null fullname => user id, cached for the process lifetime */
|
||||
private static ?array $salerIdsByName = null;
|
||||
|
||||
/**
|
||||
* Map a raw `tblclients` row (array or stdClass) to a ClientData DTO.
|
||||
*/
|
||||
public function transform(array|object $row): ClientData
|
||||
{
|
||||
$row = (array) $row;
|
||||
|
||||
$fullname = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
|
||||
if ($fullname === '') {
|
||||
$fullname = $row['companyname'] ?: null;
|
||||
}
|
||||
|
||||
return new ClientData(
|
||||
uuid: $row['uuid'],
|
||||
fullname: $fullname,
|
||||
company: $row['companyname'] ?: null,
|
||||
address: trim(($row['address1'] ?? '') . ', ' . ($row['address2'] ?? ''), ', ') ?: null,
|
||||
state: $row['state'] ?: null,
|
||||
city: $row['city'] ?: null,
|
||||
// ponytail: WHMCS repurposes the `postcode` column to store the salesperson's fullname
|
||||
saler: $this->resolveSalerId($row['postcode'] ?? null),
|
||||
country: $row['country'] ?: null,
|
||||
phone: $this->formatPhone($row['phonenumber'] ?? null),
|
||||
email: $row['email'] ? strtolower($row['email']) : null,
|
||||
// ponytail: WHMCS hashes are not Laravel bcrypt, copied as-is; customer must reset password to log into portal
|
||||
password: $row['password'] ?: null,
|
||||
note: $row['notes'] ?: null,
|
||||
credit: (int) round((float) $row['credit']),
|
||||
status: $row['status'] === 'Active' ? 1 : 0,
|
||||
emailMarketing: ((int) ($row['emailoptout'] ?? 0)) === 1 ? 0 : 1,
|
||||
createdAt: $row['created_at'] ?: $row['datecreated'] ?: null,
|
||||
updatedAt: $row['updated_at'] ?: $row['datecreated'] ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveSalerId(?string $fullname): ?int
|
||||
{
|
||||
if (! $fullname) {
|
||||
return null;
|
||||
}
|
||||
|
||||
self::$salerIdsByName ??= DB::table('user')->pluck('id', 'fullname')->all();
|
||||
|
||||
return self::$salerIdsByName[$fullname] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "+84.93 613 68 14" -> "0936136814"; other country codes keep their digits as-is (e.g. "+62..." -> "62...").
|
||||
*/
|
||||
private function formatPhone(?string $raw): ?string
|
||||
{
|
||||
if (! $raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $raw);
|
||||
|
||||
if (str_starts_with($digits, '84')) {
|
||||
$digits = '0' . substr($digits, 2);
|
||||
}
|
||||
|
||||
return $digits ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\CustomerDomainData;
|
||||
use App\Integrations\Whmcs\Support\CustomerIdResolver;
|
||||
use App\Integrations\Whmcs\Support\OrderIdResolver;
|
||||
use App\Integrations\Whmcs\Support\RegistrarIdResolver;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CustomerDomainTransformer
|
||||
{
|
||||
public function __construct(
|
||||
private CustomerIdResolver $customers,
|
||||
private OrderIdResolver $orders,
|
||||
private RegistrarIdResolver $registrars,
|
||||
) {
|
||||
}
|
||||
|
||||
public function transform(array|object $row): CustomerDomainData
|
||||
{
|
||||
$row = (array) $row;
|
||||
$name = trim($row['domain']);
|
||||
|
||||
$notes = [];
|
||||
$notes[] = "WHMCS status: {$row['status']}";
|
||||
|
||||
$liveInDns = DB::table('dns_manager_domain')->whereRaw('LOWER(domain_name) = ?', [mb_strtolower($name)])->exists();
|
||||
$status = $liveInDns ? 1 : 2;
|
||||
if (! $liveInDns) {
|
||||
$notes[] = 'Không tìm thấy domain này trong DNS manager (dns_manager_domain).';
|
||||
}
|
||||
|
||||
if ($row['registrar'] && ! $this->registrars->resolve($row['registrar'])) {
|
||||
$notes[] = "Registrar WHMCS chưa map: {$row['registrar']}";
|
||||
}
|
||||
|
||||
if ($row['additionalnotes']) {
|
||||
$notes[] = $row['additionalnotes'];
|
||||
}
|
||||
|
||||
return new CustomerDomainData(
|
||||
legacyId: (int) $row['id'],
|
||||
customerId: $this->customers->resolve((int) $row['userid']),
|
||||
orderId: $this->orders->resolve((int) $row['orderid']) ?? 0,
|
||||
name: $name,
|
||||
firstPayment: (int) round((float) $row['firstpaymentamount']),
|
||||
nextPayment: (int) round((float) $row['recurringamount']),
|
||||
nextDueDate: $this->toTimestamp($row['nextduedate'] ?? null),
|
||||
nextInvoiceDate: $this->toTimestamp($row['nextinvoicedate'] ?? null),
|
||||
registrarId: $row['registrar'] ? $this->registrars->resolve($row['registrar']) : null,
|
||||
status: $status,
|
||||
note: implode(' | ', $notes),
|
||||
);
|
||||
}
|
||||
|
||||
private function toTimestamp(?string $date): ?int
|
||||
{
|
||||
if (! $date || str_starts_with($date, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtotime($date) ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\InvoiceItemData;
|
||||
use App\Integrations\Whmcs\Support\ProductIdResolver;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InvoiceItemTransformer
|
||||
{
|
||||
public function __construct(private ProductIdResolver $products)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null for WHMCS tax/manual lines (blank `type`) — those are already
|
||||
* reflected in invoices.tax on the header, keeping them would double-count.
|
||||
* Also returns null for discount lines (negative amount) — invoice_items.unit_price
|
||||
* is unsigned; those are rolled into invoices.discount_amount instead, see InvoiceTransformer.
|
||||
*/
|
||||
public function transform(array|object $row): ?InvoiceItemData
|
||||
{
|
||||
$row = (array) $row;
|
||||
|
||||
$whmcsType = trim((string) ($row['type'] ?? ''));
|
||||
if ($whmcsType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$amount = (int) round((float) $row['amount']);
|
||||
if ($amount < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new InvoiceItemData(
|
||||
legacyId: (int) $row['id'],
|
||||
type: $this->mapType($whmcsType),
|
||||
description: $row['description'] ? substr($row['description'], 0, 500) : null,
|
||||
unitPrice: $amount,
|
||||
totalPrice: $amount,
|
||||
productId: $this->resolveProductId($whmcsType, (int) $row['relid']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only 'Hosting' line items carry a resolvable product: relid -> tblhosting.id -> packageid -> products.legacy_id.
|
||||
* Domain/Addon relids point at tbldomains/tblhostingaddons, which don't map onto the products table.
|
||||
*/
|
||||
private function resolveProductId(string $whmcsType, int $relid): ?int
|
||||
{
|
||||
if ($whmcsType !== 'Hosting' || $relid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$packageId = DB::connection('whmcs')->table('tblhosting')->where('id', $relid)->value('packageid');
|
||||
|
||||
return $packageId ? $this->products->resolve((int) $packageId) : null;
|
||||
}
|
||||
|
||||
private function mapType(string $whmcsType): string
|
||||
{
|
||||
if ($whmcsType === 'Addon') {
|
||||
return 'addon';
|
||||
}
|
||||
|
||||
if (str_contains($whmcsType, 'Upgrade')) {
|
||||
return 'upgrade';
|
||||
}
|
||||
|
||||
return 'service';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\InvoiceData;
|
||||
use App\Integrations\Whmcs\Extractors\InvoiceExtractor;
|
||||
use App\Integrations\Whmcs\Support\CustomerIdResolver;
|
||||
use App\Integrations\Whmcs\Support\OrderIdResolver;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class InvoiceTransformer
|
||||
{
|
||||
public function __construct(
|
||||
private CustomerIdResolver $customers,
|
||||
private OrderIdResolver $orders,
|
||||
private InvoiceExtractor $extractor,
|
||||
private InvoiceItemTransformer $itemTransformer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function transform(array|object $row): InvoiceData
|
||||
{
|
||||
$row = (array) $row;
|
||||
$legacyId = (int) $row['id'];
|
||||
|
||||
$dueDate = $this->nullableDate($row['duedate'] ?? null);
|
||||
$whmcsOrderId = $this->extractor->orderIdFor($legacyId);
|
||||
|
||||
$rawItems = $this->extractor->items($legacyId);
|
||||
|
||||
// WHMCS records discounts as negative-amount line items; invoice_items.unit_price
|
||||
// is unsigned, so fold them into the header's discount_amount instead.
|
||||
$discountAmount = (int) round(-$rawItems->sum(fn ($item) => min(0, (float) $item->amount)));
|
||||
|
||||
$items = $rawItems
|
||||
->map(fn ($item) => $this->itemTransformer->transform($item))
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
return new InvoiceData(
|
||||
legacyId: $legacyId,
|
||||
invoiceNumber: $row['invoicenum'] ?: "WHMCS-{$legacyId}",
|
||||
customerId: $this->customers->resolve((int) $row['userid']),
|
||||
orderId: $whmcsOrderId ? $this->orders->resolve($whmcsOrderId) : null,
|
||||
date: $this->nullableDate($row['date'] ?? null),
|
||||
subTotal: (int) round((float) $row['subtotal']),
|
||||
discountAmount: $discountAmount,
|
||||
tax: (int) round((float) $row['tax'] + (float) $row['tax2']),
|
||||
total: (int) round((float) $row['total']),
|
||||
status: $this->mapStatus($row['status'], $dueDate),
|
||||
paymentMethod: $row['paymentmethod'] ?: null,
|
||||
dueDate: $dueDate,
|
||||
paidAt: $this->nullableDate($row['datepaid'] ?? null),
|
||||
note: $row['notes'] ?: null,
|
||||
createdAt: $this->nullableDate($row['created_at'] ?? null),
|
||||
items: $items,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* invoices.status: 1=unpaid, 2=paid, 3=cancelled, 4=overdue, 5=refunded.
|
||||
*/
|
||||
private function mapStatus(string $status, ?string $dueDate): int
|
||||
{
|
||||
return match ($status) {
|
||||
'Paid' => 2,
|
||||
'Cancelled' => 3,
|
||||
'Refunded' => 5,
|
||||
'Collections' => 4,
|
||||
default => ($dueDate && Carbon::parse($dueDate)->isPast()) ? 4 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WHMCS has zero-dates ('0000-00-00...') and, in this data, at least one bogus
|
||||
* year (e.g. '0004-04-22') — both invalid for MySQL strict mode, so nullify them.
|
||||
*/
|
||||
private function nullableDate(?string $value): ?string
|
||||
{
|
||||
if (! $value || (int) substr($value, 0, 4) < 1970) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\OrderItemData;
|
||||
use App\Integrations\Whmcs\Support\OrderIdResolver;
|
||||
use App\Integrations\Whmcs\Support\ProductIdResolver;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class OrderItemTransformer
|
||||
{
|
||||
/** WHMCS tblhosting.billingcycle label => [billing_cycle, months] */
|
||||
private const CYCLES = [
|
||||
'Monthly' => ['monthly', 1],
|
||||
'Quarterly' => ['quarterly', 3],
|
||||
'Semi-Annually' => ['semi_annually', 6],
|
||||
'Annually' => ['annually', 12],
|
||||
'Biennially' => ['biennially', 24],
|
||||
'Triennially' => ['triennially', 36],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private OrderIdResolver $orders,
|
||||
private ProductIdResolver $products,
|
||||
) {
|
||||
}
|
||||
|
||||
public function transform(array|object $row): OrderItemData
|
||||
{
|
||||
$row = (array) $row;
|
||||
[$billingCycle, $months] = self::CYCLES[$row['billingcycle']] ?? ['one_time', 0];
|
||||
|
||||
$productId = $this->products->resolve((int) $row['packageid']);
|
||||
|
||||
return new OrderItemData(
|
||||
legacyId: (int) $row['id'],
|
||||
orderId: $this->orders->resolve((int) $row['orderid']),
|
||||
productId: $productId,
|
||||
productPricingId: $productId ? $this->productPricingId($productId, $billingCycle) : null,
|
||||
serverName: $row['domain'] ?: null,
|
||||
domainName: $row['domain'] ?: null,
|
||||
billingCycle: $billingCycle,
|
||||
months: $months,
|
||||
quantity: (int) ($row['qty'] ?: 1),
|
||||
price: $this->price($row),
|
||||
createdAt: $this->nullableDate($row['created_at'] ?? null),
|
||||
updatedAt: $this->nullableDate($row['updated_at'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
/** One-time services carry their price in firstpaymentamount, not the (0.00) recurring amount. */
|
||||
private function price(array $row): int
|
||||
{
|
||||
$amount = (float) $row['amount'];
|
||||
|
||||
return (int) round($amount > 0 ? $amount : (float) $row['firstpaymentamount']);
|
||||
}
|
||||
|
||||
private function productPricingId(int $productId, string $billingCycle): ?int
|
||||
{
|
||||
return DB::table('product_pricings')
|
||||
->where('product_id', $productId)
|
||||
->where('billing_cycle', $billingCycle)
|
||||
->value('id');
|
||||
}
|
||||
|
||||
private function nullableDate(?string $value): ?string
|
||||
{
|
||||
if (! $value || (int) substr($value, 0, 4) < 1970) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\OrderData;
|
||||
use App\Integrations\Whmcs\Support\CustomerIdResolver;
|
||||
|
||||
class OrderTransformer
|
||||
{
|
||||
public function __construct(private CustomerIdResolver $customers)
|
||||
{
|
||||
}
|
||||
|
||||
public function transform(array|object $row): OrderData
|
||||
{
|
||||
$row = (array) $row;
|
||||
|
||||
return new OrderData(
|
||||
legacyId: (int) $row['id'],
|
||||
orderNum: (string) $row['ordernum'],
|
||||
customerId: $this->customers->resolve((int) $row['userid']),
|
||||
date: $this->nullableDate($row['date'] ?? null),
|
||||
total: (int) round((float) $row['amount']),
|
||||
status: $this->mapStatus($row['status']),
|
||||
paymentMethod: $row['paymentmethod'] ?: null,
|
||||
note: $row['notes'] ?: null,
|
||||
clientIp: $row['ipaddress'] ?: null,
|
||||
createdAt: $this->nullableDate($row['date'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* orders.status: 0=pending, 1=completed, 2=cancelled, 3=processing.
|
||||
*/
|
||||
private function mapStatus(string $status): int
|
||||
{
|
||||
return match ($status) {
|
||||
'Active', 'Completed' => 1,
|
||||
'Cancelled', 'Fraud', 'Failed' => 2,
|
||||
'Pending Activation', 'In Progress' => 3,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WHMCS has zero-dates ('0000-00-00...') and occasional bogus years, both
|
||||
* invalid for MySQL strict mode, so nullify anything before 1970.
|
||||
*/
|
||||
private function nullableDate(?string $value): ?string
|
||||
{
|
||||
if (! $value || (int) substr($value, 0, 4) < 1970) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ProductAddonData;
|
||||
use App\Integrations\Whmcs\Extractors\AddonExtractor;
|
||||
use App\Integrations\Whmcs\Support\ProductIdResolver;
|
||||
use App\Integrations\Whmcs\Support\SlugUniquifier;
|
||||
|
||||
class ProductAddonTransformer
|
||||
{
|
||||
public function __construct(
|
||||
private ProductIdResolver $products,
|
||||
private AddonExtractor $extractor,
|
||||
private SlugUniquifier $slugs,
|
||||
) {
|
||||
}
|
||||
|
||||
public function transform(array|object $row): ProductAddonData
|
||||
{
|
||||
$row = (array) $row;
|
||||
$legacyId = (int) $row['id'];
|
||||
$name = $row['name'] ?: "WHMCS Addon {$legacyId}";
|
||||
|
||||
$pricingRow = $this->extractor->pricing($legacyId);
|
||||
$price = $this->firstPrice($pricingRow);
|
||||
|
||||
return new ProductAddonData(
|
||||
legacyId: $legacyId,
|
||||
code: $this->slugs->unique('product_addon', 'code', $name, $legacyId),
|
||||
name: $name,
|
||||
// ponytail: WHMCS addons apply to a csv list of products across categories, not one type;
|
||||
// best-effort guess from the first linked product's category, default 'vps' (column default)
|
||||
type: $this->guessType($row['packages'] ?? ''),
|
||||
price: $price,
|
||||
status: ! $row['hidden'] && ! $row['retired'],
|
||||
sortOrder: (int) ($row['weight'] ?? 0),
|
||||
createdAt: $this->nullableDate($row['created_at'] ?? null),
|
||||
updatedAt: $this->nullableDate($row['updated_at'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
private function firstPrice(?object $pricingRow): int
|
||||
{
|
||||
if (! $pricingRow) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (['monthly', 'quarterly', 'semiannually', 'annually', 'biennially', 'triennially'] as $column) {
|
||||
$price = (float) $pricingRow->{$column};
|
||||
if ($price >= 0) {
|
||||
return (int) round($price);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function guessType(string $packagesCsv): string
|
||||
{
|
||||
$firstLegacyProductId = (int) strtok(trim($packagesCsv, ','), ',');
|
||||
|
||||
if ($firstLegacyProductId > 0) {
|
||||
$localProductId = $this->products->resolve($firstLegacyProductId);
|
||||
if ($localProductId) {
|
||||
$type = $this->products->categoryTypeOf($localProductId);
|
||||
if ($type) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'vps';
|
||||
}
|
||||
|
||||
private function nullableDate(?string $value): ?string
|
||||
{
|
||||
if (! $value || (int) substr($value, 0, 4) < 1970) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ProductCategoryData;
|
||||
use App\Integrations\Whmcs\Support\SlugUniquifier;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductCategoryTransformer
|
||||
{
|
||||
public function __construct(private SlugUniquifier $slugs)
|
||||
{
|
||||
}
|
||||
|
||||
public function transform(array|object $row): ProductCategoryData
|
||||
{
|
||||
$row = (array) $row;
|
||||
$legacyId = (int) $row['id'];
|
||||
$name = $row['name'] ?: "WHMCS Group {$legacyId}";
|
||||
|
||||
return new ProductCategoryData(
|
||||
legacyId: $legacyId,
|
||||
name: $name,
|
||||
slug: $this->slugs->unique('product_categories', 'slug', $row['slug'] ?: $name, $legacyId),
|
||||
type: $this->guessType($name),
|
||||
isActive: ! $row['hidden'],
|
||||
sortOrder: (int) $row['order'],
|
||||
createdAt: $this->nullableDate($row['created_at'] ?? null),
|
||||
updatedAt: $this->nullableDate($row['updated_at'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHMCS product groups have no category "type" of their own — this is a best-effort
|
||||
* keyword guess against the enum product_categories.type uses. Verify/adjust manually.
|
||||
*/
|
||||
private function guessType(string $name): ?string
|
||||
{
|
||||
$name = Str::upper($name);
|
||||
|
||||
return match (true) {
|
||||
str_contains($name, 'DOMAIN'), str_contains($name, 'TÊN MIỀN') => 'domain',
|
||||
str_contains($name, 'SSL') => 'ssl',
|
||||
str_contains($name, 'EMAIL') => 'email',
|
||||
str_contains($name, 'THIẾT KẾ'), str_contains($name, 'WEB DESIGN') => 'web_design',
|
||||
str_contains($name, 'HARDWARE') => 'hardware',
|
||||
str_contains($name, 'IDC'), str_contains($name, 'DEDICATED'), str_contains($name, 'CHỖ ĐẶT'), str_contains($name, 'COLO') => 'server_rental',
|
||||
str_contains($name, 'HOSTING') => 'hosting',
|
||||
str_contains($name, 'VPS') => 'vps',
|
||||
str_contains($name, 'CLOUD') => 'cloud',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function nullableDate(?string $value): ?string
|
||||
{
|
||||
if (! $value || (int) substr($value, 0, 4) < 1970) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ProductData;
|
||||
use App\Integrations\Whmcs\Extractors\ProductExtractor;
|
||||
use App\Integrations\Whmcs\Support\ProductCategoryIdResolver;
|
||||
use App\Integrations\Whmcs\Support\SlugUniquifier;
|
||||
|
||||
class ProductTransformer
|
||||
{
|
||||
/** WHMCS tblpricing column => product_pricings.billing_cycle */
|
||||
private const CYCLES = [
|
||||
'monthly' => 'monthly',
|
||||
'quarterly' => 'quarterly',
|
||||
'semiannually' => 'semi_annually',
|
||||
'annually' => 'annually',
|
||||
'biennially' => 'biennially',
|
||||
'triennially' => 'triennially',
|
||||
];
|
||||
|
||||
/** matching tblpricing setup-fee column for each cycle above */
|
||||
private const SETUP_FEE_COLUMNS = [
|
||||
'monthly' => 'msetupfee',
|
||||
'quarterly' => 'qsetupfee',
|
||||
'semiannually' => 'ssetupfee',
|
||||
'annually' => 'asetupfee',
|
||||
'biennially' => 'bsetupfee',
|
||||
'triennially' => 'tsetupfee',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private ProductCategoryIdResolver $categories,
|
||||
private ProductExtractor $extractor,
|
||||
private SlugUniquifier $slugs,
|
||||
) {
|
||||
}
|
||||
|
||||
public function transform(array|object $row): ProductData
|
||||
{
|
||||
$row = (array) $row;
|
||||
$legacyId = (int) $row['id'];
|
||||
$name = $row['name'] ?: "WHMCS Product {$legacyId}";
|
||||
|
||||
$pricingRow = $this->extractor->pricing($legacyId);
|
||||
[$pricings, $setupFee] = $this->buildPricings($pricingRow);
|
||||
|
||||
return new ProductData(
|
||||
legacyId: $legacyId,
|
||||
categoryId: $this->categories->resolve((int) $row['gid']),
|
||||
name: $name,
|
||||
slug: $this->slugs->unique('products', 'slug', $row['slug'] ?: $name, $legacyId),
|
||||
shortDescription: $row['short_description'] ?: null,
|
||||
description: $row['description'] ?: null,
|
||||
stock: $row['stockcontrol'] ? (int) $row['qty'] : null,
|
||||
isUnlimited: ! $row['stockcontrol'],
|
||||
setupFee: $setupFee,
|
||||
isActive: ! $row['hidden'],
|
||||
sortOrder: (int) $row['order'],
|
||||
createdAt: $this->nullableDate($row['created_at'] ?? null),
|
||||
updatedAt: $this->nullableDate($row['updated_at'] ?? null),
|
||||
pricings: $pricings,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0: array<int, array{billing_cycle: string, price: float}>, 1: float} */
|
||||
private function buildPricings(?object $pricingRow): array
|
||||
{
|
||||
if (! $pricingRow) {
|
||||
return [[], 0.0];
|
||||
}
|
||||
|
||||
$pricings = [];
|
||||
$setupFee = null;
|
||||
|
||||
foreach (self::CYCLES as $column => $billingCycle) {
|
||||
$price = (float) $pricingRow->{$column};
|
||||
if ($price < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pricings[] = ['billing_cycle' => $billingCycle, 'price' => $price];
|
||||
$setupFee ??= (float) $pricingRow->{self::SETUP_FEE_COLUMNS[$column]};
|
||||
}
|
||||
|
||||
return [$pricings, $setupFee ?? 0.0];
|
||||
}
|
||||
|
||||
private function nullableDate(?string $value): ?string
|
||||
{
|
||||
if (! $value || (int) substr($value, 0, 4) < 1970) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Transformers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ServiceData;
|
||||
use App\Integrations\Whmcs\Support\CustomerIdResolver;
|
||||
use App\Integrations\Whmcs\Support\OrderItemIdResolver;
|
||||
use App\Integrations\Whmcs\Support\ProductIdResolver;
|
||||
|
||||
class ServiceTransformer
|
||||
{
|
||||
public function __construct(
|
||||
private CustomerIdResolver $customers,
|
||||
private ProductIdResolver $products,
|
||||
private OrderItemIdResolver $orderItems,
|
||||
) {
|
||||
}
|
||||
|
||||
public function transform(array|object $row): ServiceData
|
||||
{
|
||||
$row = (array) $row;
|
||||
$legacyId = (int) $row['id'];
|
||||
|
||||
return new ServiceData(
|
||||
legacyId: $legacyId,
|
||||
customerId: $this->customers->resolve((int) $row['userid']),
|
||||
productId: $this->products->resolve((int) $row['packageid']),
|
||||
legacyProductId: (int) $row['packageid'],
|
||||
orderItemId: $this->orderItems->resolve($legacyId),
|
||||
serverName: $row['domain'] ?: null,
|
||||
status: $this->mapStatus($row['domainstatus']),
|
||||
autoRenew: $row['billingcycle'] !== 'One Time',
|
||||
startedAt: $this->nullableDate($row['regdate'] ?? null),
|
||||
expiresAt: $this->nullableDate($row['nextduedate'] ?? null),
|
||||
cancelledAt: $this->nullableDate($row['termination_date'] ?? null),
|
||||
note: $row['notes'] ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* services.status: pending, active, suspended, expired, cancelled, terminated.
|
||||
*/
|
||||
private function mapStatus(string $whmcsStatus): string
|
||||
{
|
||||
return match ($whmcsStatus) {
|
||||
'Active', 'Completed' => 'active',
|
||||
'Suspended' => 'suspended',
|
||||
'Terminated' => 'terminated',
|
||||
'Cancelled', 'Fraud' => 'cancelled',
|
||||
default => 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private function nullableDate(?string $value): ?string
|
||||
{
|
||||
if (! $value || (int) substr($value, 0, 4) < 1970) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ClientData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ClientWriter
|
||||
{
|
||||
/**
|
||||
* Upsert into `customers` keyed by `uuid` (WHMCS already assigns a uuid per client).
|
||||
*/
|
||||
public function write(ClientData $data): void
|
||||
{
|
||||
DB::table('customers')->updateOrInsert(
|
||||
['uuid' => $data->uuid],
|
||||
[
|
||||
'fullname' => $data->fullname,
|
||||
'company' => $data->company,
|
||||
'address' => $data->address,
|
||||
'state' => $data->state,
|
||||
'city' => $data->city,
|
||||
'saler' => $data->saler,
|
||||
'country' => $data->country,
|
||||
'phone' => $data->phone,
|
||||
'email' => $data->email,
|
||||
'password' => $data->password,
|
||||
'note' => $data->note,
|
||||
'credit' => $data->credit,
|
||||
'status' => $data->status,
|
||||
'email_marketing' => $data->emailMarketing,
|
||||
'created_at' => $data->createdAt,
|
||||
'updated_at' => $data->updatedAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\CustomerDomainData;
|
||||
use App\Integrations\Whmcs\Support\DnsDomainLinker;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CustomerDomainWriter
|
||||
{
|
||||
public function __construct(private DnsDomainLinker $dnsLinker)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips domains whose WHMCS client couldn't be resolved to a local customer.
|
||||
*/
|
||||
public function write(CustomerDomainData $data): bool
|
||||
{
|
||||
if ($data->customerId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::table('module_domain')->updateOrInsert(
|
||||
['legacy_id' => $data->legacyId],
|
||||
[
|
||||
'name' => $data->name,
|
||||
'order_id' => $data->orderId,
|
||||
'first_payment' => $data->firstPayment,
|
||||
'module_dns_id' => 0,
|
||||
'next_payment' => $data->nextPayment,
|
||||
'next_due_date' => $data->nextDueDate,
|
||||
'next_invoice_date' => $data->nextInvoiceDate,
|
||||
'customer_id' => $data->customerId,
|
||||
'domain_type_id' => 0,
|
||||
'registrar_id' => $data->registrarId,
|
||||
'note' => $data->note,
|
||||
'status' => $data->status,
|
||||
]
|
||||
);
|
||||
|
||||
$this->dnsLinker->link($data->name, $data->customerId, $data->registrarId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\InvoiceData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InvoiceWriter
|
||||
{
|
||||
/**
|
||||
* Skips invoices whose WHMCS client couldn't be resolved to a local customer —
|
||||
* invoices.customer_id is NOT NULL, so there is nothing valid to insert.
|
||||
*/
|
||||
public function write(InvoiceData $data): void
|
||||
{
|
||||
if ($data->customerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($data) {
|
||||
DB::table('invoices')->updateOrInsert(
|
||||
['legacy_id' => $data->legacyId],
|
||||
[
|
||||
'invoice_number' => $data->invoiceNumber,
|
||||
'date' => $data->date,
|
||||
'customer_id' => $data->customerId,
|
||||
'order_id' => $data->orderId,
|
||||
'sub_total' => $data->subTotal,
|
||||
'discount_amount' => $data->discountAmount,
|
||||
'tax' => $data->tax,
|
||||
'total' => $data->total,
|
||||
'status' => $data->status,
|
||||
'payment_method' => $data->paymentMethod,
|
||||
'due_date' => $data->dueDate,
|
||||
'paid_at' => $data->paidAt,
|
||||
'note' => $data->note,
|
||||
'created_at' => $data->createdAt,
|
||||
'updated_at' => $data->createdAt,
|
||||
]
|
||||
);
|
||||
|
||||
$invoiceId = DB::table('invoices')->where('legacy_id', $data->legacyId)->value('id');
|
||||
|
||||
foreach ($data->items as $item) {
|
||||
DB::table('invoice_items')->updateOrInsert(
|
||||
['legacy_id' => $item->legacyId],
|
||||
[
|
||||
'invoice_id' => $invoiceId,
|
||||
'product_id' => $item->productId,
|
||||
'type' => $item->type,
|
||||
'description' => $item->description,
|
||||
'quantity' => 1,
|
||||
'unit_price' => $item->unitPrice,
|
||||
'total_price' => $item->totalPrice,
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\OrderItemData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class OrderItemWriter
|
||||
{
|
||||
/**
|
||||
* Skips items whose order couldn't be resolved — order_items.order_id is NOT NULL.
|
||||
* Requires whmcs:sync-orders to have already run.
|
||||
*/
|
||||
public function write(OrderItemData $data): bool
|
||||
{
|
||||
if ($data->orderId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::table('order_items')->updateOrInsert(
|
||||
['legacy_id' => $data->legacyId],
|
||||
[
|
||||
'order_id' => $data->orderId,
|
||||
'product_id' => $data->productId,
|
||||
'product_pricing_id' => $data->productPricingId,
|
||||
'server_name' => $data->serverName,
|
||||
'domain_name' => $data->domainName,
|
||||
'billing_cycle' => $data->billingCycle,
|
||||
'months' => $data->months,
|
||||
'quantity' => $data->quantity,
|
||||
'price' => $data->price,
|
||||
'created_at' => $data->createdAt,
|
||||
'updated_at' => $data->updatedAt,
|
||||
]
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\OrderData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class OrderWriter
|
||||
{
|
||||
public function write(OrderData $data): void
|
||||
{
|
||||
DB::table('orders')->updateOrInsert(
|
||||
['legacy_id' => $data->legacyId],
|
||||
[
|
||||
'order_num' => $data->orderNum,
|
||||
'date' => $data->date,
|
||||
'customer_id' => $data->customerId,
|
||||
'sub_total' => $data->total,
|
||||
'total' => $data->total,
|
||||
'status' => $data->status,
|
||||
'payment_method' => $data->paymentMethod,
|
||||
'note' => $data->note,
|
||||
'client_ip' => $data->clientIp,
|
||||
'created_at' => $data->createdAt,
|
||||
'updated_at' => $data->createdAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ProductAddonData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProductAddonWriter
|
||||
{
|
||||
public function write(ProductAddonData $data): void
|
||||
{
|
||||
DB::table('product_addon')->updateOrInsert(
|
||||
['legacy_id' => $data->legacyId],
|
||||
[
|
||||
'code' => $data->code,
|
||||
'name' => $data->name,
|
||||
'type' => $data->type,
|
||||
'price' => $data->price,
|
||||
'status' => $data->status,
|
||||
'sort_order' => $data->sortOrder,
|
||||
'created_at' => $data->createdAt,
|
||||
'updated_at' => $data->updatedAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ProductCategoryData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProductCategoryWriter
|
||||
{
|
||||
public function write(ProductCategoryData $data): void
|
||||
{
|
||||
DB::table('product_categories')->updateOrInsert(
|
||||
['legacy_id' => $data->legacyId],
|
||||
[
|
||||
'name' => $data->name,
|
||||
'slug' => $data->slug,
|
||||
'type' => $data->type,
|
||||
'is_active' => $data->isActive,
|
||||
'sort_order' => $data->sortOrder,
|
||||
'created_at' => $data->createdAt,
|
||||
'updated_at' => $data->updatedAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ProductData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProductWriter
|
||||
{
|
||||
/**
|
||||
* Skips products whose category couldn't be resolved — products.product_categories_id is NOT NULL.
|
||||
*/
|
||||
public function write(ProductData $data): bool
|
||||
{
|
||||
if ($data->categoryId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($data) {
|
||||
$productType = DB::table('product_categories')->where('id', $data->categoryId)->value('type') ?? 'other';
|
||||
|
||||
DB::table('products')->updateOrInsert(
|
||||
['legacy_id' => $data->legacyId],
|
||||
[
|
||||
'uuid' => DB::table('products')->where('legacy_id', $data->legacyId)->value('uuid') ?? (string) Str::uuid(),
|
||||
'product_type' => $productType,
|
||||
'product_categories_id' => $data->categoryId,
|
||||
'name' => $data->name,
|
||||
'slug' => $data->slug,
|
||||
'short_description' => $data->shortDescription,
|
||||
'description' => $data->description,
|
||||
'stock' => $data->stock,
|
||||
'is_unlimited' => $data->isUnlimited,
|
||||
'setup_fee' => $data->setupFee,
|
||||
'is_active' => $data->isActive,
|
||||
'sort_order' => $data->sortOrder,
|
||||
'created_at' => $data->createdAt,
|
||||
'updated_at' => $data->updatedAt,
|
||||
]
|
||||
);
|
||||
|
||||
$productId = DB::table('products')->where('legacy_id', $data->legacyId)->value('id');
|
||||
|
||||
foreach ($data->pricings as $pricing) {
|
||||
DB::table('product_pricings')->updateOrInsert(
|
||||
['product_id' => $productId, 'billing_cycle' => $pricing['billing_cycle']],
|
||||
['price' => $pricing['price'], 'currency' => 'VND']
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Integrations\Whmcs\Writers;
|
||||
|
||||
use App\Integrations\Whmcs\DTOs\ServiceData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ServiceWriter
|
||||
{
|
||||
/**
|
||||
* Skips services whose WHMCS client couldn't be resolved to a local customer —
|
||||
* services.customer_id is NOT NULL.
|
||||
*/
|
||||
public function write(ServiceData $data): bool
|
||||
{
|
||||
if ($data->customerId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::table('services')->updateOrInsert(
|
||||
['legacy_id' => $data->legacyId],
|
||||
[
|
||||
'uuid' => DB::table('services')->where('legacy_id', $data->legacyId)->value('uuid') ?? (string) Str::uuid(),
|
||||
'customer_id' => $data->customerId,
|
||||
'product_id' => $data->productId,
|
||||
'legacy_product_id' => $data->legacyProductId,
|
||||
'order_item_id' => $data->orderItemId,
|
||||
'server_name' => $data->serverName,
|
||||
'status' => $data->status,
|
||||
'auto_renew' => $data->autoRenew,
|
||||
'started_at' => $data->startedAt,
|
||||
'expires_at' => $data->expiresAt,
|
||||
'cancelled_at' => $data->cancelledAt,
|
||||
'note' => $data->note,
|
||||
]
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user