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,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;
}
}