Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Domain;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/** Optional stub — fetch user domains from the platform Domains API. */
|
||||
class DomainClient
|
||||
{
|
||||
private function base(): string
|
||||
{
|
||||
return rtrim((string) config('domain.api_url', env('DOMAIN_API_URL', '')), '/');
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return (string) config('domain.api_key', env('DOMAIN_API_KEY_HOSTING', ''));
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function listForUser(string $publicId): array
|
||||
{
|
||||
if ($this->base() === '' || $this->token() === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)
|
||||
->get($this->base().'/domains', ['user' => $publicId]);
|
||||
|
||||
if ($res->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (array) ($res->json('data') ?? $res->json() ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Domain;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainDkimKey;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DomainDkimService
|
||||
{
|
||||
public function ensureActive(Domain $domain): DomainDkimKey
|
||||
{
|
||||
$existing = DomainDkimKey::query()
|
||||
->where('domain_id', $domain->id)
|
||||
->where('status', 'active')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$selectorPrefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill');
|
||||
$selector = Str::lower(trim($selectorPrefix)) ?: 'ladill';
|
||||
$selector .= '1';
|
||||
|
||||
$publicKey = '';
|
||||
$privateKey = null;
|
||||
|
||||
if (function_exists('openssl_pkey_new')) {
|
||||
$resource = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
if ($resource !== false) {
|
||||
openssl_pkey_export($resource, $privateOut);
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
$publicPem = (string) ($details['key'] ?? '');
|
||||
$publicKey = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicPem) ?: '';
|
||||
$privateKey = $privateOut ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($publicKey === '') {
|
||||
$publicKey = Str::upper(Str::random(360));
|
||||
}
|
||||
|
||||
$privatePath = null;
|
||||
if (is_string($privateKey) && trim($privateKey) !== '') {
|
||||
$safeHost = Str::slug($domain->host, '_');
|
||||
$directory = storage_path('app/mail/dkim');
|
||||
File::ensureDirectoryExists($directory);
|
||||
$privatePath = $directory.'/'.$safeHost.'_'.$selector.'.key';
|
||||
File::put($privatePath, $privateKey);
|
||||
}
|
||||
|
||||
$key = DomainDkimKey::query()->create([
|
||||
'domain_id' => $domain->id,
|
||||
'selector' => $selector,
|
||||
'public_key_txt' => $publicKey,
|
||||
'private_key_path' => $privatePath,
|
||||
'status' => 'active',
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('Generated DKIM key for domain', ['domain_id' => $domain->id, 'selector' => $selector]);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
public function activeKey(Domain $domain): ?DomainDkimKey
|
||||
{
|
||||
return DomainDkimKey::query()
|
||||
->where('domain_id', $domain->id)
|
||||
->where('status', 'active')
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Domain;
|
||||
|
||||
use App\Models\Domain;
|
||||
|
||||
class DomainDnsBlueprintService
|
||||
{
|
||||
public function expectedNameservers(): array
|
||||
{
|
||||
$nameservers = (array) config('mailinfra.nameservers', ['ns1.ladill.com', 'ns2.ladill.com']);
|
||||
|
||||
return collect($nameservers)
|
||||
->map(fn ($value) => strtolower(trim((string) $value, ". \t\n\r\0\x0B")))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function mailSetupInstructions(): array
|
||||
{
|
||||
return [
|
||||
'imap' => [
|
||||
'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'),
|
||||
'port' => (int) config('mailinfra.imap_port', 993),
|
||||
'security' => 'SSL/TLS',
|
||||
'username' => 'full_email',
|
||||
],
|
||||
'smtp' => [
|
||||
'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'),
|
||||
'port' => (int) config('mailinfra.smtp_port', 587),
|
||||
'security' => 'STARTTLS',
|
||||
'authentication' => true,
|
||||
'username' => 'full_email',
|
||||
],
|
||||
'smtp_ssl' => [
|
||||
'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'),
|
||||
'port' => (int) config('mailinfra.smtp_ssl_port', 465),
|
||||
'security' => 'SSL/TLS',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function managedRecords(Domain $domain, string $selector, string $publicKey): array
|
||||
{
|
||||
$mailHost = strtolower(trim((string) config('mailinfra.mail_host', 'mail.ladill.com')));
|
||||
$autodiscoverHost = strtolower(trim((string) config('mailinfra.autodiscover_host', 'autodiscover.ladill.com')));
|
||||
$autoconfigHost = strtolower(trim((string) config('mailinfra.autoconfig_host', 'autoconfig.ladill.com')));
|
||||
$ttl = (int) config('mailinfra.default_ttl', 3600);
|
||||
$websiteIp = trim((string) config('mailinfra.website_a_record', ''));
|
||||
$wwwTarget = trim((string) config('mailinfra.website_www_cname', '@'));
|
||||
$rua = trim((string) config('mailinfra.dmarc_rua', 'mailto:dmarc@ladill.com'));
|
||||
|
||||
$records = [];
|
||||
|
||||
// Always include A record for apex domain (use configured IP or placeholder)
|
||||
$records[] = [
|
||||
'name' => '@',
|
||||
'type' => 'A',
|
||||
'value' => $websiteIp !== '' ? $websiteIp : '161.97.138.149',
|
||||
'ttl' => $ttl,
|
||||
'priority' => null,
|
||||
'source' => 'managed',
|
||||
'status' => 'active',
|
||||
'notes' => 'Website apex record',
|
||||
];
|
||||
|
||||
// Always include www record (CNAME to @ or A record to same IP)
|
||||
if ($wwwTarget !== '' && $wwwTarget !== '@') {
|
||||
$records[] = [
|
||||
'name' => 'www',
|
||||
'type' => 'CNAME',
|
||||
'value' => $wwwTarget,
|
||||
'ttl' => $ttl,
|
||||
'priority' => null,
|
||||
'source' => 'managed',
|
||||
'status' => 'active',
|
||||
'notes' => 'Website www record',
|
||||
];
|
||||
} else {
|
||||
// Default: www points to same IP as apex
|
||||
$records[] = [
|
||||
'name' => 'www',
|
||||
'type' => 'A',
|
||||
'value' => $websiteIp !== '' ? $websiteIp : '161.97.138.149',
|
||||
'ttl' => $ttl,
|
||||
'priority' => null,
|
||||
'source' => 'managed',
|
||||
'status' => 'active',
|
||||
'notes' => 'Website www record',
|
||||
];
|
||||
}
|
||||
|
||||
$records[] = [
|
||||
'name' => '@',
|
||||
'type' => 'MX',
|
||||
'value' => $mailHost.'.',
|
||||
'ttl' => $ttl,
|
||||
'priority' => 10,
|
||||
'source' => 'managed',
|
||||
'status' => 'active',
|
||||
'notes' => 'Primary mail exchanger',
|
||||
];
|
||||
|
||||
$records[] = [
|
||||
'name' => '@',
|
||||
'type' => 'TXT',
|
||||
'value' => (string) config('mailinfra.spf_customer_txt'),
|
||||
'ttl' => $ttl,
|
||||
'priority' => null,
|
||||
'source' => 'managed',
|
||||
'status' => 'active',
|
||||
'notes' => 'SPF policy',
|
||||
];
|
||||
|
||||
$records[] = [
|
||||
'name' => $selector.'._domainkey',
|
||||
'type' => 'TXT',
|
||||
'value' => sprintf('v=DKIM1; k=rsa; p=%s', $publicKey),
|
||||
'ttl' => $ttl,
|
||||
'priority' => null,
|
||||
'source' => 'managed',
|
||||
'status' => 'active',
|
||||
'notes' => 'DKIM public key',
|
||||
];
|
||||
|
||||
$records[] = [
|
||||
'name' => '_dmarc',
|
||||
'type' => 'TXT',
|
||||
'value' => sprintf('v=DMARC1; p=none; rua=%s; adkim=s; aspf=s', $rua),
|
||||
'ttl' => $ttl,
|
||||
'priority' => null,
|
||||
'source' => 'managed',
|
||||
'status' => 'active',
|
||||
'notes' => 'DMARC policy',
|
||||
];
|
||||
|
||||
$records[] = [
|
||||
'name' => 'autodiscover',
|
||||
'type' => 'CNAME',
|
||||
'value' => $autodiscoverHost.'.',
|
||||
'ttl' => $ttl,
|
||||
'priority' => null,
|
||||
'source' => 'managed',
|
||||
'status' => 'active',
|
||||
'notes' => 'Autodiscover helper',
|
||||
];
|
||||
|
||||
$records[] = [
|
||||
'name' => 'autoconfig',
|
||||
'type' => 'CNAME',
|
||||
'value' => $autoconfigHost.'.',
|
||||
'ttl' => $ttl,
|
||||
'priority' => null,
|
||||
'source' => 'managed',
|
||||
'status' => 'active',
|
||||
'notes' => 'Autoconfig helper',
|
||||
];
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
public function manualDnsPack(Domain $domain, string $selector, string $publicKey): array
|
||||
{
|
||||
return collect($this->managedRecords($domain, $selector, $publicKey))
|
||||
->map(function (array $record) {
|
||||
$record['source'] = 'required_manual';
|
||||
$record['status'] = 'pending';
|
||||
|
||||
return $record;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Domain;
|
||||
|
||||
use App\Models\DomainPricing;
|
||||
use App\Services\Currency\CurrencyConverterService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DomainPricingService
|
||||
{
|
||||
private const TLD_PRICING_CACHE_KEY = 'dynadot:tld_pricing';
|
||||
private const LIVE_TLD_PRICING_CACHE_KEY = 'dynadot:tld_pricing:live';
|
||||
private const TLD_PRICING_CACHE_TTL_HOURS = 6;
|
||||
|
||||
public function __construct(
|
||||
private DynadotService $dynadot,
|
||||
private CurrencyConverterService $currency,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the markup percentage from config.
|
||||
*/
|
||||
public function getMarkupPercent(): float
|
||||
{
|
||||
return (float) config('mailinfra.dynadot_price_markup_percent', 35);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing for a specific TLD in GHS with markup.
|
||||
*
|
||||
* @return array{register: int, renew: int, transfer: int, currency: string, usd_rate: float}|null
|
||||
*/
|
||||
public function getPricingForTld(string $tld): ?array
|
||||
{
|
||||
$tld = ltrim(strtolower(trim($tld)), '.');
|
||||
$allPricing = $this->getAllTldPricing();
|
||||
|
||||
if (! isset($allPricing[$tld])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $allPricing[$tld];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing for multiple TLDs.
|
||||
*
|
||||
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
|
||||
*/
|
||||
public function getPricingForTlds(array $tlds): array
|
||||
{
|
||||
$allPricing = $this->getAllTldPricing();
|
||||
$result = [];
|
||||
|
||||
foreach ($tlds as $tld) {
|
||||
$tld = ltrim(strtolower(trim($tld)), '.');
|
||||
if (isset($allPricing[$tld])) {
|
||||
$result[$tld] = $allPricing[$tld];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only TLDs that currently have live Dynadot pricing.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getLivePricedTlds(): array
|
||||
{
|
||||
return array_keys($this->getLiveDynadotPricing());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all TLD pricing with conversion and markup applied.
|
||||
* Price overrides from DomainPricing model take precedence.
|
||||
*
|
||||
* @return array<string, array{register: int, renew: int, transfer: int, currency: string, usd_rate: float, is_override: bool}>
|
||||
*/
|
||||
public function getAllTldPricing(): array
|
||||
{
|
||||
$cacheKey = self::TLD_PRICING_CACHE_KEY . ':ghs';
|
||||
|
||||
return Cache::remember($cacheKey, now()->addHours(self::TLD_PRICING_CACHE_TTL_HOURS), function () {
|
||||
$usdPricing = $this->fetchDynadotPricing();
|
||||
$overrides = DomainPricing::getOverrides();
|
||||
|
||||
if (empty($usdPricing) && empty($overrides)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$markup = $this->getMarkupPercent();
|
||||
$usdRate = $this->currency->getUsdToGhsRate();
|
||||
$converted = [];
|
||||
|
||||
// First, add all Dynadot prices with conversion
|
||||
foreach ($usdPricing as $tld => $prices) {
|
||||
$converted[$tld] = [
|
||||
'register' => $this->convertPrice($prices['register'] ?? 0, $markup),
|
||||
'renew' => $this->convertPrice($prices['renew'] ?? 0, $markup),
|
||||
'transfer' => $this->convertPrice($prices['transfer'] ?? 0, $markup),
|
||||
'currency' => 'GHS',
|
||||
'usd_rate' => $usdRate,
|
||||
'usd_register' => $prices['register'] ?? 0,
|
||||
'usd_renew' => $prices['renew'] ?? 0,
|
||||
'usd_transfer' => $prices['transfer'] ?? 0,
|
||||
'is_override' => false,
|
||||
];
|
||||
}
|
||||
|
||||
// Apply overrides (manual GHS prices take precedence)
|
||||
foreach ($overrides as $tld => $override) {
|
||||
$converted[$tld] = [
|
||||
'register' => $override['register'],
|
||||
'renew' => $override['renew'],
|
||||
'transfer' => $override['transfer'],
|
||||
'currency' => 'GHS',
|
||||
'usd_rate' => $usdRate,
|
||||
'usd_register' => null,
|
||||
'usd_renew' => null,
|
||||
'usd_transfer' => null,
|
||||
'is_override' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $converted;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert USD cents to GHS pesewas with markup.
|
||||
*/
|
||||
private function convertPrice(int $usdCents, float $markupPercent): int
|
||||
{
|
||||
return $this->currency->convertCentsTopesewaswithMarkup($usdCents, $markupPercent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch TLD pricing from Dynadot API.
|
||||
*
|
||||
* @return array<string, array{register: int, renew: int, transfer: int}>
|
||||
*/
|
||||
private function fetchDynadotPricing(): array
|
||||
{
|
||||
$livePricing = $this->getLiveDynadotPricing();
|
||||
|
||||
if ($livePricing !== []) {
|
||||
return $livePricing;
|
||||
}
|
||||
|
||||
return $this->getFallbackPricing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback pricing in USD cents for common TLDs.
|
||||
*/
|
||||
private function getFallbackPricing(): array
|
||||
{
|
||||
return [
|
||||
'com' => ['register' => 1099, 'renew' => 1099, 'transfer' => 1099],
|
||||
'net' => ['register' => 1199, 'renew' => 1199, 'transfer' => 1199],
|
||||
'org' => ['register' => 1099, 'renew' => 1099, 'transfer' => 1099],
|
||||
'io' => ['register' => 3999, 'renew' => 3999, 'transfer' => 3999],
|
||||
'co' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999],
|
||||
'info' => ['register' => 1899, 'renew' => 1899, 'transfer' => 1899],
|
||||
'biz' => ['register' => 1699, 'renew' => 1699, 'transfer' => 1699],
|
||||
'xyz' => ['register' => 1299, 'renew' => 1299, 'transfer' => 1299],
|
||||
'online' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999],
|
||||
'store' => ['register' => 4999, 'renew' => 4999, 'transfer' => 4999],
|
||||
'tech' => ['register' => 4999, 'renew' => 4999, 'transfer' => 4999],
|
||||
'site' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw live TLD pricing from Dynadot without fallback expansion.
|
||||
*
|
||||
* @return array<string, array{register: int, renew: int, transfer: int}>
|
||||
*/
|
||||
private function getLiveDynadotPricing(): array
|
||||
{
|
||||
return Cache::remember(self::LIVE_TLD_PRICING_CACHE_KEY, now()->addHours(self::TLD_PRICING_CACHE_TTL_HOURS), function () {
|
||||
if (! $this->dynadot->isConfigured()) {
|
||||
Log::warning('DomainPricingService: Dynadot not configured');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->dynadot->getTldPricing();
|
||||
|
||||
if (! ($result['success'] ?? false) || empty($result['pricing'])) {
|
||||
Log::warning('DomainPricingService: Failed to fetch Dynadot pricing', $result);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result['pricing'];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('DomainPricingService: Exception fetching pricing', ['error' => $e->getMessage()]);
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price for display.
|
||||
*/
|
||||
public function formatPrice(int $pesewas): string
|
||||
{
|
||||
return 'GH₵ ' . number_format($pesewas / 100, 2);
|
||||
}
|
||||
|
||||
public function convertRegistrarAmountToGhsMinor(int $amountMinor, string $currency): ?int
|
||||
{
|
||||
$currency = strtoupper(trim($currency));
|
||||
|
||||
if ($currency === 'GHS') {
|
||||
return $amountMinor;
|
||||
}
|
||||
|
||||
if ($currency === 'USD') {
|
||||
return $this->convertPrice($amountMinor, $this->getMarkupPercent());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get price info for a domain (for checkout/cart).
|
||||
*/
|
||||
public function getDomainPrice(string $domain, string $action = 'register'): ?array
|
||||
{
|
||||
$parts = explode('.', strtolower($domain), 2);
|
||||
|
||||
if (count($parts) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tld = $parts[1];
|
||||
$pricing = $this->getPricingForTld($tld);
|
||||
|
||||
if (! $pricing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$priceKey = match ($action) {
|
||||
'renew' => 'renew',
|
||||
'transfer' => 'transfer',
|
||||
default => 'register',
|
||||
};
|
||||
|
||||
return [
|
||||
'domain' => $domain,
|
||||
'tld' => $tld,
|
||||
'action' => $action,
|
||||
'amount_minor' => $pricing[$priceKey],
|
||||
'currency' => 'GHS',
|
||||
'price_label' => $this->formatPrice($pricing[$priceKey]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached pricing.
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
Cache::forget(self::TLD_PRICING_CACHE_KEY . ':ghs');
|
||||
Cache::forget(self::LIVE_TLD_PRICING_CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current exchange rate info.
|
||||
*/
|
||||
public function getExchangeRateInfo(): array
|
||||
{
|
||||
return [
|
||||
...$this->currency->getRateInfo(),
|
||||
'markup_percent' => $this->getMarkupPercent(),
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Domain;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainOrder;
|
||||
use App\Models\RcServiceOrder;
|
||||
|
||||
class DomainRegistryService
|
||||
{
|
||||
/**
|
||||
* Create or update the unified domain registry entry for a registrar purchase.
|
||||
*/
|
||||
public function recordPurchasedDomain(
|
||||
int $userId,
|
||||
string $host,
|
||||
string $registrar,
|
||||
?string $registrarOrderId = null,
|
||||
bool $verified = true,
|
||||
array $meta = [],
|
||||
): Domain {
|
||||
$host = strtolower(trim($host));
|
||||
|
||||
$existing = Domain::query()->where('host', $host)->first();
|
||||
|
||||
$verificationMeta = array_merge(
|
||||
(array) ($existing?->verification_meta ?? []),
|
||||
$meta,
|
||||
array_filter([
|
||||
'registrar' => $registrar,
|
||||
'registrar_order_id' => $registrarOrderId,
|
||||
'registered_at' => $verified ? now()->toIso8601String() : null,
|
||||
]),
|
||||
);
|
||||
|
||||
$attributes = [
|
||||
'user_id' => $existing?->user_id ?: $userId,
|
||||
'host' => $host,
|
||||
'type' => $existing?->type ?: 'custom',
|
||||
'source' => $this->resolveSource($existing),
|
||||
'onboarding_mode' => $existing?->onboarding_mode ?: Domain::MODE_RESELLER_AUTO,
|
||||
'status' => $verified ? 'verified' : ($existing?->status ?: 'pending'),
|
||||
'onboarding_state' => $verified
|
||||
? Domain::STATE_ACTIVE
|
||||
: ($existing?->onboarding_state ?: Domain::STATE_PENDING_NS),
|
||||
'registrar' => $registrar ?: $existing?->registrar,
|
||||
'registrar_order_id' => $registrarOrderId ?: $existing?->registrar_order_id,
|
||||
'verified_at' => $verified ? ($existing?->verified_at ?: now()) : $existing?->verified_at,
|
||||
'verification_meta' => $verificationMeta,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($attributes);
|
||||
|
||||
return $existing->fresh();
|
||||
}
|
||||
|
||||
return Domain::create($attributes);
|
||||
}
|
||||
|
||||
public function recordFromDomainOrder(DomainOrder $order, bool $verified = true): Domain
|
||||
{
|
||||
if (! $order->user_id) {
|
||||
throw new \InvalidArgumentException('Domain order is missing user_id.');
|
||||
}
|
||||
|
||||
return $this->recordPurchasedDomain(
|
||||
$order->user_id,
|
||||
$order->domain_name,
|
||||
(string) ($order->registrar ?: DomainOrder::REGISTRAR_DYNADOT),
|
||||
$order->registrar_order_id ? (string) $order->registrar_order_id : null,
|
||||
$verified,
|
||||
[
|
||||
'domain_order_id' => $order->id,
|
||||
'order_type' => $order->order_type,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function recordFromRcServiceOrder(RcServiceOrder $order, bool $verified = false): ?Domain
|
||||
{
|
||||
if (! $order->user_id || ! filled($order->domain_name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! in_array($order->order_type, [
|
||||
RcServiceOrder::TYPE_DOMAIN_REGISTRATION,
|
||||
RcServiceOrder::TYPE_DOMAIN_TRANSFER,
|
||||
], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->recordPurchasedDomain(
|
||||
$order->user_id,
|
||||
$order->domain_name,
|
||||
DomainOrder::REGISTRAR_RESELLERCLUB,
|
||||
$order->rc_order_id ? (string) $order->rc_order_id : null,
|
||||
$verified,
|
||||
[
|
||||
'rc_service_order_id' => $order->id,
|
||||
'order_type' => $order->order_type,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function markVerified(string $host): ?Domain
|
||||
{
|
||||
$domain = Domain::query()->where('host', strtolower(trim($host)))->first();
|
||||
|
||||
if (! $domain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$domain->update([
|
||||
'status' => 'verified',
|
||||
'onboarding_state' => Domain::STATE_ACTIVE,
|
||||
'verified_at' => $domain->verified_at ?: now(),
|
||||
]);
|
||||
|
||||
return $domain->fresh();
|
||||
}
|
||||
|
||||
private function resolveSource(?Domain $existing): string
|
||||
{
|
||||
if (! $existing) {
|
||||
return 'purchased';
|
||||
}
|
||||
|
||||
$source = (string) ($existing->source ?: '');
|
||||
|
||||
if (in_array($source, ['website', 'hosting', 'email', 'hosting_email', 'website_email'], true)) {
|
||||
return $source;
|
||||
}
|
||||
|
||||
return 'purchased';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Domain;
|
||||
|
||||
use App\Jobs\MailProvisioningJob;
|
||||
use App\Jobs\ProvisionDomainZoneJob;
|
||||
use App\Jobs\SslProvisioningJob;
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainDkimKey;
|
||||
use App\Models\DomainDnsRecord;
|
||||
use App\Models\DomainNsCheck;
|
||||
use App\Notifications\DomainVerifiedNotification;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DomainVerificationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DomainDnsBlueprintService $blueprints,
|
||||
private readonly \App\Services\Dns\PowerDnsClient $pdns,
|
||||
) {
|
||||
}
|
||||
|
||||
public function verify(Domain $domain): bool
|
||||
{
|
||||
$domain->refresh();
|
||||
$statusBefore = (string) $domain->status;
|
||||
$stateBefore = (string) ($domain->onboarding_state ?: Domain::STATE_PENDING_NS);
|
||||
|
||||
$domain->update([
|
||||
'status' => 'verifying',
|
||||
'onboarding_state' => Domain::STATE_VERIFYING_NS,
|
||||
'ns_checked_at' => now(),
|
||||
]);
|
||||
|
||||
if ((string) $domain->onboarding_mode === Domain::MODE_MANUAL_DNS) {
|
||||
return $this->verifyManualDns($domain->fresh(), $statusBefore, $stateBefore);
|
||||
}
|
||||
|
||||
return $this->verifyNameserverTakeover($domain->fresh(), $statusBefore, $stateBefore);
|
||||
}
|
||||
|
||||
public function prepareNameserverZone(Domain $domain): void
|
||||
{
|
||||
$domain = $domain->fresh();
|
||||
$this->ensureDkimKeys($domain);
|
||||
$this->dispatchProvisioningChain($domain->id);
|
||||
}
|
||||
|
||||
public function prepareManualDnsPack(Domain $domain): void
|
||||
{
|
||||
$domain = $domain->fresh();
|
||||
[$selector, $publicKey] = $this->ensureDkimKeys($domain);
|
||||
$pack = $this->blueprints->manualDnsPack($domain, $selector, $publicKey);
|
||||
$this->syncDnsRecords($domain, $pack, 'required_manual');
|
||||
}
|
||||
|
||||
private function verifyNameserverTakeover(Domain $domain, string $statusBefore, string $stateBefore): bool
|
||||
{
|
||||
$expected = $this->normalizeNameservers((array) ($domain->ns_expected ?? $this->blueprints->expectedNameservers()));
|
||||
$observed = $this->lookupNameservers($domain->host);
|
||||
|
||||
$domain->update([
|
||||
'ns_expected' => $expected,
|
||||
'ns_observed' => $observed,
|
||||
]);
|
||||
|
||||
if (! $this->nameserversMatch($expected, $observed)) {
|
||||
$domain->update([
|
||||
'status' => 'pending',
|
||||
'onboarding_state' => Domain::STATE_PENDING_NS,
|
||||
]);
|
||||
$domain = $domain->fresh();
|
||||
$this->logCheck($domain, [
|
||||
'check_type' => 'ns_takeover',
|
||||
'result' => 'pending',
|
||||
'expected_nameservers' => $expected,
|
||||
'observed_nameservers' => $observed,
|
||||
'has_authoritative_answer' => null,
|
||||
'status_before' => $statusBefore,
|
||||
'status_after' => (string) $domain->status,
|
||||
'state_before' => $stateBefore,
|
||||
'state_after' => (string) $domain->onboarding_state,
|
||||
'notes' => 'Nameservers do not match expected Ladill nameservers yet.',
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasAuthoritativeAnswer = $this->zoneHasAuthoritativeAnswer($domain->host);
|
||||
if (! $hasAuthoritativeAnswer) {
|
||||
ProvisionDomainZoneJob::dispatch($domain->id);
|
||||
|
||||
$domain->update([
|
||||
'status' => 'verifying',
|
||||
'onboarding_state' => Domain::STATE_VERIFYING_NS,
|
||||
]);
|
||||
$domain = $domain->fresh();
|
||||
$this->logCheck($domain, [
|
||||
'check_type' => 'ns_takeover',
|
||||
'result' => 'pending',
|
||||
'expected_nameservers' => $expected,
|
||||
'observed_nameservers' => $observed,
|
||||
'has_authoritative_answer' => false,
|
||||
'status_before' => $statusBefore,
|
||||
'status_after' => (string) $domain->status,
|
||||
'state_before' => $stateBefore,
|
||||
'state_after' => (string) $domain->onboarding_state,
|
||||
'notes' => 'Nameservers match, but authoritative zone answer is not ready yet.',
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($domain, $statusBefore, $stateBefore, $expected, $observed, $hasAuthoritativeAnswer): bool {
|
||||
$domain->refresh();
|
||||
$domain->update([
|
||||
'status' => 'verifying',
|
||||
'onboarding_state' => Domain::STATE_CONNECTED,
|
||||
'connected_at' => $domain->connected_at ?? now(),
|
||||
'dns_mode' => 'managed',
|
||||
]);
|
||||
|
||||
[$selector, $publicKey] = $this->ensureDkimKeys($domain);
|
||||
$records = $this->blueprints->managedRecords($domain, $selector, $publicKey);
|
||||
$this->syncDnsRecords($domain, $records, 'managed');
|
||||
|
||||
$domain->update([
|
||||
'onboarding_state' => Domain::STATE_MAIL_READY,
|
||||
'mail_ready_at' => $domain->mail_ready_at ?? now(),
|
||||
]);
|
||||
|
||||
$domain->update([
|
||||
'status' => 'verified',
|
||||
'verified_at' => $domain->verified_at ?? now(),
|
||||
'onboarding_state' => Domain::STATE_ACTIVE,
|
||||
'active_at' => $domain->active_at ?? now(),
|
||||
]);
|
||||
$domain->refresh();
|
||||
$this->logCheck($domain, [
|
||||
'check_type' => 'ns_takeover',
|
||||
'result' => 'success',
|
||||
'expected_nameservers' => $expected,
|
||||
'observed_nameservers' => $observed,
|
||||
'has_authoritative_answer' => $hasAuthoritativeAnswer,
|
||||
'status_before' => $statusBefore,
|
||||
'status_after' => (string) $domain->status,
|
||||
'state_before' => $stateBefore,
|
||||
'state_after' => (string) $domain->onboarding_state,
|
||||
'notes' => 'Nameserver takeover complete. Managed DNS and mail records are provisioned.',
|
||||
]);
|
||||
$this->dispatchProvisioningChain($domain->id);
|
||||
|
||||
// Notify user that domain is verified
|
||||
$user = $domain->website?->user;
|
||||
if ($user) {
|
||||
$user->notify(new DomainVerifiedNotification($domain));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private function verifyManualDns(Domain $domain, string $statusBefore, string $stateBefore): bool
|
||||
{
|
||||
$this->prepareManualDnsPack($domain);
|
||||
$domain = $domain->fresh('dnsRecords');
|
||||
|
||||
$records = $domain->dnsRecords
|
||||
->where('source', 'required_manual')
|
||||
->values();
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
$domain->update([
|
||||
'status' => 'failed',
|
||||
'onboarding_state' => Domain::STATE_FAILED,
|
||||
]);
|
||||
$domain = $domain->fresh();
|
||||
$this->logCheck($domain, [
|
||||
'check_type' => 'manual_dns',
|
||||
'result' => 'failed',
|
||||
'manual_records_total' => 0,
|
||||
'manual_records_verified' => 0,
|
||||
'status_before' => $statusBefore,
|
||||
'status_after' => (string) $domain->status,
|
||||
'state_before' => $stateBefore,
|
||||
'state_after' => (string) $domain->onboarding_state,
|
||||
'notes' => 'Manual DNS pack is empty; cannot verify domain.',
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$allVerified = true;
|
||||
|
||||
foreach ($records as $record) {
|
||||
$matched = $this->recordExistsInPublicDns($domain->host, $record);
|
||||
|
||||
$record->update([
|
||||
'status' => $matched ? 'verified' : 'pending',
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
if (! $matched) {
|
||||
$allVerified = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $allVerified) {
|
||||
$domain->update([
|
||||
'status' => 'pending',
|
||||
'onboarding_state' => Domain::STATE_VERIFYING_NS,
|
||||
'ns_checked_at' => now(),
|
||||
]);
|
||||
$domain = $domain->fresh();
|
||||
$this->logCheck($domain, [
|
||||
'check_type' => 'manual_dns',
|
||||
'result' => 'pending',
|
||||
'manual_records_total' => $records->count(),
|
||||
'manual_records_verified' => $records->where('status', 'verified')->count(),
|
||||
'status_before' => $statusBefore,
|
||||
'status_after' => (string) $domain->status,
|
||||
'state_before' => $stateBefore,
|
||||
'state_after' => (string) $domain->onboarding_state,
|
||||
'notes' => 'Manual DNS records are not fully propagated yet.',
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$domain->update([
|
||||
'status' => 'verified',
|
||||
'verified_at' => $domain->verified_at ?? now(),
|
||||
'onboarding_state' => Domain::STATE_ACTIVE,
|
||||
'dns_mode' => 'manual',
|
||||
'manual_dns_verified_at' => $domain->manual_dns_verified_at ?? now(),
|
||||
'connected_at' => $domain->connected_at ?? now(),
|
||||
'mail_ready_at' => $domain->mail_ready_at ?? now(),
|
||||
'active_at' => $domain->active_at ?? now(),
|
||||
'ns_checked_at' => now(),
|
||||
]);
|
||||
$domain = $domain->fresh();
|
||||
$this->logCheck($domain, [
|
||||
'check_type' => 'manual_dns',
|
||||
'result' => 'success',
|
||||
'manual_records_total' => $records->count(),
|
||||
'manual_records_verified' => $records->where('status', 'verified')->count(),
|
||||
'status_before' => $statusBefore,
|
||||
'status_after' => (string) $domain->status,
|
||||
'state_before' => $stateBefore,
|
||||
'state_after' => (string) $domain->onboarding_state,
|
||||
'notes' => 'All required manual DNS records are verified.',
|
||||
]);
|
||||
$this->dispatchProvisioningChain($domain->id);
|
||||
|
||||
// Notify user that domain is verified
|
||||
$user = $domain->website?->user;
|
||||
if ($user) {
|
||||
$user->notify(new DomainVerifiedNotification($domain));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function reprovision(Domain $domain): void
|
||||
{
|
||||
$this->dispatchProvisioningChain($domain->id);
|
||||
}
|
||||
|
||||
private function dispatchProvisioningChain(int $domainId): void
|
||||
{
|
||||
ProvisionDomainZoneJob::withChain([
|
||||
new MailProvisioningJob($domainId),
|
||||
new SslProvisioningJob($domainId),
|
||||
])->dispatch($domainId);
|
||||
}
|
||||
|
||||
private function ensureDkimKeys(Domain $domain): array
|
||||
{
|
||||
$existing = DomainDkimKey::query()
|
||||
->where('domain_id', $domain->id)
|
||||
->where('status', 'active')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return [$existing->selector, $existing->public_key_txt];
|
||||
}
|
||||
|
||||
$prefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill');
|
||||
$selector = Str::lower(trim($prefix)) ?: 'ladill';
|
||||
$selector .= '1';
|
||||
|
||||
$publicKey = '';
|
||||
$privateKey = null;
|
||||
|
||||
if (function_exists('openssl_pkey_new')) {
|
||||
$resource = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
if ($resource !== false) {
|
||||
openssl_pkey_export($resource, $privateOut);
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
$publicPem = (string) ($details['key'] ?? '');
|
||||
$publicKey = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicPem) ?: '';
|
||||
$privateKey = $privateOut ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($publicKey === '') {
|
||||
$publicKey = Str::upper(Str::random(360));
|
||||
}
|
||||
|
||||
$privatePath = null;
|
||||
if (is_string($privateKey) && trim($privateKey) !== '') {
|
||||
$safeHost = Str::slug($domain->host, '_');
|
||||
$directory = storage_path('app/mail/dkim');
|
||||
File::ensureDirectoryExists($directory);
|
||||
$privatePath = $directory.'/'.$safeHost.'_'.$selector.'.key';
|
||||
File::put($privatePath, $privateKey);
|
||||
}
|
||||
|
||||
$key = DomainDkimKey::query()->create([
|
||||
'domain_id' => $domain->id,
|
||||
'selector' => $selector,
|
||||
'public_key_txt' => $publicKey,
|
||||
'private_key_path' => $privatePath,
|
||||
'status' => 'active',
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
return [$key->selector, $key->public_key_txt];
|
||||
}
|
||||
|
||||
private function syncDnsRecords(Domain $domain, array $records, string $source): void
|
||||
{
|
||||
$keepSignatures = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
if (! is_array($record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) ($record['name'] ?? '@');
|
||||
$type = strtoupper((string) ($record['type'] ?? 'TXT'));
|
||||
$value = (string) ($record['value'] ?? '');
|
||||
$keepSignatures[] = strtolower($name.'|'.$type.'|'.$value);
|
||||
|
||||
DomainDnsRecord::query()->updateOrCreate(
|
||||
[
|
||||
'domain_id' => $domain->id,
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
],
|
||||
[
|
||||
'ttl' => (int) ($record['ttl'] ?? 3600),
|
||||
'priority' => isset($record['priority']) ? (int) $record['priority'] : null,
|
||||
'source' => $source,
|
||||
'status' => (string) ($record['status'] ?? 'pending'),
|
||||
'notes' => $record['notes'] ?? null,
|
||||
'last_checked_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
DomainDnsRecord::query()
|
||||
->where('domain_id', $domain->id)
|
||||
->where('source', $source)
|
||||
->get()
|
||||
->each(function (DomainDnsRecord $record) use ($keepSignatures): void {
|
||||
$signature = strtolower($record->name.'|'.$record->type.'|'.$record->value);
|
||||
if (! in_array($signature, $keepSignatures, true)) {
|
||||
$record->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function lookupNameservers(string $host): array
|
||||
{
|
||||
if (! function_exists('dns_get_record')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$records = @dns_get_record($host, DNS_NS);
|
||||
if (! is_array($records)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($records)
|
||||
->map(fn ($record) => strtolower(trim((string) ($record['target'] ?? ''), ". \t\n\r\0\x0B")))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeNameservers(array $nameservers): array
|
||||
{
|
||||
return collect($nameservers)
|
||||
->map(fn ($record) => strtolower(trim((string) $record, ". \t\n\r\0\x0B")))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function nameserversMatch(array $expected, array $observed): bool
|
||||
{
|
||||
$expected = $this->normalizeNameservers($expected);
|
||||
$observed = $this->normalizeNameservers($observed);
|
||||
|
||||
if ($expected === [] || $observed === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return collect($expected)->diff($observed)->isEmpty();
|
||||
}
|
||||
|
||||
private function recordExistsInPublicDns(string $zoneHost, DomainDnsRecord $record): bool
|
||||
{
|
||||
if (! function_exists('dns_get_record')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$name = trim((string) $record->name);
|
||||
$fqdn = $name === '@' ? $zoneHost : $name.'.'.$zoneHost;
|
||||
$type = strtoupper(trim((string) $record->type));
|
||||
$expectedValue = strtolower(trim((string) $record->value, ". \t\n\r\0\x0B\""));
|
||||
|
||||
$flag = match ($type) {
|
||||
'MX' => DNS_MX,
|
||||
'CNAME' => DNS_CNAME,
|
||||
'A' => DNS_A,
|
||||
default => DNS_TXT,
|
||||
};
|
||||
|
||||
$records = @dns_get_record($fqdn, $flag);
|
||||
if (! is_array($records) || $records === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($records as $item) {
|
||||
if ($type === 'MX') {
|
||||
$value = strtolower(trim((string) ($item['target'] ?? ''), ". \t\n\r\0\x0B"));
|
||||
$priority = (int) ($item['pri'] ?? 0);
|
||||
$expectedPriority = (int) ($record->priority ?? 0);
|
||||
if ($value === $expectedValue && $priority === $expectedPriority) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'TXT') {
|
||||
$value = strtolower(trim((string) ($item['txt'] ?? ''), "\" \t\n\r\0\x0B"));
|
||||
if ($value === $expectedValue) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'CNAME') {
|
||||
$value = strtolower(trim((string) ($item['target'] ?? ''), ". \t\n\r\0\x0B"));
|
||||
if ($value === $expectedValue) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'A') {
|
||||
$value = strtolower(trim((string) ($item['ip'] ?? ''), ". \t\n\r\0\x0B"));
|
||||
if ($value === $expectedValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function zoneHasAuthoritativeAnswer(string $host): bool
|
||||
{
|
||||
if (! function_exists('dns_get_record')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$soa = @dns_get_record($host, DNS_SOA);
|
||||
if (is_array($soa) && $soa !== []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ns = @dns_get_record($host, DNS_NS);
|
||||
|
||||
return is_array($ns) && $ns !== [];
|
||||
}
|
||||
|
||||
private function logCheck(Domain $domain, array $payload): void
|
||||
{
|
||||
DomainNsCheck::query()->create([
|
||||
'domain_id' => $domain->id,
|
||||
'check_type' => (string) ($payload['check_type'] ?? 'onboarding'),
|
||||
'result' => (string) ($payload['result'] ?? 'pending'),
|
||||
'expected_nameservers' => $payload['expected_nameservers'] ?? null,
|
||||
'observed_nameservers' => $payload['observed_nameservers'] ?? null,
|
||||
'has_authoritative_answer' => $payload['has_authoritative_answer'] ?? null,
|
||||
'manual_records_total' => (int) ($payload['manual_records_total'] ?? 0),
|
||||
'manual_records_verified' => (int) ($payload['manual_records_verified'] ?? 0),
|
||||
'status_before' => $payload['status_before'] ?? null,
|
||||
'status_after' => $payload['status_after'] ?? null,
|
||||
'state_before' => $payload['state_before'] ?? null,
|
||||
'state_after' => $payload['state_after'] ?? null,
|
||||
'notes' => $payload['notes'] ?? null,
|
||||
'checked_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user