nestict 2a327ddd3d Upload files to "system/plugin"
Signed-off-by: nestict <icttechnest@gmail.com>
2025-05-24 12:34:11 +02:00

637 lines
21 KiB
PHP

<?php
register_menu("Mpesa C2B Settings", true, "c2b_settings", 'SETTINGS', '', '', "");
register_menu("Mpesa Transactions", true, "c2b_overview", 'AFTER_MESSAGE', 'fa fa-paypal', '', "");
try {
$db = ORM::get_db();
$tableCheckQuery = "CREATE TABLE IF NOT EXISTS tbl_mpesa_transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
TransID VARCHAR(255) NOT NULL,
TransactionType VARCHAR(255) NOT NULL,
TransTime VARCHAR(255) NOT NULL,
TransAmount DECIMAL(10, 2) NOT NULL,
BusinessShortCode VARCHAR(255) NOT NULL,
BillRefNumber VARCHAR(255) NOT NULL,
OrgAccountBalance DECIMAL(10, 2) NOT NULL,
MSISDN VARCHAR(255) NOT NULL,
FirstName VARCHAR(255) NOT NULL,
CustomerID VARCHAR(255) NOT NULL,
PackageName VARCHAR(255) NOT NULL,
PackagePrice VARCHAR(255) NOT NULL,
TransactionStatus VARCHAR(255) NOT NULL,
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)";
$db->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', '<em>' . Lang::T("You haven't registered your validation and verification URLs. Please register URLs by clicking ") . ' <a href="' . APP_URL . '/index.php?_route=plugin/c2b_settings"> Register URL </a>' . '</em>');
}
$ui->assign('payments', $payments);
$ui->assign('xheader', '<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.5/css/jquery.dataTables.css">');
$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('<br>', $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', '<em>' . Lang::T("You haven't registered your validation and verification URLs, Please register URLs by clicking ") . ' <a href="' . APP_URL . '/index.php?_route=plugin/c2b_settings"> Register URL </a>' . '</em>');
}
$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') . "<pre>" . htmlspecialchars($data, ENT_QUOTES, 'UTF-8') . "</pre>\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;
}