update email template

This commit is contained in:
baodev0
2026-09-09 16:56:22 +07:00
parent d3d9f76ba0
commit 2365ef72e9
210 changed files with 6335 additions and 4688 deletions
@@ -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,
) {
}
}
+20
View File
@@ -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;
}
}