update email template
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user