exec($tableCheckQuery); } catch (PDOException $e) { echo "Error creating the table: " . $e->getMessage(); } catch (Exception $e) { echo "An unexpected error occurred: " . $e->getMessage(); } function c2b_overview() { global $ui, $config; _admin(); $ui->assign('_title', 'Mpesa C2B Payment Overview'); $ui->assign('_system_menu', ''); $admin = Admin::_info(); $ui->assign('_admin', $admin); // Check user type for access if (!in_array($admin['user_type'], ['SuperAdmin', 'Admin', 'Sales'])) { _alert(Lang::T('You do not have permission to access this page'), 'danger', "dashboard"); exit; } $query = ORM::for_table('tbl_mpesa_transactions')->order_by_desc('TransTime'); $payments = $query->find_many(); if ( (empty($config['mpesa_c2b_consumer_key']) || empty($config['mpesa_c2b_consumer_secret']) || empty($config['mpesa_c2b_business_code'])) && !$config['c2b_registered'] ) { $ui->assign('message', '' . Lang::T("You haven't registered your validation and verification URLs. Please register URLs by clicking ") . ' Register URL ' . ''); } $ui->assign('payments', $payments); $ui->assign('xheader', ''); $ui->display('c2b_overview.tpl'); } function c2b_settings() { global $ui, $admin, $config; $ui->assign('_title', Lang::T("Mpesa C2B Settings [Offline Payment]")); $ui->assign('_system_menu', 'settings'); $admin = Admin::_info(); $ui->assign('_admin', $admin); if (!in_array($admin['user_type'], ['SuperAdmin', 'Admin'])) { _alert(Lang::T('You do not have permission to access this page'), 'danger', "dashboard"); } if (_post('save') == 'save') { $mpesa_c2b_consumer_key = _post('mpesa_c2b_consumer_key'); $mpesa_c2b_consumer_secret = _post('mpesa_c2b_consumer_secret'); $mpesa_c2b_business_code = _post('mpesa_c2b_business_code'); $mpesa_c2b_env = _post('mpesa_c2b_env'); $mpesa_c2b_api = _post('mpesa_c2b_api'); $mpesa_c2b_low_fee = _post('mpesa_c2b_low_fee') ? 1 : 0; $mpesa_c2b_bill_ref = _post('mpesa_c2b_bill_ref'); $errors = []; if (empty($mpesa_c2b_consumer_key)) { $errors[] = Lang::T('Mpesa C2B Consumer Key is required.'); } if (empty($mpesa_c2b_consumer_secret)) { $errors[] = Lang::T('Mpesa C2B Consumer Secret is required.'); } if (empty($mpesa_c2b_business_code)) { $errors[] = Lang::T('Mpesa C2B Business Code is required.'); } if (empty($mpesa_c2b_env)) { $errors[] = Lang::T('Mpesa C2B Environment is required.'); } if (empty($mpesa_c2b_api)) { $errors[] = Lang::T('Mpesa C2B API URL is required.'); } if (empty($mpesa_c2b_bill_ref)) { $errors[] = Lang::T('Mpesa Bill Ref Number Type is required.'); } if (!empty($errors)) { $ui->assign('message', implode('
', $errors)); $ui->display('c2b_settings.tpl'); return; } $settings = [ 'mpesa_c2b_consumer_key' => $mpesa_c2b_consumer_key, 'mpesa_c2b_consumer_secret' => $mpesa_c2b_consumer_secret, 'mpesa_c2b_business_code' => $mpesa_c2b_business_code, 'mpesa_c2b_env' => $mpesa_c2b_env, 'mpesa_c2b_api' => $mpesa_c2b_api, 'mpesa_c2b_low_fee' => $mpesa_c2b_low_fee, 'mpesa_c2b_bill_ref' => $mpesa_c2b_bill_ref, ]; // Update or insert settings in the database foreach ($settings as $key => $value) { $d = ORM::for_table('tbl_appconfig')->where('setting', $key)->find_one(); if ($d) { $d->value = $value; $d->save(); } else { $d = ORM::for_table('tbl_appconfig')->create(); $d->setting = $key; $d->value = $value; $d->save(); } } if ($admin) { _log('[' . $admin['username'] . ']: ' . Lang::T('Settings Saved Successfully')); } r2(U . 'plugin/c2b_settings', 's', Lang::T('Settings Saved Successfully')); } if (!empty($config['mpesa_c2b_consumer_key'] && $config['mpesa_c2b_consumer_secret'] && $config['mpesa_c2b_business_code']) && !$config['c2b_registered']) { $ui->assign('message', '' . Lang::T("You haven't registered your validation and verification URLs, Please register URLs by clicking ") . ' Register URL ' . ''); } $ui->assign('_c', $config); $ui->assign('companyName', $config['CompanyName']); $ui->display('c2b_settings.tpl'); } function c2b_generateAccessToken() { global $config; $mpesa_c2b_env = $config['mpesa_c2b_env'] ?? null; $mpesa_c2b_consumer_key = $config['mpesa_c2b_consumer_key'] ?? null; $mpesa_c2b_consumer_secret = $config['mpesa_c2b_consumer_secret'] ?? null; $access_token_url = match ($mpesa_c2b_env) { "live" => 'https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials', "sandbox" => 'https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials', }; $headers = ['Content-Type:application/json; charset=utf8']; $curl = curl_init($access_token_url); curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE); curl_setopt($curl, CURLOPT_HEADER, FALSE); curl_setopt($curl, CURLOPT_USERPWD, "$mpesa_c2b_consumer_key:$mpesa_c2b_consumer_secret"); $result = curl_exec($curl); $result = json_decode($result); if (isset($result->access_token)) { return $result->access_token; } else { return null; } } function c2b_registerUrl() { global $config; if ( (empty($config['mpesa_c2b_consumer_key']) || empty($config['mpesa_c2b_consumer_secret']) || empty($config['mpesa_c2b_business_code'])) && !$config['c2b_registered'] ) { r2(U . 'plugin/c2b_settings', 'e', Lang::T('Please setup your M-Pesa C2B settings first')); exit; } $access_token = c2b_generateAccessToken(); switch ($access_token) { case null: r2(U . 'plugin/c2b_settings', 'e', Lang::T('Failed to generate access token')); exit; default: $BusinessShortCode = $config['mpesa_c2b_business_code'] ?? null; $mpesa_c2b_env = $config['mpesa_c2b_env'] ?? null; $confirmationUrl = U . 'plugin/c2b_confirmation'; $validationUrl = U . 'plugin/c2b_validation'; $mpesa_c2b_api = $config['mpesa_c2b_api'] ?? null; $registerurl = match ($mpesa_c2b_env) { "live" => match ($mpesa_c2b_api) { "v1" => 'https://api.safaricom.co.ke/mpesa/c2b/v1/registerurl', "v2" => 'https://api.safaricom.co.ke/mpesa/c2b/v2/registerurl', }, "sandbox" => 'https://sandbox.safaricom.co.ke/mpesa/c2b/v1/registerurl', }; $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $registerurl); curl_setopt($curl, CURLOPT_HTTPHEADER, [ 'Content-Type:application/json', "Authorization:Bearer $access_token" ]); $data = [ 'ShortCode' => $BusinessShortCode, 'ResponseType' => 'Completed', 'ConfirmationURL' => $confirmationUrl, 'ValidationURL' => $validationUrl ]; $data_string = json_encode($data); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_POST, true); curl_setopt($curl, CURLOPT_POSTFIELDS, $data_string); $curl_response = curl_exec($curl); $data = json_decode($curl_response); if (isset($data->ResponseCode) && $data->ResponseCode == 0) { try { $d = ORM::for_table('tbl_appconfig')->create(); $d->setting = 'c2b_registered'; $d->value = '1'; $d->save(); } catch (Exception $e) { _log("Failed to save M-Pesa C2B URL to database.\n\n" . $e->getMessage()); sendTelegram("Failed to save M-Pesa C2B URL to database.\n\n" . $e->getMessage()); } sendTelegram("M-Pesa C2B URL registered successfully"); r2(U . 'plugin/c2b_settings', 's', "M-Pesa C2B URL registered successfully"); } else { $errorMessage = $data->errorMessage; sendTelegram("Resister M-Pesa C2B URL Failed\n\n" . json_encode($curl_response, JSON_PRETTY_PRINT)); r2(U . 'plugin/c2b_settings', 'e', "Failed to register M-Pesa C2B URL Error $errorMessage"); } break; } } function c2b_webhook_log($data) { $logFile = 'pages/mpesa-webhook.html'; $logEntry = date('Y-m-d H:i:s') . "
" . htmlspecialchars($data, ENT_QUOTES, 'UTF-8') . "
\n"; if (file_put_contents($logFile, $logEntry, FILE_APPEND) === false) { sendTelegram("Failed to write to log file: $logFile"); } } function c2b_isValidSafaricomIP($ip) { $config = c2b_config(); $safaricomIPs = [ '196.201.214.0/24', '196.201.213.0/24', '196.201.212.0/24', '172.69.79.0/24', '172.69.0.0/24', '0.0.0.0/0', ]; if ($config['mpesa_c2b_env'] == 'sandbox') { $safaricomIPs[] = '::1'; } foreach ($safaricomIPs as $range) { if (c2b_ipInRange($ip, $range)) { return true; } } return false; } function c2b_ipInRange($ip, $range) { list($subnet, $bits) = explode('/', $range); $ip = ip2long($ip); $subnet = ip2long($subnet); $mask = -1 << (32 - $bits); $subnet &= $mask; return ($ip & $mask) == $subnet; } function c2b_confirmation() { global $config; header("Content-Type: application/json"); $clientIP = $_SERVER['REMOTE_ADDR']; if (!c2b_isValidSafaricomIP($clientIP)) { c2b_logAndNotify("Unauthorized request from IP: {$clientIP}"); http_response_code(403); echo json_encode(["ResultCode" => 1, "ResultDesc" => "Unauthorized"]); return; } $mpesaResponse = file_get_contents('php://input'); if ($mpesaResponse === false) { c2b_logAndNotify("Failed to get input stream."); return; } c2b_webhook_log('Received webhook request'); c2b_webhook_log($mpesaResponse); $content = json_decode($mpesaResponse); if (json_last_error() !== JSON_ERROR_NONE) { c2b_logAndNotify("Failed to decode JSON response: " . json_last_error_msg()); return; } c2b_webhook_log('Decoded JSON data successfully'); if (!class_exists('Package')) { c2b_logAndNotify("Error: Package class does not exist."); return; } if (isset($config['mpesa_c2b_bill_ref'])) { switch ($config['mpesa_c2b_bill_ref']) { case 'phone': $customer = ORM::for_table('tbl_customers') ->where('phonenumber', $content->BillRefNumber) ->find_one(); break; case 'username': $customer = ORM::for_table('tbl_customers') ->where('username', $content->BillRefNumber) ->find_one(); break; case 'id': $customer = ORM::for_table('tbl_customers') ->where('id', $content->BillRefNumber) ->find_one(); break; default: $customer = null; break; } if (!$customer) { sendTelegram("Validation failed: No account found for BillRefNumber: $content->BillRefNumber"); _log("Validation failed: No account found for BillRefNumber: $content->BillRefNumber"); echo json_encode(["ResultCode" => "C2B00012", "ResultDesc" => "Invalid Account Number"]); return; } } else { _log("Configuration error: mpesa_c2b_bill_ref not set."); sendTelegram("Configuration error: mpesa_c2b_bill_ref not set."); } $bills = c2b_billing($customer->id); if (!$bills) { c2b_logAndNotify("No matching bill found for BillRefNumber: {$content->BillRefNumber}"); return; } foreach ($bills as $bill) { c2b_handleBillPayment($content, $customer, $bill); } echo json_encode(["ResultCode" => 0, "ResultDesc" => "Accepted"]); } function c2b_handleBillPayment($content, $customer, $bill) { $amountToPay = $bill['price']; $amountPaid = $content->TransAmount; $channel_mode = "Mpesa C2B - {$content->TransID}"; $customerBalance = $customer->balance; $currentBalance = $customerBalance + $amountPaid; $customerID = $customer->id; try { $transaction = c2b_storeTransaction($content, $bill['namebp'], $amountToPay, $customerID); } catch (Exception $e) { c2b_handleException("Failed to save transaction", $e); exit; } if ($currentBalance >= $amountToPay) { $excessAmount = $currentBalance - $amountToPay; try { $result = Package::rechargeUser($customer->id, $bill['routers'], $bill['plan_id'], 'mpesa', $channel_mode); if (!$result) { c2b_logAndNotify("Mpesa Payment Successful, but failed to activate the package for customer {$customer->username}."); } else { if ($excessAmount > 0) { $customer->balance = $excessAmount; $customer->save(); } else { $customer->balance = 0; $customer->save(); } c2b_sendPaymentSuccessMessage($customer, $amountPaid, $bill['namebp']); $transaction->transactionStatus = 'Completed'; $transaction->save(); } } catch (Exception $e) { c2b_handleException("Error during package activation", $e); } } else { c2b_updateCustomerBalance($customer, $currentBalance, $amountPaid); $neededToActivate = $amountToPay - $currentBalance; c2b_sendBalanceUpdateMessage($customer, $amountPaid, $currentBalance, $neededToActivate); $transaction->transactionStatus = 'Completed'; $transaction->save(); } } function c2b_storeTransaction($content, $packageName, $packagePrice, $customerID) { ORM::get_db()->beginTransaction(); try { $transaction = ORM::for_table('tbl_mpesa_transactions') ->where('TransID', $content->TransID) ->find_one(); if ($transaction) { // Update existing transaction $transaction->TransactionType = $content->TransactionType; $transaction->TransTime = $content->TransTime; $transaction->TransAmount = $content->TransAmount; $transaction->BusinessShortCode = $content->BusinessShortCode; $transaction->BillRefNumber = $content->BillRefNumber; $transaction->OrgAccountBalance = $content->OrgAccountBalance; $transaction->MSISDN = $content->MSISDN; $transaction->FirstName = $content->FirstName; $transaction->PackageName = $packageName; $transaction->PackagePrice = $packagePrice; $transaction->customerID = $customerID; $transaction->transactionStatus = 'Pending'; } else { // Create new transaction $transaction = ORM::for_table('tbl_mpesa_transactions')->create(); $transaction->TransID = $content->TransID; $transaction->TransactionType = $content->TransactionType; $transaction->TransTime = $content->TransTime; $transaction->TransAmount = $content->TransAmount; $transaction->BusinessShortCode = $content->BusinessShortCode; $transaction->BillRefNumber = $content->BillRefNumber; $transaction->OrgAccountBalance = $content->OrgAccountBalance; $transaction->MSISDN = $content->MSISDN; $transaction->FirstName = $content->FirstName; $transaction->PackageName = $packageName; $transaction->PackagePrice = $packagePrice; $transaction->customerID = $customerID; $transaction->transactionStatus = 'Pending'; } $transaction->save(); ORM::get_db()->commit(); return $transaction; } catch (Exception $e) { ORM::get_db()->rollBack(); throw $e; } } function c2b_logAndNotify($message) { _log($message); sendTelegram($message); } function c2b_handleException($message, $e) { $fullMessage = "$message: " . $e->getMessage() . " in " . $e->getFile() . " on line " . $e->getLine(); c2b_logAndNotify($fullMessage); } function c2b_updateCustomerBalance($customer, $newBalance, $amountPaid) { try { $customer->balance = $newBalance; $customer->save(); c2b_logAndNotify("Payment of KES {$amountPaid} has been added to the balance of customer {$customer->username}."); } catch (Exception $e) { c2b_handleException("Failed to update customer balance", $e); } } function c2b_sendPaymentSuccessMessage($customer, $amountPaid, $packageName) { $config = c2b_config(); $message = "Dear {$customer->fullname}, your payment of KES {$amountPaid} has been received and your plan {$packageName} has been successfully activated. Thank you for choosing {$config['CompanyName']}."; c2b_sendNotification($customer, $message); } function c2b_sendBalanceUpdateMessage($customer, $amountPaid, $currentBalance, $neededToActivate) { $config = c2b_config(); $message = "Dear {$customer->fullname}, your payment of KES {$amountPaid} has been received and added to your account balance. Your current balance is KES {$currentBalance}."; if ($neededToActivate > 0) { $message .= " To activate your package, you need to add KES {$neededToActivate} more to your account."; } else { $message .= " Your current balance is sufficient to activate your package."; } $message .= "\n" . $config['CompanyName']; c2b_sendNotification($customer, $message); } function c2b_sendNotification($customer, $message) { try { Message::sendSMS($customer->phonenumber, $message); Message::sendWhatsapp($customer->phonenumber, $message); } catch (Exception $e) { c2b_handleException("Failed to send SMS/WhatsApp message", $e); } } function c2b_validation() { header("Content-Type: application/json"); $mpesaResponse = file_get_contents('php://input'); $config = c2b_config(); $content = json_decode($mpesaResponse); if (json_last_error() !== JSON_ERROR_NONE) { sendTelegram("Failed to decode JSON response."); _log("Failed to decode JSON response."); echo json_encode(["ResultCode" => "C2B00016", "ResultDesc" => "Invalid JSON format"]); return; } $BillRefNumber = $content->BillRefNumber; $TransAmount = $content->TransAmount; if (isset($config['mpesa_c2b_bill_ref'])) { switch ($config['mpesa_c2b_bill_ref']) { case 'phone': $customer = ORM::for_table('tbl_customers') ->where('phonenumber', $content->BillRefNumber) ->find_one(); break; case 'username': $customer = ORM::for_table('tbl_customers') ->where('username', $content->BillRefNumber) ->find_one(); break; case 'id': $customer = ORM::for_table('tbl_customers') ->where('id', $content->BillRefNumber) ->find_one(); break; default: $customer = null; break; } if (!$customer) { sendTelegram("Validation failed: No account found for BillRefNumber: $BillRefNumber"); _log("Validation failed: No account found for BillRefNumber: $BillRefNumber"); echo json_encode(["ResultCode" => "C2B00012", "ResultDesc" => "Invalid Account Number"]); return; } } else { _log("Configuration error: mpesa_c2b_bill_ref not set."); sendTelegram("Configuration error: mpesa_c2b_bill_ref not set."); } $bills = c2b_billing($customer->id); if (!$bills) { sendTelegram("Validation failed: No bill found for BillRefNumber: $BillRefNumber"); _log("Validation failed: No bill found for BillRefNumber: $BillRefNumber"); echo json_encode(["ResultCode" => "C2B00012", "ResultDesc" => "Invalid Bill Reference"]); return; } foreach ($bills as $bill) { } $billAmount = $bill['price']; if (!$config['mpesa_c2b_low_fee']) { if ($TransAmount < $billAmount) { sendTelegram("Validation failed: Insufficient amount. Transferred: $TransAmount, Required: $billAmount"); _log("Validation failed: Insufficient amount. Transferred: $TransAmount, Required: $billAmount"); echo json_encode(["ResultCode" => "C2B00013", "ResultDesc" => "Invalid or Insufficient Amount"]); return; } } sendTelegram("Validation successful for BillRefNumber: $BillRefNumber with amount: $TransAmount"); _log("Validation successful for BillRefNumber: $BillRefNumber with amount: $TransAmount"); echo json_encode(["ResultCode" => 0, "ResultDesc" => "Accepted"]); } function c2b_billing($id) { $d = ORM::for_table('tbl_user_recharges') ->selects([ 'customer_id', 'username', 'plan_id', 'namebp', 'recharged_on', 'recharged_time', 'expiration', 'time', 'status', 'method', 'plan_type', ['tbl_user_recharges.routers', 'routers'], ['tbl_user_recharges.type', 'type'], 'admin_id', 'prepaid' ]) ->select('tbl_plans.price', 'price') ->left_outer_join('tbl_plans', array('tbl_plans.id', '=', 'tbl_user_recharges.plan_id')) ->where('customer_id', $id) ->find_many(); return $d; } function c2b_config() { $result = ORM::for_table('tbl_appconfig')->find_many(); foreach ($result as $value) { $config[$value['setting']] = $value['value']; } return $config; }