diff --git a/.htaccess b/.htaccess index 68702625..89e12b95 100644 --- a/.htaccess +++ b/.htaccess @@ -80,6 +80,11 @@ RewriteRule ^indemnity_waiver$ src/pages/other/indemnity_waiver.php [L] RewriteRule ^basic_indemnity$ src/pages/other/basic_indemnity.php [L] RewriteRule ^view_indemnity$ src/pages/other/view_indemnity.php [L] +# === PAYMENT RETURN PAGES === +RewriteRule ^success$ src/pages/payment/success.php [L] +RewriteRule ^failure$ src/pages/payment/failure.php [L] +RewriteRule ^cancel$ src/pages/payment/cancel.php [L] + # === ADMIN PAGES === RewriteRule ^admin_members$ src/admin/admin_members.php [L] RewriteRule ^admin_payments$ src/admin/admin_payments.php [L] diff --git a/.htaccess copy b/.htaccess copy new file mode 100644 index 00000000..46c835d3 --- /dev/null +++ b/.htaccess copy @@ -0,0 +1,215 @@ +# URL Rewrite Rules - Maps old URLs to new directory structure during migration + +RewriteEngine On +RewriteBase / + +# Don't rewrite existing files or directories +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d + +# === STRIP .PHP EXTENSION === +# Redirect /page.php to /page (301 permanent redirect) +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.+)\.php$ /$1 [R=301,L] +# Internally rewrite /page to /page.php if page.php exists +RewriteCond %{REQUEST_FILENAME}\.php -f +RewriteRule ^(.+)$ $1.php [L] + +# === AUTH PAGES === +RewriteRule ^login$ src/pages/auth/login.php [L] +RewriteRule ^register$ src/pages/auth/register.php [L] +RewriteRule ^forgot_password$ src/pages/auth/forgot_password.php [L] +RewriteRule ^reset_password$ src/pages/auth/reset_password.php [L] +RewriteRule ^verify$ src/pages/auth/verify.php [L] +RewriteRule ^resend_verification$ src/pages/auth/resend_verification.php [L] +RewriteRule ^change_password$ src/pages/auth/change_password.php [L] +RewriteRule ^update_password$ src/pages/auth/update_password.php [L] + +# === MEMBERSHIP PAGES === +RewriteRule ^membership$ src/pages/memberships/membership.php [L] +RewriteRule ^membership_details$ src/pages/memberships/membership_details.php [L] +RewriteRule ^membership_application$ src/pages/memberships/membership_application.php [L] +RewriteRule ^membership_payment$ src/pages/memberships/membership_payment.php [L] +RewriteRule ^renew_membership$ src/pages/memberships/renew_membership.php [L] +RewriteRule ^member_info$ src/pages/memberships/member_info.php [L] + +# === BOOKING PAGES === +RewriteRule ^bookings$ src/pages/bookings/bookings.php [L] +RewriteRule ^campsites$ src/pages/bookings/campsites.php [L] +RewriteRule ^campsite_booking$ src/pages/bookings/campsite_booking.php [L] +RewriteRule ^add_campsite$ src/pages/add_campsite.php [L] +RewriteRule ^trips$ src/pages/bookings/trips.php [L] +RewriteRule ^trip-details$ src/pages/bookings/trip-details.php [L] +RewriteRule ^course_details$ src/pages/bookings/course_details.php [L] +RewriteRule ^driver_training$ src/pages/bookings/driver_training.php [L] + +# === SHOP PAGES === +RewriteRule ^view_cart$ src/pages/shop/view_cart.php [L] +RewriteRule ^add_to_cart$ src/pages/shop/add_to_cart.php [L] +RewriteRule ^bar_tabs$ src/pages/shop/bar_tabs.php [L] +RewriteRule ^payment_confirmation$ src/pages/shop/payment_confirmation.php [L] +RewriteRule ^confirm$ src/pages/shop/confirm.php [L] +RewriteRule ^confirm2$ src/pages/shop/confirm2.php [L] + +# === GALLERY PAGES === +RewriteRule ^gallery$ src/pages/gallery/gallery.php [L] +RewriteRule ^create_album$ src/pages/gallery/create_album.php [L] +RewriteRule ^edit_album$ src/pages/gallery/create_album.php [L] +RewriteRule ^view_album$ src/pages/gallery/view_album.php [L] + +# === EVENTS & BLOG PAGES === +RewriteRule ^events$ src/pages/events/events.php [L] +RewriteRule ^blog$ src/pages/blog/blog.php [L] +RewriteRule ^blog_details$ src/pages/blog/blog_details.php [L] +RewriteRule ^best_of_the_eastern_cape_2024$ src/pages/events/best_of_the_eastern_cape_2024.php [L] +RewriteRule ^2025_agm_minutes$ src/pages/events/2025_agm_minutes.php [L] +RewriteRule ^agm_content$ src/pages/events/agm_content.php [L] +RewriteRule ^instapage$ src/pages/events/instapage.php [L] + +# === OTHER PAGES === +RewriteRule ^about$ src/pages/other/about.php [L] +RewriteRule ^contact$ src/pages/other/contact.php [L] +RewriteRule ^privacy_policy$ src/pages/other/privacy_policy.php [L] +RewriteRule ^track-map$ src/pages/track-map.php [L] +RewriteRule ^404$ src/pages/other/404.php [L] +RewriteRule ^account_settings$ src/pages/other/account_settings.php [L] +RewriteRule ^rescue_recovery$ src/pages/other/rescue_recovery.php [L] +RewriteRule ^bush_mechanics$ src/pages/other/bush_mechanics.php [L] +RewriteRule ^indemnity$ src/pages/other/indemnity.php [L] +RewriteRule ^indemnity_waiver$ src/pages/other/indemnity_waiver.php [L] +RewriteRule ^basic_indemnity$ src/pages/other/basic_indemnity.php [L] +RewriteRule ^view_indemnity$ src/pages/other/view_indemnity.php [L] + +# === ADMIN PAGES === +RewriteRule ^admin_members$ src/admin/admin_members.php [L] +RewriteRule ^admin_payments$ src/admin/admin_payments.php [L] +RewriteRule ^admin_web_users$ src/admin/admin_web_users.php [L] +RewriteRule ^admin_events$ src/admin/admin_events.php [L] +RewriteRule ^admin_course_bookings$ src/admin/admin_course_bookings.php [L] +RewriteRule ^admin_camp_bookings$ src/admin/admin_camp_bookings.php [L] +RewriteRule ^admin_trip_bookings$ src/admin/admin_trip_bookings.php [L] +RewriteRule ^admin_visitors$ src/admin/admin_visitors.php [L] +RewriteRule ^admin_efts$ src/admin/admin_efts.php [L] +RewriteRule ^admin_trips$ src/admin/admin_trips.php [L] +RewriteRule ^manage_events$ src/admin/manage_events.php [L] +RewriteRule ^manage_trips$ src/admin/manage_trips.php [L] + +# === API/AJAX ENDPOINTS === +RewriteRule ^fetch_users$ src/api/fetch_users.php [L] +RewriteRule ^fetch_drinks$ src/api/fetch_drinks.php [L] +RewriteRule ^fetch_bar_tabs$ src/api/fetch_bar_tabs.php [L] +RewriteRule ^get_campsites$ src/api/get_campsites.php [L] +RewriteRule ^get_tab_total$ src/api/get_tab_total.php [L] +RewriteRule ^google_validate_login$ src/api/google_validate_login.php [L] + +# === PROCESSORS === +RewriteRule ^validate_login$ src/processors/validate_login.php [L] +RewriteRule ^register_user$ src/processors/register_user.php [L] +RewriteRule ^process_application$ src/processors/process_application.php [L] +RewriteRule ^process_booking$ src/processors/process_booking.php [L] +RewriteRule ^process_camp_booking$ src/processors/process_camp_booking.php [L] +RewriteRule ^process_course_booking$ src/processors/process_course_booking.php [L] +RewriteRule ^process_trip_booking$ src/processors/process_trip_booking.php [L] +RewriteRule ^process_membership_payment$ src/processors/process_membership_payment.php [L] +RewriteRule ^process_payments$ src/processors/process_payments.php [L] +RewriteRule ^process_eft$ src/processors/process_eft.php [L] +RewriteRule ^submit_order$ src/processors/submit_order.php [L] +RewriteRule ^submit_pop$ src/processors/submit_pop.php [L] +RewriteRule ^process_signature$ src/processors/process_signature.php [L] +RewriteRule ^create_bar_tab$ src/processors/create_bar_tab.php [L] +RewriteRule ^update_application$ src/processors/update_application.php [L] +RewriteRule ^update_user$ src/processors/update_user.php [L] +RewriteRule ^upload_profile_picture$ src/processors/upload_profile_picture.php [L] +RewriteRule ^send_reset_link$ src/processors/send_reset_link.php [L] +RewriteRule ^logout$ src/processors/logout.php [L] +RewriteRule ^process_trip$ src/processors/process_trip.php [L] +RewriteRule ^process_event$ src/processors/process_event.php [L] +RewriteRule ^toggle_trip_published$ src/processors/toggle_trip_published.php [L] +RewriteRule ^toggle_event_published$ src/processors/toggle_event_published.php [L] +RewriteRule ^delete_trip$ src/processors/delete_trip.php [L] +RewriteRule ^delete_event$ src/processors/delete_event.php [L] +RewriteRule ^save_album$ src/processors/save_album.php [L] +RewriteRule ^update_album$ src/processors/update_album.php [L] +RewriteRule ^delete_album$ src/processors/delete_album.php [L] +RewriteRule ^delete_photo$ src/processors/delete_photo.php [L] +RewriteRule ^get_album_photos$ src/processors/get_album_photos.php [L] +RewriteRule ^link_membership_user$ src/processors/link_membership_user.php [L] +RewriteRule ^unlink_membership_user$ src/processors/unlink_membership_user.php [L] + +# Blog routes +RewriteRule ^admin_blogs$ src/pages/blog/admin_blogs.php [L] +RewriteRule ^user_blogs$ src/pages/blog/user_blogs.php [L] +RewriteRule ^blog_read$ src/pages/blog/blog_read.php [L] +RewriteRule ^blog_edit$ src/pages/blog/blog_edit.php [L] +RewriteRule ^blog_create$ src/processors/blog/blog_create.php [L] +RewriteRule ^blog_delete$ src/processors/blog/blog_delete.php [L] +RewriteRule ^publish_blog$ src/processors/blog/publish_blog.php [L] +RewriteRule ^blog_unpublish$ src/processors/blog/blog_unpublish.php [L] +RewriteRule ^submit_blog$ src/processors/blog/submit_blog.php [L] +RewriteRule ^upload_blog_image$ src/processors/blog/upload_blog_image.php [L] +RewriteRule ^autosave$ src/processors/blog/autosave.php [L] + + + +php_flag display_errors On +# php_value error_reporting -1 +RedirectMatch 403 ^/\.well-known +Options -Indexes + + + Require all denied + + +ErrorDocument 404 /404.php + + + Require all granted + Require not ip 4.222.252.98 + Require not ip 4.222.252.97 + + + + Order allow,deny + Deny from all + + + +# ALL CUSTOM ENTRIES SHOULD GO ABOVE THIS LINE +# BEGIN IWORX header +# This file was created by InterWorx-CP +# You may modify this file, but any changes made between +# BEGIN IWORX and END IWORX tags may be lost on future +# updates. Additionally, changes NOT made between these +# tags will not be recognized in the SiteWorx interface. +# END IWORX header + +# BEGIN IWORX accesscontrol +# END IWORX accesscontrol + +# BEGIN IWORX errordocs +# END IWORX errordocs + +# BEGIN IWORX mimetypes +# END IWORX mimetypes + +# BEGIN IWORX handlers +# END IWORX handlers + +# BEGIN IWORX charset +# END IWORX charset + +# BEGIN IWORX redirects +# END IWORX redirects + +# BEGIN IWORX phpvars +# END IWORX phpvars + +# BEGIN IWORX dirindex +# END IWORX dirindex + +# BEGIN IWORX hotlink +# END IWORX hotlink + +# BEGIN IWORX passwordprotection +# END IWORX passwordprotection + diff --git a/assets/images/logos/ikhokha.png b/assets/images/logos/ikhokha.png new file mode 100644 index 00000000..32f6fc3f Binary files /dev/null and b/assets/images/logos/ikhokha.png differ diff --git a/classes/iKhokhaClient.php b/classes/iKhokhaClient.php new file mode 100644 index 00000000..38beb3eb --- /dev/null +++ b/classes/iKhokhaClient.php @@ -0,0 +1,120 @@ +appId = getenv('IKHOKHA_APP_ID') ?: ($_ENV['IKHOKHA_APP_ID'] ?? ''); + $this->appSecret = getenv('IKHOKHA_APP_SECRET') ?: ($_ENV['IKHOKHA_APP_SECRET'] ?? ''); + $this->apiUrl = getenv('IKHOKHA_API_URL') ?: ($_ENV['IKHOKHA_API_URL'] ?? ''); + } + + /** + * Make a request to the iKhokha API. Signs the payload per API docs. + * $endpoint should be the path portion starting with '/public-api/...' + */ + private function request(string $endpoint, array $data, string $method = 'POST') { + // Validate apiUrl + if (empty($this->apiUrl)) { + return ['error' => true, 'errno' => 3, 'message' => 'IKHOKHA_API_URL is not configured in environment']; + } + + // If the configured API URL already contains the endpoint path, use it as-is. + if ((function_exists('str_ends_with') && str_ends_with($this->apiUrl, $endpoint)) || + (substr_compare($this->apiUrl, $endpoint, -strlen($endpoint)) === 0)) { + $url = $this->apiUrl; + } else { + $url = rtrim($this->apiUrl, '/') . $endpoint; + } + $body = json_encode($data); + + // Build payload to sign: path + body and apply escape rules per iKhokha docs + $parsed = parse_url($url); + $path = $parsed['path'] ?? $endpoint; + $payloadToSign = $path . $body; + + // Escape function from iKhokha example + $escapeString = function ($str) { + $escaped = preg_replace(['/[\\\"\'\"]/u', '/\x00/'], ['\\\\$0', '\\0'], (string)$str); + $cleaned = str_replace('\/', '/', $escaped); + return $cleaned; + }; + + $escapedPayload = $escapeString($payloadToSign); + $signature = hash_hmac('sha256', $escapedPayload, $this->appSecret); + + $ch = curl_init($url); + + $headers = [ + 'Content-Type: application/json', + "IK-APPID: {$this->appId}", + "IK-SIGN: {$signature}" + ]; + + // Optional debug logging to logs/ikhokha.log when IKHOKHA_DEBUG_LOG is true + $debugLog = getenv('IKHOKHA_DEBUG_LOG') ?: ($_ENV['IKHOKHA_DEBUG_LOG'] ?? null); + if ($debugLog) { + $logPath = dirname(__DIR__) . '/logs/ikhokha.log'; + $logEntry = [ + 'time' => date('c'), + 'url' => $url, + 'headers' => $headers, + 'body' => $data, + 'signature' => $signature + ]; + @file_put_contents($logPath, json_encode(['request' => $logEntry]) . PHP_EOL, FILE_APPEND | LOCK_EX); + } + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + + if (strtoupper($method) === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } else { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + } + + $response = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + // Log response if debug enabled + if (!empty($debugLog)) { + $logPath = dirname(__DIR__) . '/logs/ikhokha.log'; + $respEntry = [ + 'time' => date('c'), + 'http_code' => $httpCode, + 'errno' => $errno, + 'error' => $error, + 'response' => $response + ]; + @file_put_contents($logPath, json_encode(['response' => $respEntry]) . PHP_EOL, FILE_APPEND | LOCK_EX); + } + + if ($response === false) { + return ['error' => true, 'message' => $error, 'errno' => $errno]; + } + + return json_decode($response, true); + } + + /** + * Create a payment link using the iKhokha create payment endpoint. + * $body must match iKhokha request schema (amount in smallest unit, urls, externalTransactionID, etc.) + */ + public function createPaymentLink(array $body) { + return $this->request('/public-api/v1/api/payment', $body, 'POST'); + } + + public function getPaymentStatus($paymentId) { + // Use the GET status endpoint + $endpoint = '/public-api/v1/api/getStatus/' . urlencode($paymentId); + return $this->request($endpoint, [], 'GET'); + } +} diff --git a/header.php b/header.php index 6d7ded11..7d82aa03 100644 --- a/header.php +++ b/header.php @@ -327,7 +327,7 @@ if ($headerStyle === 'light') { echo "
  • My Blog Posts
  • "; } ?> -
  • Submit P.O.P
  • +
  • Log Out
  • diff --git a/index.php b/index.php index b6f7fe3d..129dbef9 100644 --- a/index.php +++ b/index.php @@ -19,6 +19,27 @@ if (!isset($_SESSION['updates_modal_shown'])) { $showUpdatesModal = false; } +// Show renew membership modal for logged-in users and where membership_fees payment_status is not PENDING RENEWAL. only show once per session +$showRenewModal = isset($_SESSION['user_id']) ? true : false; +if ($showRenewModal) { + if (!isset($_SESSION['renew_modal_shown'])) { + $_SESSION['renew_modal_shown'] = true; + } else { + $showRenewModal = false; + } + $user_id = $_SESSION['user_id']; + $stmt = $conn->prepare("SELECT payment_status FROM membership_fees WHERE user_id = ? LIMIT 1"); + $stmt->bind_param("i", $user_id); + $stmt->execute(); + $stmt->bind_result($payment_status); + $stmt->fetch(); + $stmt->close(); + + if ($payment_status === 'PENDING RENEWAL') { + $showRenewModal = false; + } +} + if (isset($_SESSION['user_id']) && isset($conn) && $conn !== null) { $userId = $_SESSION['user_id']; $stmt = $conn->prepare("SELECT user_id FROM membership_application WHERE user_id = ? AND accept_indemnity = 0 LIMIT 1"); @@ -657,34 +678,72 @@ if (countUpcomingTrips() > 0) { ?> const modal = document.getElementById('updatesModal'); const closeBtn = document.querySelector('.updates-modal-close'); const showModal = ; - - if (showModal) { - // Show modal after a short delay for better UX + const showRenewModal = ; + + if (showModal && modal) { + // Show updates modal after a short delay for better UX setTimeout(function() { modal.style.display = 'flex'; }, 500); } - - // Close modal when X is clicked - closeBtn.addEventListener('click', function() { - modal.style.display = 'none'; - }); - - // Close modal when clicking outside the modal content - modal.addEventListener('click', function(event) { - if (event.target === modal) { - modal.style.display = 'none'; + + // Close updates modal when X is clicked + if (closeBtn) { + closeBtn.addEventListener('click', function() { + if (modal) modal.style.display = 'none'; + }); + } + + // Close updates modal when clicking outside the modal content + if (modal) { + modal.addEventListener('click', function(event) { + if (event.target === modal) { + modal.style.display = 'none'; + } + }); + } + + // Show renew membership Bootstrap modal for logged-in users + try { + const renewModalEl = document.getElementById('renewModal'); + if (showRenewModal && renewModalEl && typeof bootstrap !== 'undefined') { + setTimeout(function() { + const renewModal = new bootstrap.Modal(renewModalEl); + renewModal.show(); + }, 700); } - }); + } catch (e) { + console.warn('Renew modal show failed', e); + } }); + + + +
    ×
    -

    ✨ What's New

    +

    What's New on 4WDCSA.co.za

    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'; + Secure Payment Badges
    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');
    - - - - - - -
    - -
    - + + + + + + +
    + +
    +
    @@ -296,8 +296,8 @@ include_once(dirname(dirname(dirname(__DIR__))) . '/header.php');
    - - + +
    -
    - New Membership Payment: - Membership Start Date: ' . $membership_start_date . '
    Membership Renewal Date: ' . $membership_end_date . ''; ?> +
    + New Membership Payment: + Membership Start Date: ' . $membership_start_date . '
    Membership Renewal Date: ' . $membership_end_date . ''; ?> +
    + + +
    Payment Details:
    +

    Amount: R

    +

    Reference:

    + + Pay Now with iKhokha + + +
    +

    You will be redirected to iKhokha's Secure payment gateway.

    -

    Your invoice has been sent to . Please upload your proof of payment below.

    + Secure Payment Badges + +

    Please upload your proof of payment below.

    Payment Details:

    The Four Wheel Drive Club of Southern Africa
    FNB
    Account Number: 58810022334
    Branch code: 250655
    Reference:
    Amount: R

    - Submit Proof of Payment - - + Submit Proof of Payment + + +
    @@ -102,4 +143,4 @@ $conn->close();
    - + \ No newline at end of file diff --git a/src/pages/memberships/renew_membership.php b/src/pages/memberships/renew_membership.php index f6d85f38..2c99b72c 100644 --- a/src/pages/memberships/renew_membership.php +++ b/src/pages/memberships/renew_membership.php @@ -1,26 +1,101 @@ prepare("SELECT payment_status FROM membership_fees WHERE user_id = ? LIMIT 1"); +$stmt->bind_param("i", $user_id); +$stmt->execute(); +$stmt->bind_result($payment_status); +$stmt->fetch(); +$stmt->close(); + +if ($payment_status === 'PENDING RENEWAL') { + header("Location: membership_details.php"); + exit(); +} + +$payment_id = generatePaymentRef('SUBS', null, $user_id); +$payment_amount = getPriceByDescription('membership_fees'); $payment_date = date('Y-m-d'); -$membership_start_date = date('Y-01-01'); -$membership_end_date = date('Y-12-31'); +$renewal_period_end = getMembershipEndDate($user_id); +// Hardcode membership start date to 2026-03-01 per request +$renewed_membership_start_date = '2026-03-01'; -$stmt = $conn->prepare("UPDATE membership_fees SET payment_amount = ?, payment_date = ?, membership_start_date = ?, membership_end_date = ?, payment_status = 'PENDING', payment_id = ? WHERE user_id = ?"); -$stmt->bind_param("dssssi", $payment_amount, $payment_date, $membership_start_date, $membership_end_date, $eft_id, $user_id); +// Set membership_end_date to the last day of February in the following year +$renewed_membership_end_date = '2027-02-28'; + +$stmt = $conn->prepare("UPDATE membership_fees SET payment_amount = ?, payment_date = ?, membership_start_date = ?, membership_end_date = ?, renewal_period_end = ?, payment_status = 'PENDING RENEWAL', payment_id = ? WHERE user_id = ?"); +$stmt->bind_param("dsssssi", $payment_amount, $payment_date, $renewed_membership_start_date, $renewed_membership_end_date, $renewal_period_end, $payment_id, $user_id); if ($stmt->execute()) { // Commit the transaction $conn->commit(); - addSubsEFT($eft_id, $user_id, $status, $payment_amount, $description); + + // Audit: user initiated membership renewal + if (function_exists('auditLog')) { + auditLog($user_id, 'MEMBERSHIP_RENEWAL_INITIATED', 'membership_fees', null, ['payment_id' => $payment_id, 'amount' => $payment_amount]); + } + + $checkP = $conn->prepare("SELECT COUNT(*) AS cnt FROM payments WHERE payment_id = ? LIMIT 1"); + if ($checkP) { + $checkP->bind_param('s', $payment_id); + $checkP->execute(); + $r = $checkP->get_result()->fetch_assoc(); + $exists = intval($r['cnt']) > 0; + $checkP->close(); + } else { + $exists = false; + } + + if (!$exists) { + $publicRef = bin2hex(random_bytes(16)); + // If current month is December, attribute the membership year to the next year + $currentYear = intval(date('Y')); + $month = intval(date('n')); + if ($month === 12) { + $membershipYear = $currentYear + 1; + } else { + $membershipYear = $currentYear; + } + $description = 'Membership Fees ' . $membershipYear . ' ' . getInitialSurname($user_id); + $status = 'AWAITING PAYMENT'; + $ins = $conn->prepare("INSERT INTO payments (payment_id, user_id, amount, status, description, public_ref) VALUES (?, ?, ?, ?, ?, ?)"); + if ($ins) { + $ins->bind_param('sidsss', $payment_id, $user_id, $payment_amount, $status, $description, $publicRef); + $ins->execute(); + $ins->close(); + } + } + + // Create iKhokha paylink via helper (functions.php) + try { + $publicRef = $publicRef ?? bin2hex(random_bytes(16)); + $resp = createIkhokhaPayment($payment_id, $payment_amount, $description, $publicRef); + $paylink = $resp['paylinkUrl'] ?? $resp['paylinkURL'] ?? $resp['paylink_url'] ?? null; + $token = encryptData($payment_id, $_ENV['SALT']); + if ($paylink) { + header('Location: membership_payment?token=' . $token); + exit(); + } else { + header("Location: membership_details"); + exit(); + } + } catch (Exception $e) { + // Log but do not fail signature save + error_log('iKhokha create error: ' . $e->getMessage()); + } + + header("Location:membership_payment.php"); // Success message $response = [ diff --git a/src/pages/other/bush_mechanics.php b/src/pages/other/bush_mechanics.php index 431dfb86..19e47008 100644 --- a/src/pages/other/bush_mechanics.php +++ b/src/pages/other/bush_mechanics.php @@ -156,7 +156,7 @@ $page_id = 'bush_mechanics'; num_rows == 0) { $button_text = "No booking dates available"; @@ -168,8 +168,9 @@ $page_id = 'bush_mechanics';
    - Need some help? + You will be redirected to iKhokha's Secure payment gateway.
    + Secure Payment Badges diff --git a/src/pages/other/indemnity.php b/src/pages/other/indemnity.php index c6f1c43e..ba49c32f 100644 --- a/src/pages/other/indemnity.php +++ b/src/pages/other/indemnity.php @@ -105,17 +105,29 @@ if (isset($_SESSION['user_id'])) { response = JSON.parse(response); } if (response.status === 'success') { - // Check if the user has paid + // If provider returned a direct paylink, go there immediately + if (response.paylinkUrl) { + window.location.href = 'membership_payment?token=' + encodeURIComponent(response.token); + return; + } + + // If we have a payment_id, redirect to membership_payment with it + // if (response.payment_id) { + // setTimeout(function() { + // window.location.href = 'membership_payment.php?payment_id=' + encodeURIComponent(response.token); + // }, 800); + // return; + // } + + // Fallback behaviour: check paymentStatus if (response.paymentStatus === 'PAID') { - // Redirect to membership_details.php if paid setTimeout(function() { window.location.href = 'membership_details.php'; - }, 2000); // 2-second delay before redirecting + }, 1200); } else { - // Redirect to membership_payment.php if not paid setTimeout(function() { window.location.href = 'membership_payment.php'; - }, 2000); // 2-second delay before redirecting + }, 1200); } } else { $('#responseMessage').html('
    ' + response.message + '
    '); diff --git a/src/pages/other/rescue_recovery.php b/src/pages/other/rescue_recovery.php index 85c12514..3fd83f12 100644 --- a/src/pages/other/rescue_recovery.php +++ b/src/pages/other/rescue_recovery.php @@ -154,7 +154,7 @@ $page_id = 'rescue_recovery'; num_rows == 0) { $button_text = "No booking dates available"; @@ -165,9 +165,11 @@ $page_id = 'rescue_recovery'; +
    - Need some help? + You will be redirected to iKhokha's Secure payment gateway.
    + Secure Payment Badges diff --git a/src/pages/payment/cancel.php b/src/pages/payment/cancel.php new file mode 100644 index 00000000..0aab44f6 --- /dev/null +++ b/src/pages/payment/cancel.php @@ -0,0 +1,73 @@ +prepare("SELECT payment_id, amount, payment_link, status, provider, provider_payment_id, public_ref, description FROM payments WHERE public_ref = ? OR payment_id = ? LIMIT 1"); + if ($stmt) { + $stmt->bind_param('ss', $ref, $ref); + $stmt->execute(); + $res = $stmt->get_result(); + if ($row = $res->fetch_assoc()) { + $payment = $row; + } else { + $error_message = 'Payment record not found for the supplied reference.'; + } + $stmt->close(); + } else { + $error_message = 'Database error: ' . $conn->error; + } +} else { + $error_message = 'No reference supplied.'; +} + +$pageTitle = 'Payment Cancelled'; +$breadcrumbs = [['Home' => 'index.php'], ['Payment' => 'membership_payment.php']]; +require_once($rootPath . '/components/banner.php'); +?> +
    +
    +
    +
    +
    + Payment Cancelled +
    Your payment was cancelled or you returned without completing it.
    +
    + + +
    + +

    Your payment appears to have been cancelled. If this was a mistake you can try again below.

    +
      +
    • Reference:
    • +
    • Amount: R
    • +
    • Description:
    • +
    + + + + Retry Payment + + + + +

    Contact info@4wdcsa.co.za if you need assistance.

    + +
    + +
    +
    + Logo +
    +
    + +
    +
    +
    + + diff --git a/src/pages/payment/failure.php b/src/pages/payment/failure.php new file mode 100644 index 00000000..1491b4ec --- /dev/null +++ b/src/pages/payment/failure.php @@ -0,0 +1,75 @@ +prepare("SELECT payment_id, amount, payment_link, status, provider, provider_payment_id, public_ref, description FROM payments WHERE public_ref = ? OR payment_id = ? LIMIT 1"); + if ($stmt) { + $stmt->bind_param('ss', $ref, $ref); + $stmt->execute(); + $res = $stmt->get_result(); + if ($row = $res->fetch_assoc()) { + $payment = $row; + } else { + $error_message = 'Payment record not found for the supplied reference.'; + } + $stmt->close(); + } else { + $error_message = 'Database error: ' . $conn->error; + } +} else { + $error_message = 'No reference supplied.'; +} + +$pageTitle = 'Payment Failed'; +$breadcrumbs = [['Home' => 'index.php'], ['Payment' => 'membership_payment.php']]; +require_once($rootPath . '/components/banner.php'); +?> +
    +
    +
    +
    +
    + Payment Failed +
    Unfortunately your payment could not be completed.
    +
    + + +
    + +

    We were unable to process your payment. You can try again or contact support for assistance.

    +
      +
    • Reference:
    • +
    • Amount: R
    • +
    • Provider:
    • +
    • Description:
    • +
    • Status:
    • +
    + + + + Try Again + + + + +

    Or contact info@4wdcsa.co.za for help.

    + +
    + +
    +
    + Logo +
    +
    + +
    +
    +
    + + diff --git a/src/pages/payment/success.php b/src/pages/payment/success.php new file mode 100644 index 00000000..11b36dad --- /dev/null +++ b/src/pages/payment/success.php @@ -0,0 +1,84 @@ +prepare("SELECT payment_id, amount, payment_link, status, provider, provider_payment_id, public_ref, description, booking_id FROM payments WHERE public_ref = ? OR payment_id = ? LIMIT 1"); + if ($stmt) { + $stmt->bind_param('ss', $ref, $ref); + $stmt->execute(); + $res = $stmt->get_result(); + if ($row = $res->fetch_assoc()) { + $payment = $row; + } else { + $error_message = 'Payment record not found for the supplied reference.'; + } + $stmt->close(); + } else { + $error_message = 'Database error: ' . $conn->error; + } +} else { + $error_message = 'No reference supplied.'; +} + +$pageTitle = 'Payment Successful'; +$breadcrumbs = [['Home' => 'index.php'], ['Payment' => 'membership_payment.php']]; +require_once($rootPath . '/components/banner.php'); +?> +
    +
    +
    +
    +
    + Payment Successful +
    Thank you — your payment was received.
    +
    + +
    MEMBERSHIP STATUS:
    + + + +
    + +

    Your payment has been processed successfully. Below are the details we received:

    +
      +
    • Reference:
    • +
    • Amount: R
    • +
    • Provider:
    • +
    • Description:
    • +
    • Status:
    • +
    + + + + Go to Membership Details + + + + + Go to my Bookings + + + +
    + +
    +
    + Logo +
    +
    + +
    +
    +
    + + diff --git a/src/processors/process_application.php b/src/processors/process_application.php index b3347e0b..866c3a35 100644 --- a/src/processors/process_application.php +++ b/src/processors/process_application.php @@ -6,9 +6,17 @@ require_once($rootPath . "/src/config/connection.php"); require_once($rootPath . "/src/config/functions.php"); $user_id = isset($_SESSION['user_id']) ? $_SESSION['user_id'] : null; -$eft_id = strtoupper($user_id." SUBS ".date("Y")." ".getInitialSurname($user_id)); +$payment_id = generatePaymentRef('SUBS', null, $user_id); $status = 'AWAITING PAYMENT'; -$description = 'Membership Fees '.date("Y")." ".getInitialSurname($user_id); +// If current month is December, attribute the membership year to the next year +$currentYear = intval(date('Y')); +$month = intval(date('n')); +if ($month === 12) { + $membershipYear = $currentYear + 1; +} else { + $membershipYear = $currentYear; +} +$description = 'Membership Fees ' . $membershipYear . ' ' . getInitialSurname($user_id); if ($_SERVER['REQUEST_METHOD'] === 'POST') { // CSRF Token Validation @@ -202,16 +210,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ->format('Y-m-d'); } - $stmt = $conn->prepare("INSERT INTO membership_fees (user_id, payment_amount, payment_date, membership_start_date, membership_end_date, payment_status, payment_id) - VALUES (?, ?, ?, ?, ?, 'PENDING', ?)"); - $stmt->bind_param("idssss", $user_id, $payment_amount, $payment_date, $membership_start_date, $membership_end_date, $eft_id); + $stmt = $conn->prepare("INSERT INTO membership_fees (user_id, payment_amount, payment_date, membership_start_date, membership_end_date, renewal_period_end, payment_status, payment_id) + VALUES (?, ?, ?, ?, ?, ?, 'AWAITING PAYMENT', ?)"); + $stmt->bind_param("idsssss", $user_id, $payment_amount, $payment_date, $membership_start_date, $membership_end_date, $membership_end_date, $payment_id); if ($stmt->execute()) { // Commit the transaction $conn->commit(); - addSubsEFT($eft_id, $user_id, $status, $payment_amount, $description); - sendInvoice(getEmail($user_id), getFullName($user_id), $eft_id, formatCurrency($payment_amount), $description); - sendAdminNotification('4WDCSA.co.za - New Membership Application - '.$last_name , 'A new member has signed up, '.$first_name.' '.$last_name); + // Do not create legacy EFTs. Create a payments-ready membership fee and notify admin. + // Optionally send an invoice referencing the internal payment id + // sendInvoice(getEmail($user_id), getFullName($user_id), $payment_id, formatCurrency($payment_amount), $description); + // sendAdminNotification('4WDCSA.co.za - New Membership Application - '.$last_name , 'A new member has signed up, '.$first_name.' '.$last_name); + // Audit: membership application submitted + if (function_exists('auditLog')) { + auditLog($user_id, 'MEMBERSHIP_APPLICATION_SUBMITTED', 'membership_application', null, ['payment_id' => $payment_id, 'amount' => $payment_amount ?? null]); + } header("Location: indemnity"); // Success message $response = [ diff --git a/src/processors/process_booking.php b/src/processors/process_booking.php index fb9d9c13..c5fc251b 100644 --- a/src/processors/process_booking.php +++ b/src/processors/process_booking.php @@ -79,6 +79,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt->bind_param('sissiiiidd', $type, $user_id, $from_date, $to_date, $num_vehicles, $num_adults, $num_children, $add_firewood, $total_amount, $discount_amount); if ($stmt->execute()) { + // Get booking id and audit + $booking_id = $conn->insert_id; + if (function_exists('auditLog')) { + auditLog($user_id, 'BOOKING_CREATED', 'bookings', $booking_id, ['total_amount' => $total_amount, 'from' => $from_date, 'to' => $to_date]); + } // Redirect to success page or display success message echo ""; } else { diff --git a/src/processors/process_course_booking.php b/src/processors/process_course_booking.php index 098abab0..16fd7404 100644 --- a/src/processors/process_course_booking.php +++ b/src/processors/process_course_booking.php @@ -93,10 +93,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $status = "AWAITING PAYMENT"; $type = 'course'; - $payment_id = uniqid(); + $payment_id = generatePaymentRef('COURSE', $course_id, $user_id); + $publicRef = bin2hex(random_bytes(16)); $num_vehicles = 1; $discountAmount = 0; - $eft_id = strtoupper("COURSE ".date("m-d", strtotime($date))." ".getInitialSurname($user_id)); + $eft_id = $payment_id; $notes = ""; if ($pending_member){ $notes = "Membership Payment pending at time of booking. Please confirm payment has been received."; @@ -117,6 +118,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($stmt->execute()) { $booking_id = $conn->insert_id; + // Audit booking creation + if (function_exists('auditLog')) { + auditLog($user_id, 'COURSE_BOOKING_CREATED', 'bookings', $booking_id, ['course_id' => $course_id, 'payment_id' => $payment_id, 'amount' => $payment_amount]); + } + if ($payment_amount < 1) { if (processZeroPayment($payment_id, $payment_amount, $description)) { echo ""; @@ -125,11 +131,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { echo "Error processing booking: $error_message"; } } else { - addEFT($eft_id, $booking_id, $user_id, $status, $payment_amount, $description); - sendInvoice(getEmail($user_id), getFullName($user_id), $eft_id, formatCurrency($payment_amount), $description); + // Create payments row + $pstmt = $conn->prepare("INSERT INTO payments (payment_id, user_id, amount, status, description, booking_id, public_ref) VALUES (?, ?, ?, ?, ?, ?, ?)"); + if ($pstmt) { + $pstmt->bind_param('sidssis', $payment_id, $user_id, $payment_amount, $status, $description, $booking_id, $publicRef); + $pstmt->execute(); + $pstmt->close(); + } + + // Create iKhokha payment link + $resp = createIkhokhaPayment($payment_id, $payment_amount, $description, $publicRef); + + // Send invoice and admin notification (keep for records) + // sendInvoice(getEmail($user_id), getFullName($user_id), $eft_id, formatCurrency($payment_amount), $description); sendAdminNotification('New Course Booking - '.getFullName($user_id), getFullName($user_id).' has booked for '.$description); - header("Location: payment_confirmation?token=".encryptData($booking_id, $salt)); - exit(); // Ensure no further code is executed after the redirect + + // Redirect user to payment link if available + $paylink = $resp['paylinkUrl'] ?? $resp['paylinkURL'] ?? $resp['paylink_url'] ?? null; + if ($paylink) { + header('Location: ' . $paylink); + exit(); + } else { + // Fallback: redirect to legacy payment confirmation page + header("Location: payment_confirmation?token=".encryptData($booking_id, $salt)); + exit(); + } } } else { // Handle error if insert fails and echo the MySQL error diff --git a/src/processors/process_membership_payment.php b/src/processors/process_membership_payment.php index cede548e..8d81ddfa 100644 --- a/src/processors/process_membership_payment.php +++ b/src/processors/process_membership_payment.php @@ -15,64 +15,92 @@ if (!$user_id) { echo ""; exit(); } -$is_member = getUserMemberStatus($user_id); -$query = "SELECT payment_amount, payment_status, membership_end_date FROM membership_fees WHERE user_id = ?"; +// Fetch the membership fee record for this user +$query = "SELECT fee_id, payment_amount, payment_status, membership_end_date FROM membership_fees WHERE user_id = ?"; $stmt = $conn->prepare($query); +if (!$stmt) { + http_response_code(500); + echo json_encode(['error' => 'Server error preparing statement']); + exit(); +} $stmt->bind_param('i', $user_id); $stmt->execute(); $result = $stmt->get_result(); -// Check if trip exists +// Check if membership fee exists if ($result->num_rows === 0) { - $response = ['error' => 'Application Fee not found.']; + $response = ['error' => 'Membership fee not found.']; header('Content-Type: application/json'); echo json_encode($response); exit(); } -// Fetch trip details +// Fetch fee details $fee = $result->fetch_assoc(); +$fee_id = isset($fee['fee_id']) ? intval($fee['fee_id']) : null; $payment_status = $fee['payment_status']; $membership_end_date = $fee['membership_end_date']; -$payment_amount = intval($fee['payment_amount']); +$payment_amount = floatval($fee['payment_amount']); +$publicRef = bin2hex(random_bytes(16)); $description = "4WDCSA: Membership Fee " . getFullName($user_id) . " " . date("Y"); $payment_id = uniqid(); -$eft_id = "SUBS 2025 ".getLastName($user_id); -// Update the membership_fees table to set payment_id -$stmt = $conn->prepare("UPDATE membership_fees SET payment_id = ? WHERE user_id = ?"); -if ($stmt) { - $stmt->bind_param("ss", $payment_id, $user_id); - - if (!$stmt->execute()) { - throw new Exception("Failed to update membership_fees table."); +// Persist the generated payment_id back to the membership_fees row (use fee_id to be precise) +$updateStmt = $conn->prepare("UPDATE membership_fees SET payment_id = ? WHERE fee_id = ?"); +if ($updateStmt) { + $updateStmt->bind_param("si", $payment_id, $fee_id); + if (!$updateStmt->execute()) { + throw new Exception("Failed to update membership_fees table: " . $updateStmt->error); } - - $stmt->close(); - $conn->close(); + $updateStmt->close(); } else { throw new Exception("Failed to prepare statement for membership_fees table: " . $conn->error); } -// Get the current date -$current_date = new DateTime(); +// If the amount is zero, treat as paid immediately +if ($payment_amount < 1) { + if (processZeroPayment($payment_id, $payment_amount, $description)) { + // Update membership_fees status to PAID + $paidStmt = $conn->prepare("UPDATE membership_fees SET payment_status = 'PAID' WHERE fee_id = ?"); + if ($paidStmt) { + $paidStmt->bind_param('i', $fee_id); + $paidStmt->execute(); + $paidStmt->close(); + } + echo ""; + exit(); + } else { + echo ""; + exit(); + } +} else { + // Create payments row + $status = "AWAITING PAYMENT"; + $pstmt = $conn->prepare("INSERT INTO payments (payment_id, user_id, amount, status, description, public_ref) VALUES (?, ?, ?, ?, ?, ?)"); + if ($pstmt) { + $pstmt->bind_param('sidsss', $payment_id, $user_id, $payment_amount, $status, $description, $publicRef); + $pstmt->execute(); + $pstmt->close(); + } -// Convert $membership_end_date to a DateTime object -$membership_end_date_obj = DateTime::createFromFormat('Y-m-d', $membership_end_date); + // Create iKhokha payment link + $resp = createIkhokhaPayment($payment_id, $payment_amount, $description, $publicRef); -// Check if the current date is after membership_end_date -// OR if the current date is before or on membership_end_date AND payment_status is "PENDING" -if ( - $current_date > $membership_end_date_obj || - ($current_date <= $membership_end_date_obj && $payment_status === "PENDING") -) { + // Send invoice and admin notification if desired + // sendInvoice(getEmail($user_id), getFullName($user_id), 'MEMBERSHIP-'.date('Y'), formatCurrency($payment_amount), $description); + sendAdminNotification('Membership Payment Initiated - '.getFullName($user_id), getFullName($user_id).' initiated a membership payment.'); - // Call the processMembershipPayment function - // processMembershipPayment($payment_id, $payment_amount, $description); - addMembershipEFT($eft_id, $user_id, $status, $amount, $description, $membershipfee_id); - header("Location: payment_confirmation?booking_id=" . $booking_id); - exit(); // Ensure no further code is executed after the redirect + // Redirect user to payment link if available + $paylink = $resp['paylinkUrl'] ?? $resp['paylinkURL'] ?? $resp['paylink_url'] ?? null; + if ($paylink) { + header('Location: ' . $paylink); + exit(); + } else { + // Fallback: redirect to a membership page with an encrypted token + header("Location: membership_confirmation?token=" . encryptData($payment_id, $salt)); + exit(); + } } diff --git a/src/processors/process_signature.php b/src/processors/process_signature.php index c504b12c..f0c6694a 100644 --- a/src/processors/process_signature.php +++ b/src/processors/process_signature.php @@ -56,17 +56,122 @@ if (isset($_POST['signature'])) { $stmt->bind_param('si', $display_path, $user_id); if ($stmt->execute()) { + // Audit: signature saved + if (function_exists('auditLog')) { + auditLog($user_id, 'SIGNATURE_SAVED', 'membership_application', null, ['path' => $display_path]); + } // Check the payment status $paymentStatus = checkMembershipPaymentStatus($user_id) ? 'PAID' : 'NOT_PAID'; - // Respond with the appropriate redirect URL based on the payment status + // If not paid, create a payments row (if missing) and initiate iKhokha paylink + $paylink = null; + if ($paymentStatus !== 'PAID') { + // Fetch the membership fee row to get amount and payment_id + $mfStmt = $conn->prepare("SELECT fee_id, payment_amount, payment_id FROM membership_fees WHERE user_id = ? ORDER BY fee_id DESC LIMIT 1"); + if ($mfStmt) { + $mfStmt->bind_param('i', $user_id); + $mfStmt->execute(); + $mfRes = $mfStmt->get_result(); + $mf = $mfRes->fetch_assoc(); + $mfStmt->close(); + } else { + $mf = null; + } + + if ($mf && isset($mf['payment_amount'])) { + $amount = floatval($mf['payment_amount']); + // Use existing payment_id or generate one + $payment_id = $mf['payment_id'] ?? generatePaymentRef('SUBS', null, $user_id);; + + if (empty($mf['payment_id'])) { + // Persist generated payment_id back to membership_fees + $u = $conn->prepare("UPDATE membership_fees SET payment_id = ? WHERE fee_id = ?"); + if ($u) { + $u->bind_param('si', $payment_id, $mf['fee_id']); + $u->execute(); + $u->close(); + } + } + + // Ensure a payments row exists + $checkP = $conn->prepare("SELECT COUNT(*) AS cnt FROM payments WHERE payment_id = ? LIMIT 1"); + if ($checkP) { + $checkP->bind_param('s', $payment_id); + $checkP->execute(); + $r = $checkP->get_result()->fetch_assoc(); + $exists = intval($r['cnt']) > 0; + $checkP->close(); + } else { + $exists = false; + } + + if (!$exists) { + $publicRef = bin2hex(random_bytes(16)); + // If current month is December, attribute the membership year to the next year + $currentYear = intval(date('Y')); + $month = intval(date('n')); + if ($month === 12) { + $membershipYear = $currentYear + 1; + } else { + $membershipYear = $currentYear; + } + $description = 'Membership Fees ' . $membershipYear . ' ' . getInitialSurname($user_id); + $status = 'AWAITING PAYMENT'; + $ins = $conn->prepare("INSERT INTO payments (payment_id, user_id, amount, status, description, public_ref) VALUES (?, ?, ?, ?, ?, ?)"); + if ($ins) { + $ins->bind_param('sidsss', $payment_id, $user_id, $amount, $status, $description, $publicRef); + if ($ins->execute()) { + // Audit: payment row created for membership + if (function_exists('auditLog')) { + auditLog($user_id, 'MEMBERSHIP_PAYMENT_CREATED', 'payments', null, ['payment_id' => $payment_id, 'amount' => $amount]); + } + } + $ins->close(); + } + } + + // Create iKhokha paylink via helper (functions.php) + try { + $publicRef = $publicRef ?? bin2hex(random_bytes(16)); + $resp = createIkhokhaPayment($payment_id, $amount, $desc ?? ('Membership Fee ' . date('Y')), $publicRef); + $paylink = $resp['paylinkUrl'] ?? $resp['paylinkURL'] ?? $resp['paylink_url'] ?? null; + // After creating paylink, update paymentStatus to AWAITING PAYMENT + $paymentStatus = $paylink ? 'AWAITING PAYMENT' : $paymentStatus; + $token = encryptData($payment_id, $_ENV['SALT']); + // Audit: paylink created (or attempted) + if (function_exists('auditLog')) { + auditLog($user_id, 'IKHOKHA_PAYLINK_CREATED', 'payments', null, ['payment_id' => $payment_id, 'paylink' => $paylink]); + } + } catch (Exception $e) { + // Log but do not fail signature save + error_log('iKhokha create error: ' . $e->getMessage()); + if (function_exists('auditLog')) { + auditLog($user_id, 'IKHOKHA_PAYLINK_FAILED', 'payments', null, ['payment_id' => $payment_id, 'error' => $e->getMessage()]); + } + } + } + } + + // Respond with the appropriate redirect URL and paylink (if created) ob_end_clean(); - echo json_encode([ + $response = [ 'status' => 'success', 'message' => 'Signature saved successfully!', - 'paymentStatus' => $paymentStatus // Send payment status - ]); + 'paymentStatus' => $paymentStatus, + 'token' => $token ?? null + ]; + if (!empty($paylink)) { + $response['paylinkUrl'] = $paylink; + } + if (!empty($payment_id)) { + $response['payment_id'] = $payment_id; + } + echo json_encode($response); } else { + // Audit: signature save failed + if (function_exists('auditLog')) { + auditLog($user_id, 'SIGNATURE_SAVE_FAILED', 'membership_application', null, ['user_id' => $user_id]); + } ob_end_clean(); echo json_encode(['status' => 'error', 'message' => 'Database update failed']); } @@ -78,6 +183,10 @@ if (isset($_POST['signature'])) { echo json_encode(['status' => 'error', 'message' => 'Failed to save signature']); } } else { + // Audit: no signature provided in request + if (function_exists('auditLog') && isset($_SESSION['user_id'])) { + auditLog($_SESSION['user_id'], 'SIGNATURE_NOT_PROVIDED', 'membership_application', null, ['endpoint' => 'process_signature.php']); + } ob_end_clean(); echo json_encode(['status' => 'error', 'message' => 'Signature not provided']); } diff --git a/src/processors/process_trip_booking.php b/src/processors/process_trip_booking.php index fe7916c8..a71a958e 100644 --- a/src/processors/process_trip_booking.php +++ b/src/processors/process_trip_booking.php @@ -78,6 +78,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $member_discount = $cost_nonmembers - $cost_members; $member_discount_pensioner = $cost_pensioner - $cost_pensioner_member; $booking_fee = $trip['booking_fee']; + // Radio option (boolean/int) — ensure defined from POST + $radio = isset($_POST['radio']) ? intval($_POST['radio']) : 0; $radioCost = $radio ? 50 : 0; $start_date = $trip['start_date']; // Start date of the trip $end_date = $trip['end_date']; // End date of the trip @@ -103,9 +105,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $status = "AWAITING PAYMENT"; $description = $trip_name; $type = 'trip'; - $payment_id = uniqid(); + $payment_id = generatePaymentRef('TRIP', $trip_id, $user_id); + $publicRef = bin2hex(random_bytes(16)); // $eft_id = strtoupper(base_convert(time(), 10, 36)); // Convert timestamp to base36 - $eft_id = strtoupper($trip_code." ".getInitialSurname($user_id)); + // $eft_id = strtoupper($trip_code." ".getInitialSurname($user_id)); // Insert booking into the database @@ -123,6 +126,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Get the generated booking_id $booking_id = $conn->insert_id; + // Audit booking creation + if (function_exists('auditLog')) { + auditLog($user_id, 'TRIP_BOOKING_CREATED', 'bookings', $booking_id, ['trip_id' => $trip_id, 'payment_id' => $payment_id, 'amount' => $payment_amount]); + } + if ($payment_amount < 1) { if (processZeroPayment($payment_id, $payment_amount, $description)) { echo ""; @@ -131,11 +139,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { echo "Error processing booking: $error_message"; } } else { - addEFT($eft_id, $booking_id, $user_id, $status, $payment_amount, $description); - sendInvoice(getEmail($user_id), getFullName($user_id), $eft_id, formatCurrency($payment_amount), $description); - sendAdminNotification('New Trip Booking - '.getFullName($user_id), getFullName($user_id).' has booked for '.$description); - header("Location: payment_confirmation?token=".encryptData($booking_id, $salt)); - exit(); // Ensure no further code is executed after the redirect + // Create payments row + $pstmt = $conn->prepare("INSERT INTO payments (payment_id, user_id, amount, status, description, booking_id, public_ref) VALUES (?, ?, ?, ?, ?, ?, ?)"); + if ($pstmt) { + $pstmt->bind_param('sidssis', $payment_id, $user_id, $payment_amount, $status, $description, $booking_id, $publicRef); + $pstmt->execute(); + $pstmt->close(); + } + + // Create iKhokha payment link + $resp = createIkhokhaPayment($payment_id, $payment_amount, $description, $publicRef); + + // Send invoice and admin notification + // sendInvoice(getEmail($user_id), getFullName($user_id), $eft_id, formatCurrency($payment_amount), $description); + sendAdminNotification('New Trip Booking - '.getFullName($user_id), getFullName($user_id).' has booked for '.$description); + + // Redirect to payment link if available + $paylink = $resp['paylinkUrl'] ?? $resp['paylinkURL'] ?? $resp['paylink_url'] ?? null; + if ($paylink) { + header('Location: ' . $paylink); + exit(); + } else { + header("Location: payment_confirmation?token=".encryptData($booking_id, $salt)); + exit(); + } } } else { // Handle error if insert fails and echo the MySQL error diff --git a/test.php b/test.php new file mode 100644 index 00000000..011cbbf8 --- /dev/null +++ b/test.php @@ -0,0 +1,41 @@ +IK-SIGN FROM WEBHOOK:
    "; +echo "bb1702d488a40091ebd5414bc6f524e203e2c5e36b24a1b86e243dad440bb557

    "; + +$payloadToSign = $path . $raw; + +// Generate signature using hash_hmac directly on the constructed string +$expected = hash_hmac('sha256', $payloadToSign, $secret); + +// --- Output debug info (UPDATED) --- +echo "DEBUG INFO
    "; +echo "Callback URL: $callbackUrl

    "; + +echo "Payload to Sign (Un-escaped):
    "; +echo htmlspecialchars($payloadToSign) . "

    "; + +echo "EXPECTED SIGNATURE:
    "; +echo $expected . "
    "; \ No newline at end of file diff --git a/test_payment.php b/test_payment.php new file mode 100644 index 00000000..11759867 --- /dev/null +++ b/test_payment.php @@ -0,0 +1,60 @@ + "4", + "externalEntityID" => "4", + "amount" => 1000, + "currency" => "ZAR", + "requesterUrl" => "https://beta.4wdcsa.co.za/requester", + "description" => "Test Description 1", + "paymentReference" => "4", + "mode" => "sandbox", + "externalTransactionID" => "5", + "urls" => [ + "callbackUrl" => "https://beta.4wdcsa.co.za/callback", + "successPageUrl" => "https://beta.4wdcsa.co.za/success", + "failurePageUrl" => "https://beta.4wdcsa.co.za/failure", + "cancelUrl" => "https://beta.4wdcsa.co.za/cancel" + ] +]; +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); +} +$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 +$responseData = json_decode($response, true); +echo print_r($responseData, true); +?> diff --git a/uploads/signatures/signature_163.png b/uploads/signatures/signature_163.png new file mode 100644 index 00000000..27bcf4f9 Binary files /dev/null and b/uploads/signatures/signature_163.png differ