diff --git a/cron_wablas.php b/cron_wablas.php new file mode 100644 index 0000000..2b5922a --- /dev/null +++ b/cron_wablas.php @@ -0,0 +1,45 @@ +format('Y-m-d H:i:s') . "] Starting Wablas automation run +"; + +$dailyQueue = wablasQueueDailySummaryIfDue($now); +if (!empty($dailyQueue['queued'])) { + echo 'Queued daily summary for ' . (($dailyQueue['scheduled_for'] ?? $now->format('Y-m-d H:i:s'))) . " +"; +} elseif (!empty($dailyQueue['reason']) && ($dailyQueue['reason'] ?? '') !== 'not_due') { + echo 'Daily summary: ' . $dailyQueue['reason'] . " +"; +} + +$processed = wablasProcessDueDispatches(25); +echo 'Checked: ' . (int) ($processed['checked'] ?? 0) + . ' | Sent: ' . (int) ($processed['sent'] ?? 0) + . ' | Failed: ' . (int) ($processed['failed'] ?? 0) + . ' | Skipped: ' . (int) ($processed['skipped'] ?? 0) + . " +"; + +foreach (($processed['messages'] ?? []) as $message) { + echo '- ' . $message . " +"; +} diff --git a/db/migrations/20260503_zzz_wablas_dispatch_logs.sql b/db/migrations/20260503_zzz_wablas_dispatch_logs.sql new file mode 100644 index 0000000..0587b19 --- /dev/null +++ b/db/migrations/20260503_zzz_wablas_dispatch_logs.sql @@ -0,0 +1,21 @@ +-- Wablas automation queue and delivery log +CREATE TABLE IF NOT EXISTS `wablas_dispatch_logs` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `channel` varchar(50) NOT NULL DEFAULT 'wablas', + `event_type` varchar(50) NOT NULL, + `event_key` varchar(190) NOT NULL, + `scheduled_for` datetime DEFAULT NULL, + `last_attempt_at` datetime DEFAULT NULL, + `attempt_count` int(11) NOT NULL DEFAULT 0, + `recipient_count` int(11) NOT NULL DEFAULT 0, + `status` varchar(50) NOT NULL DEFAULT 'pending', + `request_payload` longtext DEFAULT NULL, + `response_payload` longtext DEFAULT NULL, + `error_message` text DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_wablas_dispatch_event` (`channel`, `event_type`, `event_key`), + KEY `idx_wablas_dispatch_status` (`status`, `scheduled_for`), + KEY `idx_wablas_dispatch_attempts` (`attempt_count`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/includes/wablas_helper.php b/includes/wablas_helper.php new file mode 100644 index 0000000..0a7f498 --- /dev/null +++ b/includes/wablas_helper.php @@ -0,0 +1,1090 @@ +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; + } +} diff --git a/index.php b/index.php index 080637c..bdf4f44 100644 --- a/index.php +++ b/index.php @@ -107,6 +107,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { require_once __DIR__ . '/db/config.php'; require_once __DIR__ . '/includes/SimpleXLSX.php'; require_once __DIR__ . '/includes/stock_helper.php'; +require_once __DIR__ . '/includes/wablas_helper.php'; require_once __DIR__ . '/db/BackupService.php'; // Helper for current outlet @@ -10446,39 +10447,7 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; -
Monitor system activity and errors
-" . htmlspecialchars((string)$lines) . "
No accessible log files found.
Monitor file logs plus recent WhatsApp dispatch results
+No Wablas activity has been logged yet.
+Create a sales invoice, send a test WhatsApp from Settings, or run the queue to see entries here.
+| Event | +Status | +Schedule / Attempt | +Recipients / Message | +Response | +
|---|---|---|---|---|
|
+ = htmlspecialchars($eventLabel !== '' ? $eventLabel : 'Dispatch') ?>
+ = htmlspecialchars((string) ($row['event_key'] ?? '')) ?>
+ Created:
+ = htmlspecialchars((string) ($row['created_at'] ?? '—')) ?>
+
+ |
+
+ = htmlspecialchars($statusMeta['label']) ?>
+
+ Attempts:
+ = (int) ($row['attempt_count'] ?? 0) ?>
+
+ |
+
+ Scheduled: = htmlspecialchars((string) ($row['scheduled_for'] ?? '—')) ?>
+ Last attempt: = htmlspecialchars((string) ($row['last_attempt_at'] ?? '—')) ?>
+ |
+
+
+ Recipients:
+ = htmlspecialchars($numbersPreview !== '' ? $numbersPreview : '—') ?>
+
+
+ Message:
+ = htmlspecialchars($messagePreview !== '' ? $messagePreview : '—') ?>
+
+ |
+
+
+ HTTP: = htmlspecialchars($httpCode) ?>
+
+
+ = htmlspecialchars($wablasPreview($errorMessage, 160)) ?>
+
+ No error recorded
+
+ |
+
Latest lines from the app log files available in this workspace
+" . htmlspecialchars((string) $lines) . "
No accessible log files found.
{invoice_no} {customer_name} {grand_total} {invoice_url} {company_name}{invoice_no} {customer_name} {grand_total} {invoice_url} {company_name} {invoice_date} {due_date} {status} {outlet_name}{report_date} {invoice_count} {sales_total} {cash_total} {card_total} {company_name}{report_date} {report_time} {invoice_count} {sales_total} {paid_total} {cash_total} {card_total} {due_total} {company_name}Use these controls to send a test WhatsApp or run the due queue immediately without waiting for cron.
+This checks whether today’s daily summary is already due, then processes pending/failed Wablas jobs immediately.
+