1091 lines
39 KiB
PHP
1091 lines
39 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
if (!function_exists('wablasTableExists')) {
|
|
function wablasTableExists(string $tableName): bool
|
|
{
|
|
static $cache = [];
|
|
|
|
$normalized = strtolower(trim($tableName));
|
|
if ($normalized === '') {
|
|
return false;
|
|
}
|
|
|
|
if (array_key_exists($normalized, $cache)) {
|
|
return $cache[$normalized];
|
|
}
|
|
|
|
try {
|
|
$stmt = db()->prepare('SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1');
|
|
$stmt->execute([$tableName]);
|
|
$cache[$normalized] = (bool) $stmt->fetchColumn();
|
|
} catch (Throwable $e) {
|
|
$cache[$normalized] = false;
|
|
}
|
|
|
|
return $cache[$normalized];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasColumnExists')) {
|
|
function wablasColumnExists(string $tableName, string $columnName): bool
|
|
{
|
|
static $cache = [];
|
|
|
|
$cacheKey = strtolower(trim($tableName) . '.' . trim($columnName));
|
|
if ($cacheKey === '.') {
|
|
return false;
|
|
}
|
|
|
|
if (array_key_exists($cacheKey, $cache)) {
|
|
return $cache[$cacheKey];
|
|
}
|
|
|
|
try {
|
|
$stmt = db()->prepare('SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1');
|
|
$stmt->execute([$tableName, $columnName]);
|
|
$cache[$cacheKey] = (bool) $stmt->fetchColumn();
|
|
} catch (Throwable $e) {
|
|
$cache[$cacheKey] = false;
|
|
}
|
|
|
|
return $cache[$cacheKey];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasFirstExistingColumn')) {
|
|
function wablasFirstExistingColumn(string $tableName, array $columnNames): ?string
|
|
{
|
|
foreach ($columnNames as $columnName) {
|
|
if (!is_string($columnName) || $columnName === '') {
|
|
continue;
|
|
}
|
|
if (wablasColumnExists($tableName, $columnName)) {
|
|
return $columnName;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasSettingsMap')) {
|
|
function wablasSettingsMap(array $keys = [], bool $forceReload = false): array
|
|
{
|
|
static $cache = [];
|
|
static $allLoaded = false;
|
|
|
|
if ($forceReload) {
|
|
$cache = [];
|
|
$allLoaded = false;
|
|
}
|
|
|
|
$normalizedKeys = [];
|
|
foreach ($keys as $key) {
|
|
$key = trim((string) $key);
|
|
if ($key !== '') {
|
|
$normalizedKeys[$key] = true;
|
|
}
|
|
}
|
|
$normalizedKeys = array_keys($normalizedKeys);
|
|
|
|
if ($normalizedKeys === []) {
|
|
if (!$allLoaded) {
|
|
try {
|
|
$stmt = db()->query('SELECT `key`, `value` FROM settings');
|
|
$rows = $stmt ? ($stmt->fetchAll(PDO::FETCH_KEY_PAIR) ?: []) : [];
|
|
foreach ($rows as $key => $value) {
|
|
$cache[(string) $key] = (string) $value;
|
|
}
|
|
$allLoaded = true;
|
|
} catch (Throwable $e) {
|
|
// Ignore and return what we have.
|
|
}
|
|
}
|
|
|
|
return $cache;
|
|
}
|
|
|
|
$missing = [];
|
|
foreach ($normalizedKeys as $key) {
|
|
if (!array_key_exists($key, $cache)) {
|
|
$missing[] = $key;
|
|
}
|
|
}
|
|
|
|
if ($missing !== []) {
|
|
try {
|
|
$placeholders = implode(', ', array_fill(0, count($missing), '?'));
|
|
$stmt = db()->prepare("SELECT `key`, `value` FROM settings WHERE `key` IN ($placeholders)");
|
|
$stmt->execute($missing);
|
|
$rows = $stmt->fetchAll(PDO::FETCH_KEY_PAIR) ?: [];
|
|
foreach ($missing as $key) {
|
|
$cache[$key] = array_key_exists($key, $rows) ? (string) $rows[$key] : '';
|
|
}
|
|
} catch (Throwable $e) {
|
|
foreach ($missing as $key) {
|
|
$cache[$key] = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
$result = [];
|
|
foreach ($normalizedKeys as $key) {
|
|
$result[$key] = $cache[$key] ?? '';
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasSettingValue')) {
|
|
function wablasSettingValue(string $key, ?string $default = null): ?string
|
|
{
|
|
$key = trim($key);
|
|
if ($key === '') {
|
|
return $default;
|
|
}
|
|
|
|
$settings = wablasSettingsMap([$key]);
|
|
$value = $settings[$key] ?? '';
|
|
if ($value === '') {
|
|
return $default;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasSettingEnabled')) {
|
|
function wablasSettingEnabled(string $key): bool
|
|
{
|
|
return wablasSettingValue($key, '0') === '1';
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasAppTimezone')) {
|
|
function wablasAppTimezone(): DateTimeZone
|
|
{
|
|
$configured = trim((string) (wablasSettingValue('timezone', date_default_timezone_get()) ?? date_default_timezone_get()));
|
|
try {
|
|
return new DateTimeZone($configured !== '' ? $configured : 'UTC');
|
|
} catch (Throwable $e) {
|
|
return new DateTimeZone('UTC');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasNow')) {
|
|
function wablasNow(): DateTimeImmutable
|
|
{
|
|
return new DateTimeImmutable('now', wablasAppTimezone());
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasFormatMoney')) {
|
|
function wablasFormatMoney(float $amount): string
|
|
{
|
|
return number_format($amount, 3, '.', '');
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasCurrentBaseUrl')) {
|
|
function wablasCurrentBaseUrl(): string
|
|
{
|
|
$host = trim((string) ($_SERVER['HTTP_HOST'] ?? ''));
|
|
if ($host === '') {
|
|
return '';
|
|
}
|
|
|
|
$scheme = 'http';
|
|
$forwardedProto = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
|
|
if ($forwardedProto === 'https' || (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off')) {
|
|
$scheme = 'https';
|
|
}
|
|
|
|
return $scheme . '://' . $host;
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasInvoiceRouteUrl')) {
|
|
function wablasInvoiceRouteUrl(int $invoiceId, string $baseUrl = ''): string
|
|
{
|
|
$relative = function_exists('page_url')
|
|
? page_url('sales', ['invoice_id' => $invoiceId])
|
|
: ('sales.php?invoice_id=' . $invoiceId);
|
|
|
|
$baseUrl = trim($baseUrl);
|
|
if ($baseUrl === '') {
|
|
return $relative;
|
|
}
|
|
|
|
return rtrim($baseUrl, '/') . '/' . ltrim($relative, '/');
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasNormalizePhone')) {
|
|
function wablasNormalizePhone(string $number, string $defaultCountryCode = ''): ?string
|
|
{
|
|
$raw = trim($number);
|
|
if ($raw === '') {
|
|
return null;
|
|
}
|
|
|
|
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
|
if ($digits === '') {
|
|
return null;
|
|
}
|
|
|
|
$countryDigits = preg_replace('/\D+/', '', $defaultCountryCode) ?? '';
|
|
if ($countryDigits !== '' && !str_starts_with($countryDigits, '0')) {
|
|
// keep as-is
|
|
}
|
|
|
|
if (str_starts_with($raw, '+')) {
|
|
return $digits;
|
|
}
|
|
|
|
if ($countryDigits !== '' && str_starts_with($digits, '0')) {
|
|
return $countryDigits . ltrim($digits, '0');
|
|
}
|
|
|
|
return $digits;
|
|
}
|
|
}
|
|
|
|
|
|
if (!function_exists('wablasNumbersFromText')) {
|
|
function wablasNumbersFromText(string $rawNumbers, string $defaultCountryCode = ''): array
|
|
{
|
|
$rawNumbers = str_replace(["\r\n", "\r"], "\n", $rawNumbers);
|
|
$parts = preg_split('/[\n,;]+/', $rawNumbers) ?: [];
|
|
$normalizedNumbers = [];
|
|
|
|
foreach ($parts as $part) {
|
|
$normalized = wablasNormalizePhone((string) $part, $defaultCountryCode);
|
|
if ($normalized !== null && $normalized !== '') {
|
|
$normalizedNumbers[$normalized] = true;
|
|
}
|
|
}
|
|
|
|
return array_slice(array_keys($normalizedNumbers), 0, 100);
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasRecipientNumbersFromSetting')) {
|
|
function wablasRecipientNumbersFromSetting(string $settingKey): array
|
|
{
|
|
$raw = str_replace(["
|
|
", "
|
|
"], "
|
|
", (string) (wablasSettingValue($settingKey, '') ?? ''));
|
|
$defaultCountryCode = (string) (wablasSettingValue('wablas_default_country_code', '') ?? '');
|
|
$seen = [];
|
|
|
|
foreach (preg_split('/[
|
|
,]+/', $raw) ?: [] as $part) {
|
|
$normalized = wablasNormalizePhone((string) $part, $defaultCountryCode);
|
|
if ($normalized === null) {
|
|
continue;
|
|
}
|
|
$seen[$normalized] = true;
|
|
}
|
|
|
|
return array_keys($seen);
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasRenderTemplate')) {
|
|
function wablasRenderTemplate(string $template, array $placeholders): string
|
|
{
|
|
$replacements = [];
|
|
foreach ($placeholders as $key => $value) {
|
|
if (!is_string($key) || $key === '') {
|
|
continue;
|
|
}
|
|
$replacements['{' . $key . '}'] = trim((string) $value);
|
|
}
|
|
|
|
$rendered = strtr($template, $replacements);
|
|
$rendered = preg_replace("/
|
|
{3,}/", "
|
|
|
|
", $rendered) ?? $rendered;
|
|
|
|
return trim($rendered);
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasDefaultInvoiceTemplate')) {
|
|
function wablasDefaultInvoiceTemplate(): string
|
|
{
|
|
return 'Hello {customer_name}, your invoice {invoice_no} total is {grand_total}. View: {invoice_url}';
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasDefaultSummaryTemplate')) {
|
|
function wablasDefaultSummaryTemplate(): string
|
|
{
|
|
return 'Daily summary {report_date}: invoices {invoice_count}, sales {sales_total}, cash {cash_total}, card {card_total}';
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasJsonEncode')) {
|
|
function wablasJsonEncode(mixed $value): string
|
|
{
|
|
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
|
return $json === false ? '{}' : $json;
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasJsonDecode')) {
|
|
function wablasJsonDecode(?string $json): array
|
|
{
|
|
if (!is_string($json) || trim($json) === '') {
|
|
return [];
|
|
}
|
|
|
|
$decoded = json_decode($json, true);
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasBuildInvoiceContext')) {
|
|
function wablasBuildInvoiceContext(int $invoiceId, array $options = []): ?array
|
|
{
|
|
if ($invoiceId <= 0 || !wablasTableExists('invoices')) {
|
|
return null;
|
|
}
|
|
|
|
$taxColumn = wablasFirstExistingColumn('customers', ['tax_id', 'tax_number', 'vat_number', 'tax_no', 'vat_no', 'trn']);
|
|
$taxSelect = $taxColumn !== null ? "c.`$taxColumn` AS customer_tax_id" : "'' AS customer_tax_id";
|
|
$outletSelect = "'' AS outlet_name";
|
|
$outletJoin = '';
|
|
|
|
if (wablasTableExists('outlets') && wablasColumnExists('invoices', 'outlet_id') && wablasColumnExists('outlets', 'name')) {
|
|
$outletSelect = 'o.name AS outlet_name';
|
|
$outletJoin = 'LEFT JOIN outlets o ON i.outlet_id = o.id';
|
|
}
|
|
|
|
$stmt = db()->prepare(
|
|
"SELECT i.id, i.transaction_no, i.invoice_date, i.due_date, i.status, i.payment_type, i.total_amount, i.vat_amount, i.total_with_vat, i.paid_amount,
|
|
c.name AS customer_name, c.phone AS customer_phone, $taxSelect, $outletSelect
|
|
FROM invoices i
|
|
LEFT JOIN customers c ON i.customer_id = c.id
|
|
$outletJoin
|
|
WHERE i.id = ?
|
|
LIMIT 1"
|
|
);
|
|
$stmt->execute([$invoiceId]);
|
|
$invoice = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
if (!is_array($invoice) || $invoice === []) {
|
|
return null;
|
|
}
|
|
|
|
$grandTotal = (float) ($invoice['total_with_vat'] ?? (((float) ($invoice['total_amount'] ?? 0)) + ((float) ($invoice['vat_amount'] ?? 0))));
|
|
$paidAmount = (float) ($invoice['paid_amount'] ?? 0);
|
|
$invoiceNo = trim((string) ($invoice['transaction_no'] ?? ''));
|
|
if ($invoiceNo === '') {
|
|
$invoiceNo = 'INV-' . str_pad((string) $invoiceId, 5, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
$status = trim((string) ($invoice['status'] ?? ''));
|
|
if ($status !== '') {
|
|
$status = ucwords(str_replace('_', ' ', $status));
|
|
}
|
|
|
|
$baseUrl = trim((string) ($options['base_url'] ?? wablasCurrentBaseUrl()));
|
|
$companyName = (string) (wablasSettingValue('company_name', 'Your Company') ?? 'Your Company');
|
|
|
|
return [
|
|
'invoice_id' => (string) $invoiceId,
|
|
'invoice_no' => $invoiceNo,
|
|
'customer_name' => (string) ($invoice['customer_name'] ?? 'Customer'),
|
|
'customer_phone' => (string) ($invoice['customer_phone'] ?? ''),
|
|
'customer_tax_id' => (string) ($invoice['customer_tax_id'] ?? ''),
|
|
'invoice_date' => (string) ($invoice['invoice_date'] ?? ''),
|
|
'due_date' => (string) ($invoice['due_date'] ?? ''),
|
|
'status' => $status,
|
|
'payment_type' => strtoupper((string) ($invoice['payment_type'] ?? '')),
|
|
'grand_total' => wablasFormatMoney($grandTotal),
|
|
'total_amount' => wablasFormatMoney((float) ($invoice['total_amount'] ?? 0)),
|
|
'vat_amount' => wablasFormatMoney((float) ($invoice['vat_amount'] ?? 0)),
|
|
'paid_amount' => wablasFormatMoney($paidAmount),
|
|
'balance_due' => wablasFormatMoney(max($grandTotal - $paidAmount, 0.0)),
|
|
'company_name' => $companyName,
|
|
'outlet_name' => (string) ($invoice['outlet_name'] ?? ''),
|
|
'invoice_url' => wablasInvoiceRouteUrl($invoiceId, $baseUrl),
|
|
'wablas_sender' => (string) (wablasSettingValue('wablas_sender', '') ?? ''),
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasBuildDailySummaryContext')) {
|
|
function wablasBuildDailySummaryContext(string $reportDate, ?DateTimeImmutable $at = null): array
|
|
{
|
|
$reportDate = trim($reportDate);
|
|
$at = $at ?? wablasNow();
|
|
|
|
$sql = "SELECT
|
|
COUNT(*) AS invoice_count,
|
|
COALESCE(SUM(total_with_vat), 0) AS sales_total,
|
|
COALESCE(SUM(paid_amount), 0) AS paid_total,
|
|
COALESCE(SUM(CASE
|
|
WHEN LOWER(COALESCE(payment_type, '')) LIKE '%cash%'
|
|
THEN paid_amount ELSE 0 END), 0) AS cash_total,
|
|
COALESCE(SUM(CASE
|
|
WHEN LOWER(COALESCE(payment_type, '')) LIKE '%card%'
|
|
OR LOWER(COALESCE(payment_type, '')) LIKE '%visa%'
|
|
OR LOWER(COALESCE(payment_type, '')) LIKE '%master%'
|
|
OR LOWER(COALESCE(payment_type, '')) LIKE '%credit%'
|
|
OR LOWER(COALESCE(payment_type, '')) LIKE '%debit%'
|
|
OR LOWER(COALESCE(payment_type, '')) LIKE '%bank%'
|
|
OR LOWER(COALESCE(payment_type, '')) LIKE '%pos%'
|
|
THEN paid_amount ELSE 0 END), 0) AS card_total,
|
|
COALESCE(SUM(CASE
|
|
WHEN COALESCE(status, '') IN ('unpaid', 'partially_paid', 'pending')
|
|
THEN GREATEST(total_with_vat - paid_amount, 0)
|
|
ELSE 0 END), 0) AS due_total
|
|
FROM invoices
|
|
WHERE invoice_date = ?
|
|
AND (type = 'sale' OR type IS NULL OR type = '')";
|
|
|
|
$stmt = db()->prepare($sql);
|
|
$stmt->execute([$reportDate]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
|
|
|
return [
|
|
'report_date' => $reportDate,
|
|
'report_time' => $at->format('H:i'),
|
|
'invoice_count' => (string) ((int) ($row['invoice_count'] ?? 0)),
|
|
'sales_total' => wablasFormatMoney((float) ($row['sales_total'] ?? 0)),
|
|
'paid_total' => wablasFormatMoney((float) ($row['paid_total'] ?? 0)),
|
|
'cash_total' => wablasFormatMoney((float) ($row['cash_total'] ?? 0)),
|
|
'card_total' => wablasFormatMoney((float) ($row['card_total'] ?? 0)),
|
|
'due_total' => wablasFormatMoney((float) ($row['due_total'] ?? 0)),
|
|
'company_name' => (string) (wablasSettingValue('company_name', 'Your Company') ?? 'Your Company'),
|
|
'wablas_sender' => (string) (wablasSettingValue('wablas_sender', '') ?? ''),
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasInvoiceScheduledFor')) {
|
|
function wablasInvoiceScheduledFor(?string $timeValue, ?DateTimeImmutable $base = null): DateTimeImmutable
|
|
{
|
|
$base = $base ?? wablasNow();
|
|
$timeValue = trim((string) $timeValue);
|
|
if ($timeValue === '' || !preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $timeValue)) {
|
|
return $base;
|
|
}
|
|
|
|
$candidate = DateTimeImmutable::createFromFormat('Y-m-d H:i', $base->format('Y-m-d') . ' ' . $timeValue, $base->getTimezone());
|
|
if (!$candidate instanceof DateTimeImmutable) {
|
|
return $base;
|
|
}
|
|
|
|
if ($candidate->format('Y-m-d H:i') === $base->format('Y-m-d H:i')) {
|
|
return $base;
|
|
}
|
|
|
|
if ($candidate < $base) {
|
|
return $candidate->modify('+1 day');
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasDailySummaryDueAt')) {
|
|
function wablasDailySummaryDueAt(?string $timeValue, ?DateTimeImmutable $base = null): DateTimeImmutable
|
|
{
|
|
$base = $base ?? wablasNow();
|
|
$timeValue = trim((string) $timeValue);
|
|
if ($timeValue === '' || !preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $timeValue)) {
|
|
$timeValue = '20:00';
|
|
}
|
|
|
|
$candidate = DateTimeImmutable::createFromFormat('Y-m-d H:i', $base->format('Y-m-d') . ' ' . $timeValue, $base->getTimezone());
|
|
return $candidate instanceof DateTimeImmutable ? $candidate : $base->setTime(20, 0);
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasDispatchLogTableReady')) {
|
|
function wablasDispatchLogTableReady(): bool
|
|
{
|
|
return wablasTableExists('wablas_dispatch_logs');
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasFetchDispatchLog')) {
|
|
function wablasFetchDispatchLog(string $eventType, string $eventKey): ?array
|
|
{
|
|
if (!wablasDispatchLogTableReady()) {
|
|
return null;
|
|
}
|
|
|
|
$stmt = db()->prepare('SELECT * FROM wablas_dispatch_logs WHERE channel = ? AND event_type = ? AND event_key = ? LIMIT 1');
|
|
$stmt->execute(['wablas', $eventType, $eventKey]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
return is_array($row) ? $row : null;
|
|
}
|
|
}
|
|
|
|
|
|
if (!function_exists('wablasFetchRecentDispatchLogs')) {
|
|
function wablasFetchRecentDispatchLogs(int $limit = 25, array $filters = []): array
|
|
{
|
|
if (!wablasDispatchLogTableReady()) {
|
|
return [];
|
|
}
|
|
|
|
$limit = max(1, min(100, $limit));
|
|
$conditions = ["channel = 'wablas'"];
|
|
$params = [];
|
|
|
|
$status = trim((string) ($filters['status'] ?? ''));
|
|
if ($status !== '' && in_array($status, ['pending', 'sent', 'failed'], true)) {
|
|
$conditions[] = 'status = ?';
|
|
$params[] = $status;
|
|
}
|
|
|
|
$eventType = trim((string) ($filters['event_type'] ?? ''));
|
|
if ($eventType !== '' && in_array($eventType, ['invoice', 'daily_summary', 'manual_test'], true)) {
|
|
$conditions[] = 'event_type = ?';
|
|
$params[] = $eventType;
|
|
}
|
|
|
|
$sql = 'SELECT * FROM wablas_dispatch_logs WHERE ' . implode(' AND ', $conditions) . ' ORDER BY id DESC LIMIT ' . $limit;
|
|
$stmt = db()->prepare($sql);
|
|
$stmt->execute($params);
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasDispatchEventLabel')) {
|
|
function wablasDispatchEventLabel(string $eventType): string
|
|
{
|
|
return match ($eventType) {
|
|
'invoice' => 'Invoice',
|
|
'daily_summary' => 'Daily Summary',
|
|
'manual_test' => 'Manual Test',
|
|
default => ucwords(str_replace('_', ' ', trim($eventType))) ?: 'Dispatch',
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasDispatchStatusMeta')) {
|
|
function wablasDispatchStatusMeta(string $status): array
|
|
{
|
|
return match (trim($status)) {
|
|
'sent' => ['label' => 'Sent', 'class' => 'bg-success bg-opacity-10 text-success'],
|
|
'failed' => ['label' => 'Failed', 'class' => 'bg-danger bg-opacity-10 text-danger'],
|
|
default => ['label' => 'Pending', 'class' => 'bg-warning bg-opacity-10 text-warning'],
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasQueueDispatch')) {
|
|
function wablasQueueDispatch(string $eventType, string $eventKey, DateTimeImmutable $scheduledFor, array $payload, int $recipientCount): bool
|
|
{
|
|
if (!wablasDispatchLogTableReady()) {
|
|
return false;
|
|
}
|
|
|
|
$stmt = db()->prepare(
|
|
'INSERT INTO wablas_dispatch_logs (
|
|
channel, event_type, event_key, scheduled_for, last_attempt_at, attempt_count, recipient_count, status, request_payload, response_payload, error_message
|
|
) VALUES (
|
|
?, ?, ?, ?, NULL, 0, ?, ?, ?, NULL, NULL
|
|
) ON DUPLICATE KEY UPDATE
|
|
scheduled_for = VALUES(scheduled_for),
|
|
last_attempt_at = NULL,
|
|
attempt_count = 0,
|
|
recipient_count = VALUES(recipient_count),
|
|
status = VALUES(status),
|
|
request_payload = VALUES(request_payload),
|
|
response_payload = NULL,
|
|
error_message = NULL,
|
|
updated_at = CURRENT_TIMESTAMP'
|
|
);
|
|
|
|
return $stmt->execute([
|
|
'wablas',
|
|
$eventType,
|
|
$eventKey,
|
|
$scheduledFor->format('Y-m-d H:i:s'),
|
|
max(0, $recipientCount),
|
|
'pending',
|
|
wablasJsonEncode($payload),
|
|
]);
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasFetchDueDispatches')) {
|
|
function wablasFetchDueDispatches(int $limit = 20): array
|
|
{
|
|
if (!wablasDispatchLogTableReady()) {
|
|
return [];
|
|
}
|
|
|
|
$limit = max(1, min(100, $limit));
|
|
$stmt = db()->prepare(
|
|
"SELECT *
|
|
FROM wablas_dispatch_logs
|
|
WHERE channel = 'wablas'
|
|
AND status IN ('pending', 'failed')
|
|
AND attempt_count < 5
|
|
AND (scheduled_for IS NULL OR scheduled_for <= ?)
|
|
ORDER BY scheduled_for ASC, id ASC
|
|
LIMIT $limit"
|
|
);
|
|
$stmt->execute([wablasNow()->format('Y-m-d H:i:s')]);
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasMarkDispatchResult')) {
|
|
function wablasMarkDispatchResult(int $id, string $status, array $response = [], ?string $errorMessage = null): void
|
|
{
|
|
if (!wablasDispatchLogTableReady() || $id <= 0) {
|
|
return;
|
|
}
|
|
|
|
$stmt = db()->prepare(
|
|
'UPDATE wablas_dispatch_logs
|
|
SET status = ?,
|
|
response_payload = ?,
|
|
error_message = ?,
|
|
last_attempt_at = NOW(),
|
|
attempt_count = attempt_count + 1,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?'
|
|
);
|
|
$stmt->execute([
|
|
$status,
|
|
wablasJsonEncode($response),
|
|
$errorMessage !== null ? substr($errorMessage, 0, 4000) : null,
|
|
$id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasApiConfig')) {
|
|
function wablasApiConfig(): array
|
|
{
|
|
return wablasSettingsMap(['wablas_api_url', 'wablas_token', 'wablas_security_key']);
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasApiReady')) {
|
|
function wablasApiReady(): bool
|
|
{
|
|
$config = wablasApiConfig();
|
|
return trim((string) ($config['wablas_api_url'] ?? '')) !== ''
|
|
&& trim((string) ($config['wablas_token'] ?? '')) !== ''
|
|
&& trim((string) ($config['wablas_security_key'] ?? '')) !== '';
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasEndpointUrl')) {
|
|
function wablasEndpointUrl(string $path): string
|
|
{
|
|
$base = trim((string) (wablasSettingValue('wablas_api_url', '') ?? ''));
|
|
if ($base === '') {
|
|
return '';
|
|
}
|
|
|
|
$base = rtrim($base, '/');
|
|
$base = preg_replace('#/api/.*$#i', '', $base) ?? $base;
|
|
return $base . '/' . ltrim($path, '/');
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasSendText')) {
|
|
function wablasSendText(array $numbers, string $message, array $options = []): array
|
|
{
|
|
$numbers = array_values(array_filter(array_map(static function ($value): string {
|
|
return trim((string) $value);
|
|
}, $numbers), static function (string $value): bool {
|
|
return $value !== '';
|
|
}));
|
|
$message = trim($message);
|
|
|
|
if ($numbers === []) {
|
|
return ['success' => false, 'error' => 'Missing recipient numbers.'];
|
|
}
|
|
if ($message === '') {
|
|
return ['success' => false, 'error' => 'Message body is empty.'];
|
|
}
|
|
if (!wablasApiReady()) {
|
|
return ['success' => false, 'error' => 'Wablas API is not fully configured.'];
|
|
}
|
|
|
|
$config = wablasApiConfig();
|
|
$auth = trim((string) ($config['wablas_token'] ?? '')) . '.' . trim((string) ($config['wablas_security_key'] ?? ''));
|
|
$endpoint = wablasEndpointUrl('/api/v2/send-message');
|
|
if ($endpoint === '') {
|
|
return ['success' => false, 'error' => 'Wablas API URL is empty.'];
|
|
}
|
|
|
|
$payload = ['data' => []];
|
|
$refId = trim((string) ($options['ref_id'] ?? ''));
|
|
$source = trim((string) ($options['source'] ?? 'admin-panel'));
|
|
|
|
foreach ($numbers as $index => $phone) {
|
|
$item = [
|
|
'phone' => $phone,
|
|
'message' => $message,
|
|
'isGroup' => false,
|
|
];
|
|
if ($refId !== '') {
|
|
$item['ref_id'] = count($numbers) > 1 ? ($refId . '-' . ($index + 1)) : $refId;
|
|
}
|
|
if ($source !== '') {
|
|
$item['source'] = $source;
|
|
}
|
|
$payload['data'][] = $item;
|
|
}
|
|
|
|
$jsonPayload = wablasJsonEncode($payload);
|
|
$ch = curl_init($endpoint);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: ' . $auth,
|
|
'Content-Type: application/json',
|
|
'Accept: application/json',
|
|
],
|
|
CURLOPT_POSTFIELDS => $jsonPayload,
|
|
CURLOPT_CONNECTTIMEOUT => 10,
|
|
CURLOPT_TIMEOUT => 30,
|
|
]);
|
|
|
|
$rawResponse = curl_exec($ch);
|
|
$curlError = curl_error($ch);
|
|
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($rawResponse === false) {
|
|
return [
|
|
'success' => false,
|
|
'error' => $curlError !== '' ? $curlError : 'Unknown cURL error.',
|
|
'http_code' => $httpCode,
|
|
'payload' => $payload,
|
|
];
|
|
}
|
|
|
|
$decoded = json_decode($rawResponse, true);
|
|
$statusValue = is_array($decoded)
|
|
? ($decoded['status'] ?? $decoded['success'] ?? null)
|
|
: null;
|
|
$success = $httpCode >= 200 && $httpCode < 300 && (
|
|
$statusValue === true
|
|
|| $statusValue === 1
|
|
|| $statusValue === '1'
|
|
|| $statusValue === 'true'
|
|
|| $statusValue === null
|
|
);
|
|
|
|
return [
|
|
'success' => $success,
|
|
'error' => $success ? null : (is_array($decoded) ? ((string) ($decoded['message'] ?? $decoded['error'] ?? 'Wablas request failed.')) : ('HTTP ' . $httpCode)),
|
|
'http_code' => $httpCode,
|
|
'payload' => $payload,
|
|
'response' => is_array($decoded) ? $decoded : ['raw' => (string) $rawResponse],
|
|
];
|
|
}
|
|
}
|
|
|
|
|
|
if (!function_exists('wablasSendManualTest')) {
|
|
function wablasSendManualTest(array $numbers, string $message): array
|
|
{
|
|
$numbers = array_values(array_filter(array_map(static function ($value): string {
|
|
return trim((string) $value);
|
|
}, $numbers), static function (string $value): bool {
|
|
return $value !== '';
|
|
}));
|
|
$message = trim($message);
|
|
|
|
if ($numbers === []) {
|
|
return ['success' => false, 'error' => 'Missing recipient numbers.'];
|
|
}
|
|
if ($message === '') {
|
|
return ['success' => false, 'error' => 'Message body is empty.'];
|
|
}
|
|
|
|
$now = wablasNow();
|
|
try {
|
|
$suffix = bin2hex(random_bytes(4));
|
|
} catch (Throwable $e) {
|
|
$suffix = substr(str_replace('.', '', uniqid('', true)), -8);
|
|
}
|
|
|
|
$eventKey = $now->format('YmdHis') . '-' . $suffix;
|
|
$payload = [
|
|
'numbers' => $numbers,
|
|
'message' => $message,
|
|
'ref_id' => 'manual-test-' . $eventKey,
|
|
'source' => 'settings-manual-test',
|
|
'meta' => [
|
|
'manual' => true,
|
|
],
|
|
];
|
|
|
|
$logId = 0;
|
|
if (wablasDispatchLogTableReady()) {
|
|
$queued = wablasQueueDispatch('manual_test', $eventKey, $now, $payload, count($numbers));
|
|
if ($queued) {
|
|
$log = wablasFetchDispatchLog('manual_test', $eventKey);
|
|
$logId = (int) ($log['id'] ?? 0);
|
|
}
|
|
}
|
|
|
|
$result = wablasSendText($numbers, $message, [
|
|
'ref_id' => 'manual-test-' . $eventKey,
|
|
'source' => 'settings-manual-test',
|
|
]);
|
|
|
|
if ($logId > 0) {
|
|
wablasMarkDispatchResult(
|
|
$logId,
|
|
!empty($result['success']) ? 'sent' : 'failed',
|
|
$result,
|
|
!empty($result['success']) ? null : (string) ($result['error'] ?? 'Unknown Wablas error.')
|
|
);
|
|
}
|
|
|
|
return [
|
|
'success' => !empty($result['success']),
|
|
'event_key' => $eventKey,
|
|
'log_id' => $logId,
|
|
'recipient_count' => count($numbers),
|
|
'http_code' => (int) ($result['http_code'] ?? 0),
|
|
'error' => $result['error'] ?? null,
|
|
'payload' => $result['payload'] ?? $payload,
|
|
'response' => $result['response'] ?? [],
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasQueueInvoiceNotification')) {
|
|
function wablasQueueInvoiceNotification(int $invoiceId): array
|
|
{
|
|
if ($invoiceId <= 0) {
|
|
return ['success' => false, 'error' => 'Invalid invoice ID.'];
|
|
}
|
|
if (!wablasSettingEnabled('wablas_enabled') || !wablasSettingEnabled('wablas_invoice_enabled')) {
|
|
return ['success' => false, 'skipped' => true, 'reason' => 'disabled', 'notice' => ''];
|
|
}
|
|
if (!wablasDispatchLogTableReady()) {
|
|
return ['success' => false, 'error' => 'Wablas dispatch log table is not available.'];
|
|
}
|
|
|
|
$numbers = wablasRecipientNumbersFromSetting('wablas_invoice_numbers');
|
|
if ($numbers === []) {
|
|
return ['success' => false, 'skipped' => true, 'reason' => 'no_recipients', 'notice' => ' WhatsApp skipped: add invoice recipient numbers in Settings.'];
|
|
}
|
|
|
|
$context = wablasBuildInvoiceContext($invoiceId, ['base_url' => wablasCurrentBaseUrl()]);
|
|
if ($context === null) {
|
|
return ['success' => false, 'error' => 'Invoice not found.'];
|
|
}
|
|
|
|
$eventType = 'invoice';
|
|
$eventKey = 'sale:' . $invoiceId;
|
|
$existing = wablasFetchDispatchLog($eventType, $eventKey);
|
|
if ($existing !== null && in_array((string) ($existing['status'] ?? ''), ['pending', 'sent'], true)) {
|
|
return [
|
|
'success' => true,
|
|
'queued' => (string) ($existing['status'] ?? '') === 'pending',
|
|
'skipped' => (string) ($existing['status'] ?? '') === 'sent',
|
|
'reason' => 'already_exists',
|
|
'scheduled_for' => (string) ($existing['scheduled_for'] ?? ''),
|
|
'notice' => '',
|
|
];
|
|
}
|
|
|
|
$template = trim((string) (wablasSettingValue('wablas_invoice_template', wablasDefaultInvoiceTemplate()) ?? wablasDefaultInvoiceTemplate()));
|
|
$message = wablasRenderTemplate($template !== '' ? $template : wablasDefaultInvoiceTemplate(), $context);
|
|
if ($message === '') {
|
|
return ['success' => false, 'skipped' => true, 'reason' => 'empty_message', 'notice' => ''];
|
|
}
|
|
|
|
$scheduledFor = wablasInvoiceScheduledFor(wablasSettingValue('wablas_invoice_time', ''), wablasNow());
|
|
$payload = [
|
|
'numbers' => $numbers,
|
|
'message' => $message,
|
|
'ref_id' => 'invoice-' . $invoiceId,
|
|
'source' => 'admin-panel',
|
|
'meta' => [
|
|
'invoice_id' => $invoiceId,
|
|
'invoice_no' => $context['invoice_no'] ?? '',
|
|
'customer_name' => $context['customer_name'] ?? '',
|
|
],
|
|
];
|
|
|
|
$queued = wablasQueueDispatch($eventType, $eventKey, $scheduledFor, $payload, count($numbers));
|
|
if (!$queued) {
|
|
return ['success' => false, 'error' => 'Unable to queue the invoice notification.'];
|
|
}
|
|
|
|
$notice = ' WhatsApp queued for ' . $scheduledFor->format('Y-m-d H:i') . '.';
|
|
if ($scheduledFor->format('Y-m-d H:i') === wablasNow()->format('Y-m-d H:i')) {
|
|
$notice = ' WhatsApp queued for dispatch.';
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'queued' => true,
|
|
'event_key' => $eventKey,
|
|
'scheduled_for' => $scheduledFor->format('Y-m-d H:i:s'),
|
|
'scheduled_for_label' => $scheduledFor->format('Y-m-d H:i'),
|
|
'notice' => $notice,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasQueueDailySummaryIfDue')) {
|
|
function wablasQueueDailySummaryIfDue(?DateTimeImmutable $now = null): array
|
|
{
|
|
$now = $now ?? wablasNow();
|
|
|
|
if (!wablasSettingEnabled('wablas_enabled') || !wablasSettingEnabled('wablas_daily_summary_enabled')) {
|
|
return ['success' => false, 'skipped' => true, 'reason' => 'disabled'];
|
|
}
|
|
if (!wablasDispatchLogTableReady()) {
|
|
return ['success' => false, 'error' => 'Wablas dispatch log table is not available.'];
|
|
}
|
|
|
|
$dueAt = wablasDailySummaryDueAt(wablasSettingValue('wablas_daily_summary_time', '20:00'), $now);
|
|
if ($now < $dueAt) {
|
|
return [
|
|
'success' => false,
|
|
'skipped' => true,
|
|
'reason' => 'not_due',
|
|
'scheduled_for' => $dueAt->format('Y-m-d H:i:s'),
|
|
];
|
|
}
|
|
|
|
$numbers = wablasRecipientNumbersFromSetting('wablas_daily_summary_numbers');
|
|
if ($numbers === []) {
|
|
return ['success' => false, 'skipped' => true, 'reason' => 'no_recipients'];
|
|
}
|
|
|
|
$reportDate = $now->format('Y-m-d');
|
|
$eventType = 'daily_summary';
|
|
$eventKey = $reportDate;
|
|
$existing = wablasFetchDispatchLog($eventType, $eventKey);
|
|
if ($existing !== null && in_array((string) ($existing['status'] ?? ''), ['pending', 'sent', 'failed'], true)) {
|
|
return [
|
|
'success' => true,
|
|
'queued' => (string) ($existing['status'] ?? '') === 'pending',
|
|
'skipped' => (string) ($existing['status'] ?? '') === 'sent',
|
|
'reason' => 'already_exists',
|
|
'status' => (string) ($existing['status'] ?? ''),
|
|
];
|
|
}
|
|
|
|
$context = wablasBuildDailySummaryContext($reportDate, $now);
|
|
$template = trim((string) (wablasSettingValue('wablas_daily_summary_template', wablasDefaultSummaryTemplate()) ?? wablasDefaultSummaryTemplate()));
|
|
$message = wablasRenderTemplate($template !== '' ? $template : wablasDefaultSummaryTemplate(), $context);
|
|
if ($message === '') {
|
|
return ['success' => false, 'skipped' => true, 'reason' => 'empty_message'];
|
|
}
|
|
|
|
$payload = [
|
|
'numbers' => $numbers,
|
|
'message' => $message,
|
|
'ref_id' => 'daily-summary-' . $reportDate,
|
|
'source' => 'admin-panel',
|
|
'meta' => [
|
|
'report_date' => $reportDate,
|
|
],
|
|
];
|
|
|
|
$queued = wablasQueueDispatch($eventType, $eventKey, $dueAt, $payload, count($numbers));
|
|
if (!$queued) {
|
|
return ['success' => false, 'error' => 'Unable to queue the daily summary.'];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'queued' => true,
|
|
'event_key' => $eventKey,
|
|
'scheduled_for' => $dueAt->format('Y-m-d H:i:s'),
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!function_exists('wablasProcessDueDispatches')) {
|
|
function wablasProcessDueDispatches(int $limit = 20): array
|
|
{
|
|
$summary = [
|
|
'checked' => 0,
|
|
'sent' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'messages' => [],
|
|
];
|
|
|
|
if (!wablasDispatchLogTableReady()) {
|
|
$summary['messages'][] = 'Dispatch log table is missing.';
|
|
return $summary;
|
|
}
|
|
|
|
if (!wablasSettingEnabled('wablas_enabled')) {
|
|
$summary['messages'][] = 'Wablas automation is disabled from Settings.';
|
|
return $summary;
|
|
}
|
|
|
|
if (!wablasApiReady()) {
|
|
$summary['messages'][] = 'Wablas API URL, token, or security key is missing.';
|
|
return $summary;
|
|
}
|
|
|
|
foreach (wablasFetchDueDispatches($limit) as $row) {
|
|
$summary['checked']++;
|
|
$eventType = (string) ($row['event_type'] ?? '');
|
|
if ($eventType === 'invoice' && !wablasSettingEnabled('wablas_invoice_enabled')) {
|
|
$summary['skipped']++;
|
|
$summary['messages'][] = 'Invoice automation is disabled; queued invoice ' . ((string) ($row['event_key'] ?? '')) . ' remains pending.';
|
|
continue;
|
|
}
|
|
if ($eventType === 'daily_summary' && !wablasSettingEnabled('wablas_daily_summary_enabled')) {
|
|
$summary['skipped']++;
|
|
$summary['messages'][] = 'Daily summary automation is disabled; ' . ((string) ($row['event_key'] ?? '')) . ' remains pending.';
|
|
continue;
|
|
}
|
|
|
|
$payload = wablasJsonDecode((string) ($row['request_payload'] ?? ''));
|
|
$numbers = isset($payload['numbers']) && is_array($payload['numbers']) ? $payload['numbers'] : [];
|
|
$message = trim((string) ($payload['message'] ?? ''));
|
|
if ($numbers === [] || $message === '') {
|
|
wablasMarkDispatchResult((int) ($row['id'] ?? 0), 'failed', ['payload' => $payload], 'Queued payload is incomplete.');
|
|
$summary['failed']++;
|
|
$summary['messages'][] = 'Failed ' . $eventType . ' ' . ((string) ($row['event_key'] ?? '')) . ': queued payload is incomplete.';
|
|
continue;
|
|
}
|
|
|
|
$result = wablasSendText($numbers, $message, [
|
|
'ref_id' => (string) ($payload['ref_id'] ?? ($eventType . '-' . ($row['event_key'] ?? ''))),
|
|
'source' => (string) ($payload['source'] ?? 'admin-panel'),
|
|
]);
|
|
|
|
if (!empty($result['success'])) {
|
|
wablasMarkDispatchResult((int) ($row['id'] ?? 0), 'sent', $result, null);
|
|
$summary['sent']++;
|
|
$summary['messages'][] = 'Sent ' . $eventType . ' ' . ((string) ($row['event_key'] ?? '')) . ' to ' . count($numbers) . ' recipient(s).';
|
|
} else {
|
|
wablasMarkDispatchResult((int) ($row['id'] ?? 0), 'failed', $result, (string) ($result['error'] ?? 'Unknown Wablas error.'));
|
|
$summary['failed']++;
|
|
$summary['messages'][] = 'Failed ' . $eventType . ' ' . ((string) ($row['event_key'] ?? '')) . ': ' . ((string) ($result['error'] ?? 'Unknown Wablas error.'));
|
|
}
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
}
|