From 477c2f2e04534ad74baa8bac372fe9b99aeebd1e Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:36:34 +0200 Subject: [PATCH] iKhokha integration complete --- .htaccess | 5 + .htaccess copy | 215 ++++++++++++++ classes/iKhokhaClient.php | 120 ++++++++ header.php | 2 +- progress.log | 48 +++ .../001_add_payment_columns.sql | 61 ++++ .../002_migrate_efts_to_payments.sql | 14 + scripts/ikhokha_migrations/README.md | 21 ++ src/api/ikhokha_webhook.php | 274 ++++++++++++++++++ src/api/test_log.php | 10 + src/config/functions.php | 184 ++++++++++++ src/pages/memberships/membership_details.php | 6 +- src/pages/memberships/membership_payment.php | 46 ++- src/pages/memberships/renew_membership.php | 78 ++++- src/pages/other/indemnity.php | 22 +- src/pages/payment/cancel.php | 73 +++++ src/pages/payment/failure.php | 75 +++++ src/pages/payment/success.php | 84 ++++++ src/processors/process_application.php | 23 +- src/processors/process_course_booking.php | 29 +- src/processors/process_membership_payment.php | 92 ++++-- src/processors/process_signature.php | 91 +++++- src/processors/process_trip_booking.php | 32 +- test.php | 41 +++ test_payment.php | 60 ++++ uploads/signatures/signature_163.png | Bin 0 -> 9888 bytes 26 files changed, 1625 insertions(+), 81 deletions(-) create mode 100644 .htaccess copy create mode 100644 classes/iKhokhaClient.php create mode 100644 progress.log create mode 100644 scripts/ikhokha_migrations/001_add_payment_columns.sql create mode 100644 scripts/ikhokha_migrations/002_migrate_efts_to_payments.sql create mode 100644 scripts/ikhokha_migrations/README.md create mode 100644 src/api/ikhokha_webhook.php create mode 100644 src/api/test_log.php create mode 100644 src/pages/payment/cancel.php create mode 100644 src/pages/payment/failure.php create mode 100644 src/pages/payment/success.php create mode 100644 test.php create mode 100644 test_payment.php create mode 100644 uploads/signatures/signature_163.png 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/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/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..1519f2bd --- /dev/null +++ b/src/api/ikhokha_webhook.php @@ -0,0 +1,274 @@ +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])); + 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(); +} + +/** + * ========================================================== + * 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(); + } + + // 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)); + } + } 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 - New Membership Application - '.getFullName($user_id) , 'A new member has signed up, '.getFullName($user_id)); + } + } + + // 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 @@ + $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(); @@ -1984,6 +2131,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 +3374,38 @@ 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; +} + diff --git a/src/pages/memberships/membership_details.php b/src/pages/memberships/membership_details.php index 0f434567..a42ef44c 100644 --- a/src/pages/memberships/membership_details.php +++ b/src/pages/memberships/membership_details.php @@ -19,6 +19,8 @@ $result = $stmt->get_result(); // Fetch single record $membership = $result->fetch_assoc(); +$payment_link = getPaymentLinkByPaymentId($membership['payment_id']); + // Fetch membership application data using mysqli $query = "SELECT * FROM membership_application WHERE user_id = ?"; $stmt = $conn->prepare($query); @@ -186,8 +188,8 @@ if (empty($application['id_number'])) { - - AWAITING PAYMENT + + PAY NOW diff --git a/src/pages/memberships/membership_payment.php b/src/pages/memberships/membership_payment.php index bb4bb9ff..cd905acc 100644 --- a/src/pages/memberships/membership_payment.php +++ b/src/pages/memberships/membership_payment.php @@ -67,7 +67,25 @@ $stmt->bind_result($user_email); $stmt->fetch(); $stmt->close(); -$conn->close(); +// If request includes payment_id, fetch provider paylink from payments table +$payment_id = $_GET['payment_id'] ?? null; +$payment_link = null; +if ($payment_id) { + $pstmt = $conn->prepare("SELECT payment_link, amount, status, provider FROM payments WHERE payment_id = ? LIMIT 1"); + if ($pstmt) { + $pstmt->bind_param('s', $payment_id); + $pstmt->execute(); + $pres = $pstmt->get_result(); + if ($prow = $pres->fetch_assoc()) { + $payment_link = $prow['payment_link']; + // prefer payments.amount if present + if (!empty($prow['amount'])) { + $payment_amount = $prow['amount']; + } + } + $pstmt->close(); + } +} ?> 'index.php'], ['Membership' => 'membership.php']]; @@ -83,13 +101,25 @@ $conn->close(); Membership Start Date: ' . $membership_start_date . '
    Membership Renewal Date: ' . $membership_end_date . ''; ?> -

    Your invoice has been sent to . 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 - - + + +
    Payment Details:
    +

    Amount: R

    +

    Reference:

    + + Pay Now with iKhokha + + +

    You will be redirected to iKhokha's Secure Payment Gateway.

    + +

    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 + + +
    diff --git a/src/pages/memberships/renew_membership.php b/src/pages/memberships/renew_membership.php index f6d85f38..8b7ecab2 100644 --- a/src/pages/memberships/renew_membership.php +++ b/src/pages/memberships/renew_membership.php @@ -1,26 +1,78 @@ 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); +// Hardcode membership start date to 2026-03-01 per request +$membership_start_date = '2026-03-01'; + +// Set membership_end_date to the last day of February in the following year +$nextYear = intval(date('Y')) + 1; +$dt = new DateTime($nextYear . '-02-28'); +$membership_end_date = $dt->format('Y-m-t'); + +$stmt = $conn->prepare("UPDATE membership_fees SET payment_amount = ?, payment_date = ?, membership_start_date = ?, membership_end_date = ?, payment_status = 'AWAITING PAYMENT', payment_id = ? WHERE user_id = ?"); +$stmt->bind_param("dssssi", $payment_amount, $payment_date, $membership_start_date, $membership_end_date, $payment_id, $user_id); if ($stmt->execute()) { // Commit the transaction $conn->commit(); - addSubsEFT($eft_id, $user_id, $status, $payment_amount, $description); + + $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; + if ($paylink) { + header('Location: membership_payment?payment_id=' . $payment_id); + 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/indemnity.php b/src/pages/other/indemnity.php index c6f1c43e..3fc42185 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.php?payment_id=' + encodeURIComponent(response.payment_id); + 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.payment_id); + // }, 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/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..d4045c4b 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 = uniqid(); $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 @@ -203,15 +211,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $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); + VALUES (?, ?, ?, ?, ?, 'AWAITING PAYMENT', ?)"); + $stmt->bind_param("idssss", $user_id, $payment_amount, $payment_date, $membership_start_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); header("Location: indemnity"); // Success message $response = [ diff --git a/src/processors/process_course_booking.php b/src/processors/process_course_booking.php index 098abab0..7d2af6f0 100644 --- a/src/processors/process_course_booking.php +++ b/src/processors/process_course_booking.php @@ -94,6 +94,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $status = "AWAITING PAYMENT"; $type = 'course'; $payment_id = uniqid(); + $publicRef = bin2hex(random_bytes(16)); $num_vehicles = 1; $discountAmount = 0; $eft_id = strtoupper("COURSE ".date("m-d", strtotime($date))." ".getInitialSurname($user_id)); @@ -125,11 +126,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..7f1eaef9 100644 --- a/src/processors/process_signature.php +++ b/src/processors/process_signature.php @@ -59,13 +59,96 @@ if (isset($_POST['signature'])) { // 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'] ?? uniqid('mem_', true); + + 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); + $ins->execute(); + $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; + } catch (Exception $e) { + // Log but do not fail signature save + error_log('iKhokha create 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 + ]; + if (!empty($paylink)) { + $response['paylinkUrl'] = $paylink; + } + if (!empty($payment_id)) { + $response['payment_id'] = $payment_id; + } + echo json_encode($response); } else { ob_end_clean(); echo json_encode(['status' => 'error', 'message' => 'Database update failed']); diff --git a/src/processors/process_trip_booking.php b/src/processors/process_trip_booking.php index fe7916c8..5391d7e5 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 @@ -104,6 +106,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $description = $trip_name; $type = 'trip'; $payment_id = uniqid(); + $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)); @@ -131,11 +134,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 0000000000000000000000000000000000000000..27bcf4f9f6ebea8ab223f75936aab0f9cb7f7bc0 GIT binary patch literal 9888 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