thawani payments

This commit is contained in:
Flatlogic Bot 2026-04-22 04:37:48 +00:00
parent 9b0dd7d971
commit 8cadf8aa40
8 changed files with 674 additions and 26 deletions

View File

@ -17,6 +17,15 @@ $name = trim($input['name'] ?? '');
$phoneInput = trim($input['phone'] ?? '');
$phone = normalize_oman_phone($phoneInput);
$address = trim($input['address'] ?? '');
$paymentMethod = trim((string) ($input['payment_method'] ?? 'pay_later'));
if (!in_array($paymentMethod, ['pay_later', 'pay_online'], true)) {
echo json_encode(['success' => false, 'error' => 'Invalid payment method']);
exit;
}
if ($paymentMethod === 'pay_online' && !thawani_is_configured()) {
echo json_encode(['success' => false, 'error' => 'Thawani payment is not configured']);
exit;
}
if ($name === '' || $phoneInput === '' || $address === '') {
echo json_encode(['success' => false, 'error' => 'Missing customer details']);
@ -75,7 +84,10 @@ $totalAmount = $subtotal + $totalVat;
try {
$db->beginTransaction();
$stmt = $db->prepare("INSERT INTO online_orders (customer_name, customer_phone, customer_address, items_json, subtotal, vat_amount, total_amount) VALUES (?, ?, ?, ?, ?, ?, ?)");
$paymentGateway = $paymentMethod === 'pay_online' ? 'thawani' : null;
$paymentStatus = $paymentMethod === 'pay_online' ? 'pending' : 'unpaid';
$stmt = $db->prepare("INSERT INTO online_orders (customer_name, customer_phone, customer_address, items_json, subtotal, vat_amount, total_amount, payment_method, payment_gateway, payment_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([
$name,
$phone,
@ -83,28 +95,37 @@ try {
json_encode($processedItems, JSON_UNESCAPED_UNICODE),
$subtotal,
$totalVat,
$totalAmount
$totalAmount,
$paymentMethod,
$paymentGateway,
$paymentStatus
]);
$orderId = (int) $db->lastInsertId();
sync_online_order_stock_reservation([], 'rejected', $processedItems, 'pending');
$db->commit();
// Optional: send telegram and WhatsApp notifications if configured
try {
$orderData = [
'id' => $orderId,
$checkoutUrl = null;
if ($paymentMethod === 'pay_online') {
$thawaniResult = thawani_create_checkout_session($orderId, [
'customer_name' => $name,
'customer_phone' => $phone,
'customer_address' => $address,
'items' => $processedItems,
'subtotal' => $subtotal,
'vat_amount' => $totalVat,
'total_amount' => $totalAmount,
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
];
]);
if (empty($thawaniResult['success'])) {
throw new RuntimeException((string) ($thawaniResult['error'] ?? 'Unable to create Thawani session'));
}
$checkoutUrl = (string) ($thawaniResult['checkout_url'] ?? '');
$sessionId = (string) ($thawaniResult['session_id'] ?? '');
$updateStmt = $db->prepare("UPDATE online_orders SET gateway_session_id = ? WHERE id = ?");
$updateStmt->execute([$sessionId, $orderId]);
}
$db->commit();
// Optional: send Telegram admin notice and customer WhatsApp notification.
try {
$msg = "🛒 *New Online Order #{$orderId}*
";
@ -136,15 +157,19 @@ try {
$context = stream_context_create($options);
@file_get_contents($url, false, $context);
}
if (wablas_is_configured()) {
wablas_notify_online_order($orderData, 'created');
}
} catch (Exception $e) {
// ignore notification errors
} catch (Throwable $e) {
error_log('Telegram notify failed for online order #' . $orderId . ': ' . $e->getMessage());
}
echo json_encode(['success' => true]);
if ($paymentMethod === 'pay_later' && wablas_is_configured()) {
try {
wablas_notify_online_order_by_id($orderId, 'created');
} catch (Throwable $e) {
error_log('Customer WhatsApp notify failed for online order #' . $orderId . ': ' . $e->getMessage());
}
}
echo json_encode(['success' => true, 'redirect_url' => $checkoutUrl]);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();

View File

@ -19,7 +19,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'wablas_daily_auto_send', 'wablas_daily_auto_time', 'wablas_daily_auto_last_date',
'wablas_template_invoice', 'wablas_template_daily_report',
'wablas_template_created', 'wablas_template_pending', 'wablas_template_accepted', 'wablas_template_completed', 'wablas_template_rejected',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_secure', 'mail_from', 'mail_from_name'
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_secure', 'mail_from', 'mail_from_name',
'thawani_enabled', 'thawani_mode', 'thawani_publishable_key', 'thawani_secret_key', 'thawani_success_url', 'thawani_cancel_url'
];
$stmt = $pdo->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)");
@ -51,6 +52,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_POST['wablas_daily_auto_send'])) {
$_POST['wablas_daily_auto_send'] = '0';
}
if (!isset($_POST['thawani_enabled'])) {
$_POST['thawani_enabled'] = '0';
}
$thawaniMode = strtolower(trim((string) ($_POST['thawani_mode'] ?? 'sandbox')));
$_POST['thawani_mode'] = in_array($thawaniMode, ['sandbox', 'live'], true) ? $thawaniMode : 'sandbox';
unset($_POST['wablas_daily_auto_last_date']);
foreach ($keys as $key) {

View File

@ -385,6 +385,14 @@ require __DIR__ . '/includes/header.php';
<option value="rejected" <?= $editOrder['status'] === 'rejected' ? 'selected' : '' ?>><?= h(tr('مرفوض', 'Rejected')) ?></option>
</select>
</div>
<div class="border rounded-3 p-3 bg-light mb-4">
<div class="small text-muted mb-2"><?= h(tr('الدفع', 'Payment')) ?></div>
<div class="fw-semibold"><?= h(online_payment_method_label((string) ($editOrder['payment_method'] ?? 'pay_later'))) ?></div>
<div class="mt-2"><span class="badge <?= h(online_payment_status_badge_class((string) ($editOrder['payment_status'] ?? 'unpaid'))) ?>"><?= h(online_payment_status_label((string) ($editOrder['payment_status'] ?? 'unpaid'))) ?></span></div>
<?php if (!empty($editOrder['gateway_session_id'])): ?>
<div class="small text-muted mt-2"><?= h(tr('جلسة ثواني:', 'Thawani session:')) ?> <?= h($editOrder['gateway_session_id']) ?></div>
<?php endif; ?>
</div>
<!-- Summary -->
<div class="totals-box mb-4">

View File

@ -121,6 +121,41 @@ try {
@file_put_contents($flagFileV7, '1');
}
$flagFileV8 = sys_get_temp_dir() . '/.schema_migrated_v8_' . md5(__DIR__);
if (!file_exists($flagFileV8)) {
$pdo = db();
$hasOnlineOrdersTable = (bool) $pdo->query("SHOW TABLES LIKE 'online_orders'")->fetchColumn();
if ($hasOnlineOrdersTable) {
$requiredColumns = [
'payment_method' => "ALTER TABLE online_orders ADD COLUMN payment_method varchar(30) NOT NULL DEFAULT 'pay_later' AFTER total_amount",
'payment_gateway' => "ALTER TABLE online_orders ADD COLUMN payment_gateway varchar(30) DEFAULT NULL AFTER payment_method",
'payment_status' => "ALTER TABLE online_orders ADD COLUMN payment_status varchar(20) NOT NULL DEFAULT 'unpaid' AFTER payment_gateway",
'gateway_session_id' => "ALTER TABLE online_orders ADD COLUMN gateway_session_id varchar(120) DEFAULT NULL AFTER payment_status",
'gateway_transaction_id' => "ALTER TABLE online_orders ADD COLUMN gateway_transaction_id varchar(120) DEFAULT NULL AFTER gateway_session_id",
'paid_at' => "ALTER TABLE online_orders ADD COLUMN paid_at datetime DEFAULT NULL AFTER gateway_transaction_id",
];
foreach ($requiredColumns as $column => $sql) {
$exists = $pdo->query("SHOW COLUMNS FROM online_orders LIKE " . $pdo->quote($column))->fetchColumn();
if (!$exists) {
$pdo->exec($sql);
}
}
$pdo->exec("UPDATE online_orders SET payment_method = 'pay_later' WHERE payment_method IS NULL OR payment_method = ''");
$pdo->exec("UPDATE online_orders SET payment_status = CASE WHEN payment_method = 'pay_online' THEN 'pending' ELSE 'unpaid' END WHERE payment_status IS NULL OR payment_status = ''");
}
$pdo->exec("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES
('thawani_enabled', '0'),
('thawani_mode', 'sandbox'),
('thawani_publishable_key', ''),
('thawani_secret_key', ''),
('thawani_success_url', ''),
('thawani_cancel_url', '')");
@file_put_contents($flagFileV8, '1');
}
} catch (\Throwable $e) {}
@ -308,6 +343,73 @@ function url_for(string $path, array $params = []): string
return $path . ($query ? ('?' . $query) : '');
}
function request_scheme(): string
{
$https = (string) ($_SERVER['HTTPS'] ?? '');
if ($https !== '' && strtolower($https) !== 'off') {
return 'https';
}
$forwardedProto = trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
if ($forwardedProto !== '') {
return strtolower(explode(',', $forwardedProto)[0]);
}
return 'http';
}
function app_base_url(): string
{
$host = trim((string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $_SERVER['HTTP_HOST'] ?? ''));
if ($host === '') {
$host = '127.0.0.1';
}
return request_scheme() . '://' . $host;
}
function append_query_params(string $url, array $params): string
{
$parts = parse_url($url);
$queryParams = [];
if (!empty($parts['query'])) {
parse_str($parts['query'], $queryParams);
}
foreach ($params as $key => $value) {
if ($value === null || $value === '') {
continue;
}
$queryParams[$key] = $value;
}
$rebuilt = '';
if (!empty($parts['scheme'])) {
$rebuilt .= $parts['scheme'] . '://';
}
if (!empty($parts['user'])) {
$rebuilt .= $parts['user'];
if (!empty($parts['pass'])) {
$rebuilt .= ':' . $parts['pass'];
}
$rebuilt .= '@';
}
if (!empty($parts['host'])) {
$rebuilt .= $parts['host'];
}
if (!empty($parts['port'])) {
$rebuilt .= ':' . $parts['port'];
}
$rebuilt .= $parts['path'] ?? '';
if ($queryParams !== []) {
$rebuilt .= '?' . http_build_query($queryParams);
}
if (!empty($parts['fragment'])) {
$rebuilt .= '#' . $parts['fragment'];
}
return $rebuilt;
}
function redirect_to(string $path, array $params = []): void
{
header('Location: ' . url_for($path, $params));
@ -597,6 +699,236 @@ function payment_status_badge_class(string $status): string
};
}
function online_payment_method_label(string $method): string
{
return match ($method) {
'pay_online' => tr('ادفع أونلاين', 'Pay Online'),
default => tr('ادفع لاحقاً', 'Pay Later'),
};
}
function online_payment_status_label(string $status): string
{
return match ($status) {
'paid' => tr('مدفوع', 'Paid'),
'pending' => tr('بانتظار الدفع', 'Awaiting Payment'),
'failed' => tr('فشل الدفع', 'Payment Failed'),
'cancelled' => tr('تم الإلغاء', 'Cancelled'),
default => tr('غير مدفوع', 'Unpaid'),
};
}
function online_payment_status_badge_class(string $status): string
{
return match ($status) {
'paid' => 'bg-success text-white',
'pending' => 'bg-warning text-dark',
'failed' => 'bg-danger text-white',
'cancelled' => 'bg-secondary text-white',
default => 'bg-danger-subtle text-danger-emphasis',
};
}
function thawani_is_enabled(): bool
{
return (string) get_setting('thawani_enabled', '0') === '1';
}
function thawani_mode(): string
{
$mode = strtolower(trim((string) get_setting('thawani_mode', 'sandbox')));
return in_array($mode, ['sandbox', 'live'], true) ? $mode : 'sandbox';
}
function thawani_checkout_base_url(): string
{
return thawani_mode() === 'live' ? 'https://checkout.thawani.om' : 'https://uatcheckout.thawani.om';
}
function thawani_api_key(): string
{
return trim((string) get_setting('thawani_secret_key', ''));
}
function thawani_publishable_key(): string
{
return trim((string) get_setting('thawani_publishable_key', ''));
}
function thawani_is_configured(): bool
{
return thawani_is_enabled() && thawani_api_key() !== '' && thawani_publishable_key() !== '';
}
function thawani_default_return_url(string $result): string
{
return app_base_url() . '/thawani_return.php?result=' . rawurlencode($result);
}
function thawani_success_url(): string
{
$custom = trim((string) get_setting('thawani_success_url', ''));
return $custom !== '' ? $custom : thawani_default_return_url('success');
}
function thawani_cancel_url(): string
{
$custom = trim((string) get_setting('thawani_cancel_url', ''));
return $custom !== '' ? $custom : thawani_default_return_url('cancel');
}
function thawani_build_products(array $items): array
{
$products = [];
foreach ($items as $item) {
$qty = max(1, (int) ($item['qty'] ?? 0));
$name = trim((string) ($item['name'] ?? $item['name_ar'] ?? $item['sku'] ?? 'Item'));
$lineTotal = (float) ($item['line_total'] ?? 0);
$vatAmount = (float) ($item['vat_amount'] ?? 0);
$unitAmount = (int) round((($lineTotal + $vatAmount) / $qty) * 1000);
$products[] = [
'name' => $name,
'quantity' => $qty,
'unit_amount' => max(1, $unitAmount),
];
}
return $products;
}
function thawani_call(string $method, string $path): array
{
$url = rtrim(thawani_checkout_base_url(), '/') . $path;
$headers = [
'Content-Type: application/json',
'Accept: application/json',
'thawani-api-key: ' . thawani_api_key(),
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($raw === false) {
return ['success' => false, 'status' => $status, 'error' => $error !== '' ? $error : 'Unable to contact Thawani'];
}
$decoded = json_decode($raw, true);
return [
'success' => $status >= 200 && $status < 300,
'status' => $status,
'data' => is_array($decoded) ? $decoded : null,
'raw' => $raw,
'error' => $status >= 200 && $status < 300 ? '' : ('HTTP ' . $status),
];
}
function thawani_create_checkout_session(int $orderId, array $order): array
{
if (!thawani_is_configured()) {
return ['success' => false, 'error' => 'Thawani is not configured'];
}
$payload = [
'client_reference_id' => 'online-order-' . $orderId,
'mode' => 'payment',
'products' => thawani_build_products($order['items'] ?? []),
'success_url' => append_query_params(thawani_success_url(), [
'order_id' => $orderId,
]),
'cancel_url' => append_query_params(thawani_cancel_url(), [
'order_id' => $orderId,
]),
'metadata' => [
'order_id' => (string) $orderId,
'customer_name' => (string) ($order['customer_name'] ?? ''),
'customer_phone' => (string) ($order['customer_phone'] ?? ''),
],
];
$url = rtrim(thawani_checkout_base_url(), '/') . '/api/v1/checkout/session';
$headers = [
'Content-Type: application/json',
'Accept: application/json',
'thawani-api-key: ' . thawani_api_key(),
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($raw === false) {
return ['success' => false, 'status' => $status, 'error' => $error !== '' ? $error : 'Unable to contact Thawani'];
}
$decoded = json_decode($raw, true);
$sessionId = (string) ($decoded['data']['session_id'] ?? $decoded['session_id'] ?? '');
if ($status < 200 || $status >= 300 || $sessionId === '') {
$message = (string) ($decoded['message'] ?? $decoded['description'] ?? ('HTTP ' . $status));
return ['success' => false, 'status' => $status, 'error' => $message, 'data' => $decoded, 'raw' => $raw];
}
return [
'success' => true,
'status' => $status,
'session_id' => $sessionId,
'checkout_url' => rtrim(thawani_checkout_base_url(), '/') . '/pay/' . rawurlencode($sessionId) . '?key=' . rawurlencode(thawani_publishable_key()),
'data' => $decoded,
'raw' => $raw,
];
}
function thawani_retrieve_session(string $sessionId): array
{
if ($sessionId === '') {
return ['success' => false, 'error' => 'Missing session id'];
}
return thawani_call('GET', '/api/v1/checkout/session/' . rawurlencode($sessionId));
}
function thawani_session_paid(array $response): bool
{
$data = (array) ($response['data']['data'] ?? $response['data'] ?? []);
$candidates = [
strtolower((string) ($data['payment_status'] ?? '')),
strtolower((string) ($data['status'] ?? '')),
strtolower((string) ($data['paymentStatus'] ?? '')),
strtolower((string) ($data['paymentStatusDescription'] ?? '')),
];
foreach ($candidates as $candidate) {
if (in_array($candidate, ['paid', 'successful', 'success'], true)) {
return true;
}
}
return false;
}
function thawani_session_transaction_id(array $response): string
{
$data = (array) ($response['data']['data'] ?? $response['data'] ?? []);
return (string) ($data['invoice'] ?? $data['payment_reference'] ?? $data['transaction_id'] ?? $data['reference'] ?? '');
}
function apply_sale_payment(int $saleId, float $paymentAmount, bool $completeOrderWhenPaid = false): array
{
$sale = fetch_sale($saleId);
@ -730,19 +1062,33 @@ function wablas_default_order_template(string $event): string
return match ($event) {
'created' => "مرحباً {customer_name}، تم استلام طلبك رقم #{order_id}.
الحالة: {status_label}
طريقة الدفع: {payment_method_label}
حالة الدفع: {payment_status_label}
الأصناف:
{items_summary}
الإجمالي: {total_amount}
العنوان: {customer_address}
شكراً لتسوقك معنا.",
'pending' => "مرحباً {customer_name}، طلبك رقم #{order_id} ما زال {status_label}.
طريقة الدفع: {payment_method_label}
حالة الدفع: {payment_status_label}
الإجمالي: {total_amount}
سنوافيك بأي تحديث جديد.",
'accepted' => "مرحباً {customer_name}، تم قبول طلبك رقم #{order_id}.
طريقة الدفع: {payment_method_label}
حالة الدفع: {payment_status_label}
الإجمالي: {total_amount}
سنبدأ التجهيز الآن.",
'completed' => "مرحباً {customer_name}، طلبك رقم #{order_id} أصبح {status_label}.
طريقة الدفع: {payment_method_label}
حالة الدفع: {payment_status_label}
الإجمالي: {total_amount}
شكراً لك.",
'rejected' => "مرحباً {customer_name}، نعتذر، تم تحديث طلبك رقم #{order_id} إلى {status_label}.
طريقة الدفع: {payment_method_label}
حالة الدفع: {payment_status_label}
إذا رغبت بالمساعدة تواصل معنا.",
default => "مرحباً {customer_name}، تم تحديث طلبك رقم #{order_id} إلى {status_label}.",
};
@ -788,6 +1134,13 @@ function wablas_order_items_summary(array $order): string
function wablas_order_template_vars(array $order): array
{
$status = (string) ($order['status'] ?? 'pending');
$paymentMethod = (string) ($order['payment_method'] ?? 'pay_later');
$paymentStatus = (string) ($order['payment_status'] ?? ($paymentMethod === 'pay_online' ? 'pending' : 'unpaid'));
$itemsSummary = wablas_order_items_summary($order);
if (trim($itemsSummary) === '') {
$itemsSummary = '-';
}
return [
'order_id' => (string) ($order['id'] ?? ''),
'customer_name' => (string) ($order['customer_name'] ?? ''),
@ -795,11 +1148,15 @@ function wablas_order_template_vars(array $order): array
'customer_address' => (string) ($order['customer_address'] ?? ''),
'status' => $status,
'status_label' => wablas_order_status_label($status),
'payment_method' => $paymentMethod,
'payment_method_label' => wablas_payment_method_label($paymentMethod),
'payment_status' => $paymentStatus,
'payment_status_label' => wablas_payment_status_label($paymentStatus),
'subtotal' => currency((float) ($order['subtotal'] ?? 0)),
'vat_amount' => currency((float) ($order['vat_amount'] ?? 0)),
'total_amount' => currency((float) ($order['total_amount'] ?? 0)),
'created_at' => (string) ($order['created_at'] ?? ''),
'items_summary' => wablas_order_items_summary($order),
'items_summary' => $itemsSummary,
];
}
@ -842,7 +1199,8 @@ function wablas_payment_method_label(string $method): string
'cash' => tr('كاش', 'Cash'),
'card' => tr('بطاقة', 'Card'),
'transfer', 'bank' => tr('تحويل', 'Transfer'),
'pay_later' => tr('آجل', 'Pay later'),
'pay_later' => tr('الدفع لاحقاً', 'Pay later'),
'pay_online' => tr('الدفع أونلاين', 'Pay online'),
default => $method,
};
}
@ -853,6 +1211,9 @@ function wablas_payment_status_label(string $status): string
'paid' => tr('مدفوع', 'Paid'),
'partial' => tr('مدفوع جزئياً', 'Partial'),
'unpaid' => tr('غير مدفوع', 'Unpaid'),
'pending' => tr('بانتظار الدفع', 'Pending payment'),
'failed' => tr('فشل الدفع', 'Payment failed'),
'cancelled' => tr('تم إلغاء الدفع', 'Payment cancelled'),
default => $status,
};
}
@ -1228,6 +1589,34 @@ function wablas_notify_online_order(array $order, string $event): array
return $result;
}
function fetch_online_order(int $orderId): ?array
{
if ($orderId <= 0) {
return null;
}
try {
$stmt = db()->prepare('SELECT * FROM online_orders WHERE id = :id LIMIT 1');
$stmt->bindValue(':id', $orderId, PDO::PARAM_INT);
$stmt->execute();
$order = $stmt->fetch(PDO::FETCH_ASSOC);
return $order ?: null;
} catch (Throwable $e) {
error_log('Failed to fetch online order #' . $orderId . ': ' . $e->getMessage());
return null;
}
}
function wablas_notify_online_order_by_id(int $orderId, string $event): array
{
$order = fetch_online_order($orderId);
if (!$order) {
return ['success' => false, 'error' => 'Online order not found'];
}
return wablas_notify_online_order($order, $event);
}
function ensure_sales_table(): void
{
$sql = "CREATE TABLE IF NOT EXISTS sales_orders (

View File

@ -39,6 +39,11 @@
<?= h(tr('واتساب', 'WhatsApp')) ?>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="settings-payment-tab" data-bs-toggle="tab" data-bs-target="#settings-payment-pane" type="button" role="tab" aria-controls="settings-payment-pane" aria-selected="false">
<?= h(tr('الدفع الإلكتروني', 'Payments')) ?>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="settings-email-tab" data-bs-toggle="tab" data-bs-target="#settings-email-pane" type="button" role="tab" aria-controls="settings-email-pane" aria-selected="false">
<?= h(tr('البريد الإلكتروني', 'Email')) ?>
@ -146,6 +151,51 @@
</div>
</div>
<div class="tab-pane fade" id="settings-payment-pane" role="tabpanel" aria-labelledby="settings-payment-tab" tabindex="0">
<div class="row g-3">
<div class="col-12">
<div class="border rounded-4 p-3 bg-body-tertiary">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch" id="thawaniEnabled" name="thawani_enabled" value="1" <?= thawani_is_enabled() ? 'checked' : '' ?>>
<label class="form-check-label fw-semibold" for="thawaniEnabled"><?= h(tr('تفعيل بوابة ثواني', 'Enable Thawani Gateway')) ?></label>
</div>
<div class="small text-muted"><?= h(tr('فعّلها ليظهر خيارا الدفع أونلاين أو الدفع لاحقاً في طلبات المتجر.', 'Enable it so the online store can offer Pay Online or Pay Later.')) ?></div>
</div>
</div>
<div class="col-md-4">
<label class="form-label"><?= h(tr('البيئة', 'Mode')) ?></label>
<select class="form-select" name="thawani_mode">
<option value="sandbox" <?= get_setting('thawani_mode', 'sandbox') === 'sandbox' ? 'selected' : '' ?>><?= h(tr('تجريبية (Sandbox)', 'Sandbox')) ?></option>
<option value="live" <?= get_setting('thawani_mode') === 'live' ? 'selected' : '' ?>><?= h(tr('حقيقية (Live)', 'Live')) ?></option>
</select>
</div>
<div class="col-md-8">
<label class="form-label"><?= h(tr('المفتاح العام (Publishable Key)', 'Publishable Key')) ?></label>
<input type="text" class="form-control" name="thawani_publishable_key" value="<?= h(get_setting('thawani_publishable_key')) ?>" placeholder="pk_...">
</div>
<div class="col-md-12">
<label class="form-label"><?= h(tr('المفتاح السري (Secret Key)', 'Secret Key')) ?></label>
<input type="password" class="form-control" name="thawani_secret_key" value="<?= h(get_setting('thawani_secret_key')) ?>" placeholder="sk_...">
</div>
<div class="col-md-6">
<label class="form-label"><?= h(tr('رابط نجاح الدفع', 'Success URL')) ?></label>
<input type="url" class="form-control" name="thawani_success_url" value="<?= h(get_setting('thawani_success_url')) ?>" placeholder="<?= h(thawani_default_return_url('success')) ?>">
<div class="form-text"><?= h(tr('اختياري. إذا تركته فارغاً سيستخدم النظام رابط الرجوع الداخلي تلقائياً.', 'Optional. Leave empty to use the built-in return URL automatically.')) ?></div>
</div>
<div class="col-md-6">
<label class="form-label"><?= h(tr('رابط إلغاء الدفع', 'Cancel URL')) ?></label>
<input type="url" class="form-control" name="thawani_cancel_url" value="<?= h(get_setting('thawani_cancel_url')) ?>" placeholder="<?= h(thawani_default_return_url('cancel')) ?>">
<div class="form-text"><?= h(tr('اختياري. يستخدم عندما يُلغى الدفع أو يعود العميل بدون إتمام العملية.', 'Optional. Used when payment is cancelled or the customer returns without completing checkout.')) ?></div>
</div>
<div class="col-12">
<div class="alert alert-info rounded-4 mb-0">
<div class="fw-semibold mb-1"><?= h(tr('معلومة', 'Note')) ?></div>
<div class="small mb-0"><?= h(tr('عند اختيار الدفع أونلاين سيُنشئ النظام جلسة Thawani ويرسل العميل مباشرةً لصفحة الدفع.', 'When Pay Online is selected, the system creates a Thawani session and redirects the customer to the hosted checkout page.')) ?></div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="settings-email-pane" role="tabpanel" aria-labelledby="settings-email-tab" tabindex="0">
<div class="row g-3">
<div class="col-md-12">

View File

@ -152,6 +152,7 @@ require __DIR__ . '/includes/header.php';
<th><?= h(tr('العميل', 'Customer')) ?></th>
<th><?= h(tr('الهاتف', 'Telephone')) ?></th>
<th><?= h(tr('العنوان', 'Address')) ?></th>
<th><?= h(tr('الدفع', 'Payment')) ?></th>
<th><?= h(tr('المبلغ', 'Amount')) ?></th>
<th><?= h(tr('الحالة', 'Status')) ?></th>
<th class="pe-4 text-end print-hide"><?= h(tr('إجراءات', 'Actions')) ?></th>
@ -159,7 +160,7 @@ require __DIR__ . '/includes/header.php';
</thead>
<tbody>
<?php if(empty($orders)): ?>
<tr><td colspan="8" class="text-center py-5 text-muted"><?= h(tr('لا توجد طلبات', 'No orders found')) ?></td></tr>
<tr><td colspan="9" class="text-center py-5 text-muted"><?= h(tr('لا توجد طلبات', 'No orders found')) ?></td></tr>
<?php else: ?>
<?php
$totalAmount = 0;
@ -180,6 +181,10 @@ require __DIR__ . '/includes/header.php';
<td class="fw-bold"><?= h($o['customer_name']) ?></td>
<td dir="ltr"><?= h(phone_display($o['customer_phone'])) ?></td>
<td style="max-width: 200px;" class="text-truncate" title="<?= h($o['customer_address']) ?>"><?= h($o['customer_address']) ?></td>
<td>
<div class="small fw-semibold"><?= h(online_payment_method_label((string) ($o['payment_method'] ?? 'pay_later'))) ?></div>
<span class="badge <?= h(online_payment_status_badge_class((string) ($o['payment_status'] ?? 'unpaid'))) ?> mt-1"><?= h(online_payment_status_label((string) ($o['payment_status'] ?? 'unpaid'))) ?></span>
</td>
<td class="fw-bold text-primary"><?= h(currency($o['total_amount'])) ?></td>
<td><span class="badge <?= $statusClass ?> px-2 py-1"><?= h($statusText) ?></span></td>
<td class="pe-4 text-end print-hide">
@ -188,6 +193,8 @@ require __DIR__ . '/includes/header.php';
"name" => $o["customer_name"],
"phone" => $o["customer_phone"],
"address" => $o["customer_address"],
"payment_method" => online_payment_method_label((string) ($o["payment_method"] ?? 'pay_later')),
"payment_status" => online_payment_status_label((string) ($o["payment_status"] ?? 'unpaid')),
"subtotal" => $o["subtotal"] ?? 0, "vat" => $o["vat_amount"] ?? 0, "total" => $o["total_amount"],
"items" => $items
], JSON_UNESCAPED_UNICODE), ENT_QUOTES, "UTF-8") ?>)'>
@ -218,7 +225,7 @@ require __DIR__ . '/includes/header.php';
<?php if(!empty($orders)): ?>
<tfoot class="table-light">
<tr>
<td colspan="5" class="text-end fw-bold"><?= h(tr('إجمالي المبالغ:', 'Total Amounts:')) ?></td>
<td colspan="6" class="text-end fw-bold"><?= h(tr('إجمالي المبالغ:', 'Total Amounts:')) ?></td>
<td colspan="3" class="fw-bold text-primary"><?= h(currency($totalAmount)) ?></td>
</tr>
</tfoot>
@ -241,6 +248,8 @@ require __DIR__ . '/includes/header.php';
<div class="col-6 mb-2"><small class="text-muted d-block"><?= h(tr('العميل', 'Customer')) ?></small><strong id="vName"></strong></div>
<div class="col-6 mb-2"><small class="text-muted d-block"><?= h(tr('الهاتف', 'Phone')) ?></small><strong id="vPhone"></strong></div>
<div class="col-12"><small class="text-muted d-block"><?= h(tr('العنوان', 'Address')) ?></small><strong id="vAddress"></strong></div>
<div class="col-6 mt-2"><small class="text-muted d-block"><?= h(tr('طريقة الدفع', 'Payment Method')) ?></small><strong id="vPaymentMethod"></strong></div>
<div class="col-6 mt-2"><small class="text-muted d-block"><?= h(tr('حالة الدفع', 'Payment Status')) ?></small><strong id="vPaymentStatus"></strong></div>
</div>
</div>
@ -276,6 +285,8 @@ function viewOrder(order) {
document.getElementById('vName').innerText = order.name;
document.getElementById('vPhone').innerText = order.phone ? ('968 ' + String(order.phone).replace(/^((00968)|(968))/, '')) : '';
document.getElementById('vAddress').innerText = order.address;
document.getElementById('vPaymentMethod').innerText = order.payment_method || '';
document.getElementById('vPaymentStatus').innerText = order.payment_status || '';
document.getElementById('vSubtotal').innerText = Number(order.subtotal).toFixed(2);
document.getElementById('vVat').innerText = Number(order.vat).toFixed(2);
document.getElementById('vTotal').innerText = Number(order.total).toFixed(2);

View File

@ -3,6 +3,10 @@ require_once __DIR__ . '/includes/app.php';
$forcePublic = true;
$pageTitle = tr('الطلب عبر الإنترنت', 'Online Ordering');
$shopPaymentStatus = trim((string) ($_GET['payment_status'] ?? ''));
$shopPaymentMessage = trim((string) ($_GET['message'] ?? ''));
$shopCanPayOnline = thawani_is_configured();
$shopShowPayOnline = thawani_is_enabled() || $shopCanPayOnline;
require __DIR__ . '/includes/header.php';
@ -110,6 +114,26 @@ body { background-color: #f8f9fa; }
</div>
</div>
<?php if ($shopPaymentStatus !== ''): ?>
<?php
$alertClass = 'alert-info';
if ($shopPaymentStatus === 'paid') {
$alertClass = 'alert-success';
} elseif (in_array($shopPaymentStatus, ['cancelled', 'failed'], true)) {
$alertClass = 'alert-warning';
}
$defaultMessage = match ($shopPaymentStatus) {
'paid' => tr('تم الدفع بنجاح وتم استلام طلبك.', 'Payment completed successfully and your order was received.'),
'cancelled' => tr('تم إلغاء عملية الدفع. يمكنك المحاولة مرة أخرى أو اختيار الدفع لاحقاً.', 'Payment was cancelled. You can try again or choose Pay Later.'),
'failed' => tr('تعذر تأكيد الدفع. إذا تم الخصم يرجى مراجعة الإدارة.', 'We could not confirm the payment. If you were charged, please contact the store.'),
default => tr('تم تحديث حالة الطلب.', 'The order status has been updated.'),
};
?>
<div class="alert <?= h($alertClass) ?> rounded-4 shadow-sm mb-4">
<?= h($shopPaymentMessage !== '' ? $shopPaymentMessage : $defaultMessage) ?>
</div>
<?php endif; ?>
<?php if (!empty($catalog)): ?>
<!-- Search and Filter -->
<div class="row mb-4">
@ -244,6 +268,30 @@ body { background-color: #f8f9fa; }
<label class="form-label fw-semibold"><?= h(tr('العنوان', 'Address')) ?> *</label>
<textarea class="form-control form-control-lg rounded-3" id="customerAddress" rows="2" required></textarea>
</div>
<div class="mb-3">
<label class="form-label fw-semibold d-block mb-3"><?= h(tr('طريقة الدفع', 'Payment Method')) ?> *</label>
<div class="row g-3">
<?php if ($shopShowPayOnline): ?>
<div class="col-md-6">
<label class="border rounded-4 p-3 w-100 h-100 bg-light-subtle <?= $shopCanPayOnline ? '' : 'opacity-75' ?>">
<input class="form-check-input me-2" type="radio" name="payment_method" value="pay_online" <?= $shopCanPayOnline ? '' : 'disabled' ?>>
<span class="fw-bold d-block"><?= h(tr('ادفع أونلاين', 'Pay Online')) ?></span>
<span class="small text-muted"><?= h(tr('سيتم تحويلك إلى بوابة ثواني لإتمام الدفع مباشرة.', 'You will be redirected to Thawani checkout to complete payment.')) ?></span>
</label>
</div>
<?php endif; ?>
<div class="col-md-6">
<label class="border rounded-4 p-3 w-100 h-100 bg-light-subtle">
<input class="form-check-input me-2" type="radio" name="payment_method" value="pay_later" checked>
<span class="fw-bold d-block"><?= h(tr('ادفع لاحقاً', 'Pay Later')) ?></span>
<span class="small text-muted"><?= h(tr('سيتم إنشاء الطلب الآن وسيتم تحصيل المبلغ لاحقاً.', 'Your order will be created now and payment will be collected later.')) ?></span>
</label>
</div>
</div>
<?php if (!$shopCanPayOnline && $shopShowPayOnline): ?>
<div class="form-text mt-2 text-warning"><?= h(tr('خيار الدفع أونلاين ظاهر لكن مفاتيح ثواني غير مكتملة في الإعدادات بعد.', 'Pay Online is visible, but the Thawani keys are not fully configured in Settings yet.')) ?></div>
<?php endif; ?>
</div>
</form>
</div>
<div class="modal-footer border-top-0 pt-0 pb-4 px-4 d-flex justify-content-between">
@ -370,10 +418,19 @@ async function submitOrder() {
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
btn.disabled = true;
const paymentMethodField = document.querySelector('input[name="payment_method"]:checked');
if (!paymentMethodField) {
Swal.fire('<?= h(tr('تنبيه', 'Warning')) ?>', '<?= h(tr('اختر طريقة الدفع.', 'Please choose a payment method.')) ?>', 'warning');
btn.innerHTML = origText;
btn.disabled = false;
return;
}
const data = {
name: document.getElementById('customerName').value,
phone: document.getElementById('customerPhone').value,
address: document.getElementById('customerAddress').value,
payment_method: paymentMethodField.value,
items: cart
};
@ -389,6 +446,12 @@ async function submitOrder() {
cart = {};
saveCart();
if (cartModalInstance) cartModalInstance.hide();
if (json.redirect_url) {
window.location.href = json.redirect_url;
return;
}
Swal.fire({
title: '<?= h(tr('تم إرسال الطلب بنجاح!', 'Order submitted successfully!')) ?>',
text: '<?= h(tr('سنتواصل معك قريباً لتأكيد الطلب.', 'We will contact you shortly to confirm the order.')) ?>',

96
thawani_return.php Normal file
View File

@ -0,0 +1,96 @@
<?php
require_once __DIR__ . '/includes/app.php';
$result = trim((string) ($_GET['result'] ?? 'cancel'));
$orderId = (int) ($_GET['order_id'] ?? 0);
if ($orderId <= 0) {
redirect_to('shop.php', [
'payment_status' => 'failed',
'message' => tr('تعذر العثور على الطلب.', 'Could not find the order.'),
]);
}
$stmt = db()->prepare('SELECT * FROM online_orders WHERE id = ? LIMIT 1');
$stmt->execute([$orderId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$order) {
redirect_to('shop.php', [
'payment_status' => 'failed',
'message' => tr('الطلب غير موجود.', 'The order was not found.'),
]);
}
$items = json_decode((string) ($order['items_json'] ?? '[]'), true) ?: [];
$oldStatus = (string) ($order['status'] ?? 'pending');
$currentPaymentStatus = (string) ($order['payment_status'] ?? 'unpaid');
$sessionId = trim((string) ($order['gateway_session_id'] ?? $_GET['session_id'] ?? ''));
$redirectStatus = 'failed';
$redirectMessage = tr('تعذر تأكيد الدفع. حاول مرة أخرى أو اختر الدفع لاحقاً.', 'We could not confirm the payment. Please try again or choose Pay Later.');
$sendCustomerWhatsapp = false;
if ($result === 'success' && $sessionId !== '') {
$verification = thawani_retrieve_session($sessionId);
if (!empty($verification['success']) && thawani_session_paid($verification)) {
$transactionId = thawani_session_transaction_id($verification);
$updateStmt = db()->prepare('UPDATE online_orders SET payment_status = ?, gateway_session_id = ?, gateway_transaction_id = ?, paid_at = NOW() WHERE id = ?');
$updateStmt->execute(['paid', $sessionId, $transactionId !== '' ? $transactionId : null, $orderId]);
$sendCustomerWhatsapp = true;
$redirectStatus = 'paid';
$redirectMessage = tr('تم الدفع بنجاح وتم استلام طلبك.', 'Payment completed successfully and your order was received.');
} else {
if ($oldStatus === 'pending') {
db()->beginTransaction();
try {
sync_online_order_stock_reservation($items, $oldStatus, $items, 'rejected');
$updateStmt = db()->prepare('UPDATE online_orders SET status = ?, payment_status = ? WHERE id = ?');
$updateStmt->execute(['rejected', 'failed', $orderId]);
db()->commit();
} catch (Throwable $e) {
if (db()->inTransaction()) {
db()->rollBack();
}
throw $e;
}
} elseif ($currentPaymentStatus !== 'paid') {
$updateStmt = db()->prepare('UPDATE online_orders SET payment_status = ? WHERE id = ?');
$updateStmt->execute(['failed', $orderId]);
}
}
} else {
if ($currentPaymentStatus !== 'paid' && $oldStatus === 'pending') {
db()->beginTransaction();
try {
sync_online_order_stock_reservation($items, $oldStatus, $items, 'rejected');
$updateStmt = db()->prepare('UPDATE online_orders SET status = ?, payment_status = ? WHERE id = ?');
$updateStmt->execute(['rejected', 'cancelled', $orderId]);
db()->commit();
} catch (Throwable $e) {
if (db()->inTransaction()) {
db()->rollBack();
}
throw $e;
}
} elseif ($currentPaymentStatus !== 'paid') {
$updateStmt = db()->prepare('UPDATE online_orders SET payment_status = ? WHERE id = ?');
$updateStmt->execute(['cancelled', $orderId]);
}
$redirectStatus = 'cancelled';
$redirectMessage = tr('تم إلغاء الدفع. يمكنك إعادة الطلب أو اختيار الدفع لاحقاً.', 'Payment was cancelled. You can place the order again or choose Pay Later.');
}
if ($sendCustomerWhatsapp && wablas_is_configured()) {
try {
wablas_notify_online_order_by_id($orderId, 'created');
} catch (Throwable $e) {
error_log('Customer WhatsApp notify failed after Thawani payment for order #' . $orderId . ': ' . $e->getMessage());
}
}
redirect_to('shop.php', [
'payment_status' => $redirectStatus,
'message' => $redirectMessage,
]);