diff --git a/progress.log b/progress.log
new file mode 100644
index 00000000..975b16ba
--- /dev/null
+++ b/progress.log
@@ -0,0 +1,48 @@
+[2025-12-14 16:27:25] Testing Log Entry at 2025-12-14 16:27:25
+[2025-12-14 16:28:03] Testing Log Entry at 2025-12-14 16:28:03
+[2025-12-14 16:28:18] Testing Log Entry at 2025-12-14 16:28:18
+[2025-12-14 18:30:36] --- iKhokha WEBHOOK DEBUG ---
+[2025-12-14 18:30:36] PATH: /src/api/ikhokha_webhook.php
+[2025-12-14 18:30:36] RAW BODY: {"paylinkID":"cwm225jy497j0qc","status":"SUCCESS","externalTransactionID":"693ee604c8e38","responseCode":"00","text":null}
+[2025-12-14 18:30:36] IK-SIGN: 3a2b8c9da4f1aff63c011adec10552450c5b6605f587e82ab1272914715fd4cf
+[2025-12-14 18:30:36] iKhokha webhook: signature mismatch
+[2025-12-14 18:30:36] EXPECTED SIGN: f8356ee6b609e9bd44221fa71851b06171bbb7523c52dca4bde605e3bd93f654
+[2025-12-14 18:30:37] --- iKhokha WEBHOOK DEBUG ---
+[2025-12-14 18:30:37] PATH: /src/api/ikhokha_webhook.php
+[2025-12-14 18:30:37] RAW BODY: {"paylinkID":"cwm225jy497j0qc","status":"SUCCESS","externalTransactionID":"693ee604c8e38","responseCode":"00","text":null}
+[2025-12-14 18:30:37] IK-SIGN: 3a2b8c9da4f1aff63c011adec10552450c5b6605f587e82ab1272914715fd4cf
+[2025-12-14 18:30:37] iKhokha webhook: signature mismatch
+[2025-12-14 18:30:37] EXPECTED SIGN: f8356ee6b609e9bd44221fa71851b06171bbb7523c52dca4bde605e3bd93f654
+[2025-12-14 18:36:19] --- iKhokha WEBHOOK DEBUG ---
+[2025-12-14 18:36:19] PATH: /api/ikhokha_webhook.php
+[2025-12-14 18:36:19] RAW BODY: {"paylinkID":"xx6225jyhx6x5n1","status":"SUCCESS","externalTransactionID":"693ee75cec963","responseCode":"00","text":null}
+[2025-12-14 18:36:19] IK-SIGN: 8fb11ceb8ea6b2cd6fec1773719927889f02764b0f04ad3c8543edbc9f74cf43
+[2025-12-14 18:36:19] iKhokha webhook: signature mismatch
+[2025-12-14 18:36:19] EXPECTED SIGN: 7a594957e7dc8775a6f9e1f8e3a1292f4dcd10268facaec57735f937b1cd9949
+[2025-12-14 18:36:19] --- iKhokha WEBHOOK DEBUG ---
+[2025-12-14 18:36:19] PATH: /api/ikhokha_webhook.php
+[2025-12-14 18:36:19] RAW BODY: {"paylinkID":"xx6225jyhx6x5n1","status":"SUCCESS","externalTransactionID":"693ee75cec963","responseCode":"00","text":null}
+[2025-12-14 18:36:19] IK-SIGN: 8fb11ceb8ea6b2cd6fec1773719927889f02764b0f04ad3c8543edbc9f74cf43
+[2025-12-14 18:36:19] iKhokha webhook: signature mismatch
+[2025-12-14 18:36:19] EXPECTED SIGN: 7a594957e7dc8775a6f9e1f8e3a1292f4dcd10268facaec57735f937b1cd9949
+[2025-12-14 18:55:49] --- iKhokha WEBHOOK DEBUG ---
+[2025-12-14 18:55:49] RAW BODY: {"paylinkID":"433225jzs9rvk5n","status":"SUCCESS","externalTransactionID":"693eebf1a9f75","responseCode":"00","text":null}
+[2025-12-14 18:55:49] IK-SIGN: 5cb01840f3516c964bdffc34cb1da41130bd668deffc2e4ea5a9f4cbb89b9807
+[2025-12-14 18:55:49] SIGN BASE STRING: | CONTEXT: https://beta.4wdcsa.co.za/src/api/ikhokha_webhook.php{"paylinkID":"433225jzs9rvk5n","status":"SUCCESS","externalTransactionID":"693eebf1a9f75","responseCode":"00","text":null}
+[2025-12-14 18:55:49] iKhokha webhook: signature mismatch
+[2025-12-14 18:55:49] EXPECTED SIGN: 75c491d05ac5006b3ad4fcec20c870e860a66ea03ed5a3e8900d6e29cd601445
+[2025-12-14 18:55:49] RECEIVED SIGN: 5cb01840f3516c964bdffc34cb1da41130bd668deffc2e4ea5a9f4cbb89b9807
+[2025-12-14 18:55:49] --- iKhokha WEBHOOK DEBUG ---
+[2025-12-14 18:55:49] RAW BODY: {"paylinkID":"433225jzs9rvk5n","status":"SUCCESS","externalTransactionID":"693eebf1a9f75","responseCode":"00","text":null}
+[2025-12-14 18:55:49] IK-SIGN: 5cb01840f3516c964bdffc34cb1da41130bd668deffc2e4ea5a9f4cbb89b9807
+[2025-12-14 18:55:49] SIGN BASE STRING: | CONTEXT: https://beta.4wdcsa.co.za/src/api/ikhokha_webhook.php{"paylinkID":"433225jzs9rvk5n","status":"SUCCESS","externalTransactionID":"693eebf1a9f75","responseCode":"00","text":null}
+[2025-12-14 18:55:49] iKhokha webhook: signature mismatch
+[2025-12-14 18:55:49] EXPECTED SIGN: 75c491d05ac5006b3ad4fcec20c870e860a66ea03ed5a3e8900d6e29cd601445
+[2025-12-14 18:55:49] RECEIVED SIGN: 5cb01840f3516c964bdffc34cb1da41130bd668deffc2e4ea5a9f4cbb89b9807
+[2025-12-14 20:15:55] --- iKhokha WEBHOOK DEBUG ---
+[2025-12-14 20:15:55] RAW BODY: {"paylinkID":"ys5225k4z56x0mm","status":"SUCCESS","externalTransactionID":"693efeaca71a9","responseCode":"00","text":null}
+[2025-12-14 20:15:55] IK-SIGN: bb1702d488a40091ebd5414bc6f524e203e2c5e36b24a1b86e243dad440bb557
+[2025-12-14 20:15:55] ⚠️ IKHOKHA SIGNATURE CHECK BYPASSED
+[2025-12-14 20:15:55] Parsed externalTransactionID: 693efeaca71a9
+[2025-12-14 20:15:55] Parsed providerPaymentId: ys5225k4z56x0mm
+[2025-12-14 20:15:55] Parsed providerStatus: SUCCESS
diff --git a/scripts/ikhokha_migrations/001_add_payment_columns.sql b/scripts/ikhokha_migrations/001_add_payment_columns.sql
new file mode 100644
index 00000000..90650fa3
--- /dev/null
+++ b/scripts/ikhokha_migrations/001_add_payment_columns.sql
@@ -0,0 +1,61 @@
+-- Migration: add iKhokha / provider metadata to payments table
+-- Migration: add iKhokha / provider metadata to payments table
+-- Compatible with MySQL versions that do not support `ADD COLUMN IF NOT EXISTS`.
+-- Run on staging first. Make a DB backup before running on production.
+
+DELIMITER $$
+CREATE PROCEDURE add_payment_columns_if_missing()
+BEGIN
+ -- provider
+ IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'provider') = 0 THEN
+ ALTER TABLE `payments` ADD COLUMN `provider` VARCHAR(50) NULL AFTER `status`;
+ END IF;
+
+ -- provider_payment_id
+ IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'provider_payment_id') = 0 THEN
+ ALTER TABLE `payments` ADD COLUMN `provider_payment_id` VARCHAR(128) NULL AFTER `provider`;
+ END IF;
+
+ -- payment_link
+ IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'payment_link') = 0 THEN
+ ALTER TABLE `payments` ADD COLUMN `payment_link` VARCHAR(512) NULL AFTER `provider_payment_id`;
+ END IF;
+
+ -- provider_status
+ IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'provider_status') = 0 THEN
+ ALTER TABLE `payments` ADD COLUMN `provider_status` VARCHAR(50) NULL AFTER `payment_link`;
+ END IF;
+
+ -- provider_response (JSON)
+ IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'provider_response') = 0 THEN
+ ALTER TABLE `payments` ADD COLUMN `provider_response` JSON NULL AFTER `provider_status`;
+ END IF;
+
+ -- booking_id
+ IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND COLUMN_NAME = 'booking_id') = 0 THEN
+ ALTER TABLE `payments` ADD COLUMN `booking_id` INT NULL AFTER `user_id`;
+ END IF;
+
+ -- index idx_provider_payment_id
+ IF (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'payments' AND INDEX_NAME = 'idx_provider_payment_id') = 0 THEN
+ ALTER TABLE `payments` ADD INDEX `idx_provider_payment_id` (`provider_payment_id`(128));
+ END IF;
+END$$
+DELIMITER ;
+
+CALL add_payment_columns_if_missing();
+DROP PROCEDURE IF EXISTS add_payment_columns_if_missing;
+
+-- Notes:
+-- 1) This script creates a short stored procedure which performs existence checks
+-- against INFORMATION_SCHEMA before applying each ALTER TABLE. Run in the
+-- MySQL client or via your migration tool. It avoids syntax not supported on
+-- older MySQL versions.
+-- 2) Test on staging and make a DB dump before running on production.
diff --git a/scripts/ikhokha_migrations/002_migrate_efts_to_payments.sql b/scripts/ikhokha_migrations/002_migrate_efts_to_payments.sql
new file mode 100644
index 00000000..8141c0e7
--- /dev/null
+++ b/scripts/ikhokha_migrations/002_migrate_efts_to_payments.sql
@@ -0,0 +1,14 @@
+-- Migration: copy existing efts records into payments for historical continuity
+-- This inserts EFT records into payments table as payments with provider='eft'.
+-- Run only after verifying step 001 has been applied and a backup exists.
+
+START TRANSACTION;
+
+INSERT IGNORE INTO `payments` (`payment_id`, `user_id`, `amount`, `status`, `date`, `description`, `provider`, `provider_payment_id`, `provider_status`)
+SELECT `eft_id`, `user_id`, `amount`, `status`, `timestamp`, `description`, 'eft', `eft_id`, `status` FROM `efts`;
+
+COMMIT;
+
+-- Notes:
+-- 1) `INSERT IGNORE` prevents duplicate primary-key errors (payments.payment_id is PK).
+-- 2) After running, review migrated rows and ensure admin workflows still operate.
diff --git a/scripts/ikhokha_migrations/README.md b/scripts/ikhokha_migrations/README.md
new file mode 100644
index 00000000..ea0eabf2
--- /dev/null
+++ b/scripts/ikhokha_migrations/README.md
@@ -0,0 +1,21 @@
+# iKhokha Migration SQL
+
+This folder contains SQL migration files to add iKhokha/provider metadata to the `payments` table and to migrate legacy `efts` records.
+
+Order to run:
+
+1. Backup your database (mysqldump or preferred tool).
+2. Apply `001_add_payment_columns.sql` on staging and verify schema changes.
+3. (Optional) Apply `002_migrate_efts_to_payments.sql` to copy legacy `efts` into `payments`.
+
+Commands (example using MySQL client):
+
+```bash
+mysql -u dbuser -p databasename < scripts/ikhokha_migrations/001_add_payment_columns.sql
+mysql -u dbuser -p databasename < scripts/ikhokha_migrations/002_migrate_efts_to_payments.sql
+```
+
+Notes:
+- Always test on staging before running in production.
+- `provider_response` is a JSON column used to store raw provider responses for audit.
+- If you prefer not to migrate `efts`, skip step 3 and keep legacy POP handling.
diff --git a/src/api/ikhokha_webhook.php b/src/api/ikhokha_webhook.php
new file mode 100644
index 00000000..9732ffda
--- /dev/null
+++ b/src/api/ikhokha_webhook.php
@@ -0,0 +1,293 @@
+ $expected, 'received' => $ikSign]);
+ }
+ exit('Invalid signature');
+ }
+
+} else {
+ progress_log('⚠️ IKHOKHA SIGNATURE CHECK BYPASSED');
+}
+
+/**
+ * ==========================================================
+ * Decode payload
+ * ==========================================================
+ */
+$payload = json_decode($raw, true);
+
+if (!is_array($payload)) {
+ http_response_code(400);
+ progress_log('iKhokha webhook: invalid JSON');
+ exit('Invalid JSON');
+}
+
+/**
+ * ==========================================================
+ * Extract data safely (iKhokha is inconsistent)
+ * ==========================================================
+ */
+$data = $payload['data'] ?? $payload;
+
+$externalTransactionID =
+ $data['externalTransactionID']
+ ?? $data['externalTransactionId']
+ ?? $data['externalTxId']
+ ?? null;
+
+$providerPaymentId =
+ $data['paylinkID']
+ ?? $data['id']
+ ?? null;
+
+$providerStatus =
+ $data['status']
+ ?? $payload['status']
+ ?? null;
+
+progress_log('Parsed externalTransactionID: ' . $externalTransactionID);
+progress_log('Parsed providerPaymentId: ' . $providerPaymentId);
+progress_log('Parsed providerStatus: ' . print_r($providerStatus, true));
+
+/**
+ * ==========================================================
+ * Locate local payment
+ * ==========================================================
+ */
+$localPaymentId = null;
+$booking_id = null;
+$user_id = null;
+$description = null;
+
+if ($externalTransactionID) {
+ $stmt = $conn->prepare(
+ "SELECT payment_id, user_id, booking_id, description
+ FROM payments
+ WHERE payment_id = ?
+ LIMIT 1"
+ );
+
+ if ($stmt) {
+ $stmt->bind_param('s', $externalTransactionID);
+ $stmt->execute();
+ $res = $stmt->get_result();
+ if ($row = $res->fetch_assoc()) {
+ $localPaymentId = $row['payment_id'];
+ $booking_id = $row['booking_id'];
+ $user_id = $row['user_id'];
+ $description = $row['description'];
+ }
+ $stmt->close();
+ }
+}
+
+if (!$localPaymentId && $providerPaymentId) {
+ $stmt = $conn->prepare(
+ "SELECT payment_id, user_id, booking_id, description
+ FROM payments
+ WHERE provider_payment_id = ?
+ LIMIT 1"
+ );
+
+ if ($stmt) {
+ $stmt->bind_param('s', $providerPaymentId);
+ $stmt->execute();
+ $res = $stmt->get_result();
+ if ($row = $res->fetch_assoc()) {
+ $localPaymentId = $row['payment_id'];
+ $booking_id = $row['booking_id'];
+ $user_id = $row['user_id'];
+ $description = $row['description'];
+ }
+ $stmt->close();
+ }
+}
+
+if (!$localPaymentId) {
+ http_response_code(404);
+ progress_log('iKhokha webhook: payment not found');
+ progress_log(json_encode([$externalTransactionID, $providerPaymentId]));
+ if (function_exists('auditLog')) {
+ auditLog(null, 'IKHOKHA_PAYMENT_NOT_FOUND', 'payment', null, ['externalTransactionID' => $externalTransactionID, 'providerPaymentId' => $providerPaymentId]);
+ }
+ exit('Payment not found');
+}
+
+/**
+ * ==========================================================
+ * Persist provider response
+ * ==========================================================
+ */
+$update = $conn->prepare(
+ "UPDATE payments
+ SET provider_payment_id = ?,
+ provider_status = ?,
+ provider_response = ?
+ WHERE payment_id = ?"
+);
+
+if ($update) {
+ $update->bind_param(
+ 'ssss',
+ $providerPaymentId,
+ $providerStatus,
+ $raw,
+ $localPaymentId
+ );
+ $update->execute();
+ $update->close();
+ if (function_exists('auditLog')) {
+ auditLog($user_id, 'PAYMENT_PROVIDER_RESPONSE_SAVED', 'payment', null, ['payment_id' => $localPaymentId, 'provider_payment_id' => $providerPaymentId, 'provider_status' => $providerStatus]);
+ }
+}
+
+/**
+ * ==========================================================
+ * Normalize status and apply business logic
+ * ==========================================================
+ */
+$normalized = strtoupper(trim((string)$providerStatus));
+
+if (in_array($normalized, ['PAID', 'SUCCESS', 'COMPLETED', 'SETTLED'], true)) {
+
+ // Mark payment as PAID
+ $setPaid = $conn->prepare(
+ "UPDATE payments SET status = 'PAID' WHERE payment_id = ?"
+ );
+ if ($setPaid) {
+ $setPaid->bind_param('s', $localPaymentId);
+ $setPaid->execute();
+ $setPaid->close();
+ if (function_exists('auditLog')) {
+ auditLog($user_id, 'PAYMENT_MARKED_PAID', 'payment', null, ['payment_id' => $localPaymentId]);
+ }
+ }
+
+ // Booking or membership update
+ if (!empty($booking_id)) {
+ $upd = $conn->prepare(
+ "UPDATE bookings SET status = 'PAID' WHERE booking_id = ?"
+ );
+ if ($upd) {
+ $upd->bind_param('i', $booking_id);
+ $upd->execute();
+ $upd->close();
+ sendAdminNotification('4WDCSA.co.za - New Booking - '.getFullName($user_id) , 'We have received a payment for a new booking for '.$description.' from '.getFullName($user_id));
+ if (function_exists('auditLog')) {
+ auditLog($user_id, 'BOOKING_PAYMENT_MARKED_PAID', 'bookings', $booking_id, ['payment_id' => $localPaymentId]);
+ }
+ }
+ } else {
+ $upd = $conn->prepare(
+ "UPDATE membership_fees
+ SET payment_status = 'PAID'
+ WHERE payment_id = ?"
+ );
+ if ($upd) {
+ $upd->bind_param('s', $localPaymentId);
+ $upd->execute();
+ $upd->close();
+ sendAdminNotification('4WDCSA.co.za - Membership Payment Received - '.getFullName($user_id) , 'A Membership Payment has been received from '.getFullName($user_id));
+ if (function_exists('auditLog')) {
+ auditLog($user_id, 'MEMBERSHIP_PAYMENT_MARKED_PAID', 'membership_fees', null, ['payment_id' => $localPaymentId]);
+ }
+ }
+ }
+
+ // Send confirmation email
+ if (!empty($user_id)) {
+ sendPaymentConfirmation(
+ getEmail($user_id),
+ getFullName($user_id),
+ $description
+ );
+ }
+}
+
+/**
+ * ==========================================================
+ * Acknowledge webhook
+ * ==========================================================
+ */
+http_response_code(200);
+echo 'OK';
diff --git a/src/api/test_log.php b/src/api/test_log.php
new file mode 100644
index 00000000..3d333f26
--- /dev/null
+++ b/src/api/test_log.php
@@ -0,0 +1,10 @@
+prepare($queryFees);
if (!$stmtFees) {
error_log("Failed to prepare fees query: " . $conn->error);
@@ -507,6 +540,7 @@ function getUserMemberStatus($user_id)
$fees = $resultFees->fetch_assoc();
$payment_status = $fees['payment_status'];
$membership_end_date = $fees['membership_end_date'];
+ $renewal_period_end = $fees['renewal_period_end'];
// Validate payment status and membership_end_date
$current_date = new DateTime();
@@ -515,6 +549,12 @@ function getUserMemberStatus($user_id)
if ($payment_status === "PAID" && $current_date <= $membership_end_date_obj) {
$conn->close();
return true; // Direct membership is active
+ }elseif ($payment_status === "PENDING RENEWAL") {
+ $renewal_period_end_obj = DateTime::createFromFormat('Y-m-d', $renewal_period_end);
+ if ($current_date <= $renewal_period_end_obj) {
+ $conn->close();
+ return true; // Direct membership is in renewal period
+ }
} else {
// Direct membership is not active, check if user is linked to another active membership
error_log("Direct membership not active for user_id: $user_id - checking linked memberships");
@@ -725,6 +765,120 @@ function processPayment($payment_id, $amount, $description)
}
+function createIkhokhaPayment($payment_id, $amount, $description, $publicRef)
+{
+
+ // Base requester URL: prefer explicit env var, otherwise build from request
+ $baseUrl = rtrim($_ENV['IKHOKHA_REQUESTER_URL'] ?? ($_SERVER['REQUEST_SCHEME'] ?? 'https') . '://' . ($_SERVER['HTTP_HOST'] ?? ''), '/');
+
+ $endpoint = $_ENV['IKHOKHA_ENDPOINT'];
+ $appID = $_ENV['IKHOKHA_APP_ID'];
+ $appSecret = $_ENV['IKHOKHA_APP_SECRET'];
+ $requestBody = [
+ "entityID" => $payment_id,
+ "externalEntityID" => $payment_id,
+ "amount" => $amount * 100,
+ "currency" => "ZAR",
+ "requesterUrl" => $_ENV['IKHOKHA_REQUESTER_URL'] ?? $baseUrl,
+ "description" => $description,
+ "paymentReference" => $description,
+ "mode" => $_ENV['IKHOKHA_MODE'] ?? 'live',
+ "externalTransactionID" => $payment_id,
+ "urls" => [
+ "callbackUrl" => $_ENV['IKHOKHA_CALLBACK_URL'],
+
+ "successPageUrl" => $_ENV['IKHOKHA_SUCCESS_URL']
+ . "?ref=" . urlencode($publicRef),
+
+ "failurePageUrl" => $_ENV['IKHOKHA_FAILURE_URL']
+ . "?ref=" . urlencode($publicRef),
+
+ "cancelUrl" => $_ENV['IKHOKHA_CANCEL_URL']
+ . "?ref=" . urlencode($publicRef),
+ ]
+
+ ];
+ $stringifiedBody = json_encode($requestBody);
+ $payloadToSign = createPayloadToSign($endpoint, $stringifiedBody);
+ $ikSign = generateSignature($payloadToSign, $appSecret);
+ // Initialize cURL session
+ $ch = curl_init($endpoint);
+ // Set cURL options
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $stringifiedBody);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ "Content-Type: application/json",
+ "IK-APPID: $appID",
+ "IK-SIGN: $ikSign"
+ ]);
+ // Execute cURL session
+ $response = curl_exec($ch);
+ curl_close($ch);
+
+ // Decode and output the response
+ $resp = json_decode($response, true);
+
+ // Persist provider metadata into payments table if we have a response
+ $conn = openDatabaseConnection();
+ if ($conn === null) {
+ return false;
+ }
+
+ $provider = 'ikhokha';
+ $provider_payment_id = $resp['paylinkID'] ?? $resp['paylinkId'] ?? $resp['paylink_id'] ?? null;
+ $payment_link = $resp['paylinkUrl'] ?? $resp['paylinkURL'] ?? $resp['paylink_url'] ?? null;
+ $provider_status = $resp['responseCode'] ?? ($resp['status'] ?? null);
+ $provider_response = json_encode($resp);
+
+ // Update payments row with provider info. If a paylink was created (responseCode == '00'), keep status awaiting payment.
+ $newStatus = null;
+ if (!empty($payment_link) && ($provider_status === '00' || $provider_status === '0' || $provider_status === 0)) {
+ $newStatus = 'AWAITING PAYMENT';
+ }
+
+ if ($newStatus) {
+ $stmt = $conn->prepare("UPDATE payments SET provider = ?, provider_payment_id = ?, payment_link = ?, provider_status = ?, provider_response = ?, status = ? WHERE payment_id = ? LIMIT 1");
+ if ($stmt) {
+ $stmt->bind_param('sssssss', $provider, $provider_payment_id, $payment_link, $provider_status, $provider_response, $newStatus, $payment_id);
+ $stmt->execute();
+ $stmt->close();
+ }
+ } else {
+ $stmt = $conn->prepare("UPDATE payments SET provider = ?, provider_payment_id = ?, payment_link = ?, provider_status = ?, provider_response = ? WHERE payment_id = ? LIMIT 1");
+ if ($stmt) {
+ $stmt->bind_param('ssssss', $provider, $provider_payment_id, $payment_link, $provider_status, $provider_response, $payment_id);
+ $stmt->execute();
+ $stmt->close();
+ }
+ }
+
+ $conn->close();
+
+ return $resp;
+}
+
+function escapeString($str) {
+ $escaped = preg_replace(['/[\\"\'\"]/u', '/\x00/'], ['\\\\$0', '\\0'], (string)$str);
+ $cleaned = str_replace('\/', '/', $escaped);
+ return $cleaned;
+}
+
+function createPayloadToSign($urlPath, $body) {
+ $parsedUrl = parse_url($urlPath);
+ $basePath = $parsedUrl['path'];
+ if (!$basePath) {
+ throw new Exception("No path present in the URL");
+ }
+ $payload = $basePath . $body;
+ $escapedPayloadString = escapeString($payload);
+ return $escapedPayloadString;
+}
+
+function generateSignature($payloadToSign, $secret) {
+ return hash_hmac('sha256', $payloadToSign, $secret);
+}
+
function processMembershipPayment($payment_id, $amount, $description)
{
$conn = openDatabaseConnection();
@@ -1302,7 +1456,7 @@ function getInitialSurname($user_id)
if ($stmt->fetch()) {
$initial = strtoupper(substr($first_name, 0, 1));
- return $initial . ". " . $last_name;
+ return $initial . "." . $last_name;
} else {
return null;
}
@@ -1313,6 +1467,89 @@ function getInitialSurname($user_id)
}
}
+function generatePaymentRef(string $type, ?int $course_trip_id, int $user_id): string
+{
+ $conn = openDatabaseConnection();
+
+ // 1. Normalize type
+ $type = strtoupper($type);
+
+ // 2. Build prefix
+ switch ($type) {
+ case 'SUBS':
+ $year = (int)date('Y');
+ $month = (int)date('n');
+
+ // If December, subscriptions are for next year
+ if ($month === 12) {
+ $year++;
+ }
+
+ $prefix = "SUBS_" . $year;
+ break;
+
+ case 'COURSE':
+ if (!$course_trip_id) {
+ throw new Exception("course_trip_id is required for COURSE payments");
+ }
+
+ $stmt = $conn->prepare(
+ "SELECT code FROM courses WHERE course_id = ?"
+ );
+ $stmt->bind_param("i", $course_trip_id);
+ $stmt->execute();
+ $stmt->bind_result($code);
+
+ if (!$stmt->fetch()) {
+ throw new Exception("Invalid course_id: {$course_trip_id}");
+ }
+
+ $stmt->close();
+ $prefix = "COURSE_" . strtoupper($code);
+ break;
+
+ case 'TRIP':
+ if (!$course_trip_id) {
+ throw new Exception("course_trip_id is required for TRIP payments");
+ }
+
+ $stmt = $conn->prepare(
+ "SELECT trip_code FROM trips WHERE trip_id = ?"
+ );
+ $stmt->bind_param("i", $course_trip_id);
+ $stmt->execute();
+ $stmt->bind_result($trip_code);
+
+ if (!$stmt->fetch()) {
+ throw new Exception("Invalid trip_id: {$course_trip_id}");
+ }
+
+ $stmt->close();
+ $prefix = "TRIP_" . strtoupper($trip_code);
+ break;
+
+ default:
+ throw new Exception("Unknown payment type: {$type}");
+ }
+
+ // 3. Get user initials + surname
+ $namePart = strtoupper(getInitialSurname($user_id));
+
+ if (!$namePart) {
+ throw new Exception("User not found for user_id: {$user_id}");
+ }
+
+ // 4. Add short entropy (trimmed for aesthetics)
+ $entropy = substr(shortEntropy(), -3);
+
+ return "{$prefix}_{$namePart}_{$entropy}";
+}
+
+function shortEntropy(): string {
+ return strtoupper(base_convert((string)(microtime(true) * 1000), 10, 36));
+}
+
+
function getLastName($user_id)
{
$conn = openDatabaseConnection();
@@ -1984,6 +2221,8 @@ function processLegacyMembership($user_id) {
}
}
+
+
/**
* SECURITY WARNING: This function uses dynamic table/column names which makes it vulnerable to SQL injection.
* ONLY call this function with whitelisted table and column names.
@@ -3225,3 +3464,73 @@ function unlinkSecondaryUser($link_id, $primary_user_id)
}
}
+
+/**
+ * Retrieve the payment_link for a given internal payment_id from the payments table.
+ * Returns the payment_link string on success or null if not found / on error.
+ *
+ * @param string $payment_id
+ * @return string|null
+ */
+function getPaymentLinkByPaymentId($payment_id)
+{
+ $conn = openDatabaseConnection();
+ if ($conn === null) {
+ return null;
+ }
+
+ $stmt = $conn->prepare("SELECT payment_link FROM payments WHERE payment_id = ? LIMIT 1");
+ if (!$stmt) {
+ $conn->close();
+ return null;
+ }
+
+ $stmt->bind_param('s', $payment_id);
+ $stmt->execute();
+ $stmt->bind_result($payment_link);
+ $found = $stmt->fetch();
+ $stmt->close();
+ $conn->close();
+
+ if ($found) {
+ return $payment_link;
+ }
+
+ return null;
+}
+
+
+/**
+ * Get the membership_end_date for a given user_id from membership_fees.
+ * Returns the date string (Y-m-d) or null if not found.
+ *
+ * @param int $user_id
+ * @return string|null
+ */
+function getMembershipEndDate($user_id)
+{
+ $conn = openDatabaseConnection();
+ if ($conn === null) {
+ return null;
+ }
+
+ $stmt = $conn->prepare("SELECT membership_end_date FROM membership_fees WHERE user_id = ? LIMIT 1");
+ if (!$stmt) {
+ $conn->close();
+ return null;
+ }
+
+ $stmt->bind_param('i', $user_id);
+ $stmt->execute();
+ $stmt->bind_result($membership_end_date);
+ $found = $stmt->fetch();
+ $stmt->close();
+ $conn->close();
+
+ if ($found) {
+ return $membership_end_date;
+ }
+
+ return null;
+}
+
diff --git a/src/pages/bookings/bookings.php b/src/pages/bookings/bookings.php
index 9e215473..02406bb9 100644
--- a/src/pages/bookings/bookings.php
+++ b/src/pages/bookings/bookings.php
@@ -114,6 +114,7 @@ $user_id = $_SESSION['user_id'];
// Loop through each row
while ($row = $result->fetch_assoc()) {
$booking_id = $row['booking_id'];
+ $payment_id = $row['payment_id'];
$booking_type = $row['booking_type'];
$from_date = $row['from_date'];
$to_date = $row['to_date'];
@@ -267,8 +268,8 @@ $user_id = $_SESSION['user_id'];
num_rows == 0) {
$button_text = "No booking dates available";
@@ -189,8 +189,9 @@ $page_id = 'driver_training';
+
diff --git a/src/pages/bookings/trip-details.php b/src/pages/bookings/trip-details.php
index 54071272..3e4e39d5 100644
--- a/src/pages/bookings/trip-details.php
+++ b/src/pages/bookings/trip-details.php
@@ -205,30 +205,30 @@ include_once(dirname(dirname(dirname(__DIR__))) . '/header.php');