Initial commit

This commit is contained in:
Kwesi Banson Jnr
2026-04-08 05:53:02 +00:00
commit 592a161ee6
63 changed files with 4105 additions and 0 deletions

10
app/Config/database.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
return [
'host' => $_ENV['DB_HOST'],
'db' => $_ENV['DB_NAME'],
'user' => $_ENV['DB_USER'],
'pass' => $_ENV['DB_PASS'],
];
?>

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Controllers;
use App\Models\User;
use App\Core\Auth;
use App\Core\Validator;
use App\Core\Controller;
class AuthController extends Controller {
public function login() {
$errors = Validator::validate($_POST, [
'email' => 'required|email',
'password' => 'required'
]);
if (!empty($errors)) {
return $this->render('auth/login', ['errors' => $errors]);
}
// Check database for user
$user = User::findByEmail($_POST['email']); // Custom method in User model
if ($user && password_verify($_POST['password'], $user['password'])) {
Auth::login($user);
header('Location: /dashboard');
} else {
return $this->render('auth/login', ['error' => 'Invalid credentials']);
}
}
}
?>

View File

@@ -0,0 +1,397 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\User;
use App\Models\Disbursement;
use App\Models\AirtelBroker;
use App\Models\MpambaBroker;
use App\Core\Auth;
use App\Core\Logger;
class DisbursementController extends Controller {
public function index() {
#$users = User::all();
$token = Auth::getBearerToken();
$user = User::getByToken($token);
$logger = new Logger();
if ($user == false) {
// code...
$logger->error("API Authentication failed token : " . $token);
return $this->json([
"status" => "failed",
"message" => "Unauthorised Access",
]);
}
$log_data = json_encode($user);
$logger->info("User $log_data successfully authenticated from " . $_SERVER['REMOTE_ADDR']);
$payload = json_decode(file_get_contents("php://input") , true);
if (json_last_error() !== JSON_ERROR_NONE) {
// echo "JSON Error: " . json_last_error_msg();
http_response_code(400);
return $this->json([
"status" => "failed",
"message" => "Bad Request. Invalid JSON Object",
]);
}
$requested_keys = ['transaction_id', 'reference_id', 'mobile_number', 'amount'];
foreach ($requested_keys as $key) {
if (!isset($payload[$key]) || trim($payload[$key]) === '') {
http_response_code(400);
return $this->json([
"status" => "failed",
"message" => "Validation Error: Missing or empty field : {$key}",
]);
}
}
if (!preg_match('/^[a-zA-Z0-9]+$/', $payload['transaction_id'])) {
http_response_code(400);
return $this->json([
"status" => "failed",
"message" => "Validation Error: Malformed Transaction ID",
]);
}
if (!preg_match('/^[a-zA-Z0-9]+$/', $payload['reference_id'])) {
return $this->json([
"status" => "failed",
"message" => "Validation Error: Malformed Reference ID",
]);
}
if (!preg_match('/^[0-9]{9,12}$/', $payload['mobile_number'])) {
return $this->json([
"status" => "failed",
"message" => "Validation Error: Invalid Mobile Number format",
]);
}
$amount = filter_var($payload['amount'], FILTER_VALIDATE_FLOAT);
if ($amount === false || $amount <= 0) {
return $this->json([
"status" => "failed",
"message" => "Validation Error: Amount must be a positive number",
]);
}
$logger->info("Disbursement check request : " . json_encode($payload));
$transaction_details = Disbursement::varifyTransaction($payload['transaction_id'], $payload['reference_id']);
if ($transaction_details == false) {
return $this->json([
'status' => 'fail',
'data' => 'Transaction not found',
]);
}
$network = $this->getNetwork($payload['mobile_number']);
//pull this away into the ENV file
$pin = "IGbCqXwRoiqsHTIIjxfo6vWyzPMKg6iF3+pNQK6gTXbOyJgOd1bbPuIstTcMwSAiRXOgQrkRC0+sQU5wHF33aha+AL0TevBntLzVyGl8002ZXy6Ux4Pu+zymRdlw7J6H/PXRC2kXhbR2GIHLHlqHC49gu65OzpJ8fvpnscg1yjE=";
if ($network == 'airtel') {
$broker_details = AirtelBroker::find($transaction_details[0]['wallet_id']);
$authURL = $_ENV['AIRTEL_MONEY_AUTH_URL'];
$auth_result = $this->authenticateAirtel($authURL, $broker_details['client_id'], $broker_details['client_secret']);
if ($auth_result['success'] == false) {
$logger->info("Airtel Money authentication failed " . json_encode($auth_result));
return $this->json([
'status' => 'fail',
'data' => 'Your request could not be handled at this time. Try again',
]);
}
$airtel_array = [
"accessToken" => $auth_result['token'],
"msisdn" => $transaction_details[0]['msisdn'],
"amount" => $amount,
"reference" => $transaction_details[0]['infotech_transaction_id'],
"pin" => $pin,
"transactionId" => $transaction_details[0]['transaction_id'],
"country" => "MW",
"currency" => "MWK"
];
$retval = $this->airtelDisbursement($airtel_array);
var_dump($retval);
$disbursement_update_data = [
'response_message' => $disbursement_retval['response'],
'confirmed' => 1,
'confirmed_at' => date('Y-m-d H:i:s'),
'status' => 'successful'
];
$logger->info('Disbursement Update data : ' . json_encode($disbursement_update_data));
$update_result = Disbursement::updateById($transaction_details[0]['id'], $disbursement_update_data);
$logger->info('Airtel Disbursement Update Result : ' . $update_result);
}
elseif($network == 'tnm'){
//code
$broker_details = MpambaBroker::getBroker($transaction_details[0]['wallet_id']);
$auth_result = $this->authenticateMpamba($transaction_details[0]['wallet_id'], $broker_details[0]['password']);
$params = [
"msisdn" => $transaction_details[0]['msisdn'],
"amount" => $transaction_details[0]['amount'],
"transaction_id" => $transaction_details[0]['transaction_id'],
"narration" => "Testing", //$transaction_details[0]['description']
"token" => $auth_result['token']
];
$disbursement_retval = $this->mpambaDisbursement($params);
var_dump($disbursement_retval);
$disbursement_update_data = [
'response_message' => $disbursement_retval['raw'],
'confirmed' => 1,
'confirmed_at' => date('Y-m-d H:i:s'),
'status' => 'successful'
];
$logger->info('Disbursement Update data : ' . json_encode($disbursement_update_data));
$update_result = Disbursement::updateById($transaction_details[0]['id'], $disbursement_update_data);
$logger->info('Mpamba Disbursement Update Result : ' . $update_result);
}
else{
return $this->json([
'status' => 'fail',
'data' => 'Invalid phone number',
]);
}
}
public function show($id) {
$user = User::find($id);
if (!$user) {
return $this->json(['message' => 'User not found'], 404);
}
return $this->json($user);
}
public function testUpdate(){
$disbursement_update_data = [
'response_message' => 'in heere',
'confirmed' => 1,
'confirmed_at' => date('Y-m-d H:i:s'),
'status' => 'successful'
];
$update_result = Disbursement::updateById('3', $disbursement_update_data);
var_dump($update_result);
}
public function airtelDisbursement($params) {
$timeout = 30;
$logger = new Logger();
$url = $_ENV['AIRTEL_MONEY_DISBURSEMENT_URL'];
$payload = [
"payee" => [
"msisdn" => $params['msisdn']
],
"reference" => $params['reference'],
"pin" => $params['pin'],
"transaction" => [
"amount" => $params['amount'],
"id" => $params['transactionId']
]
];
$log_data = json_encode($payload);
$logger->info("Disbursement Requests to Airtel MW : " . $log_data );
$headers = [
'Content-Type: application/json',
'Accept: */*',
'X-Country: ' . $params['country'],
'X-Currency: ' . $params['currency'],
'Authorization: Bearer ' . $params['accessToken']
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true
]);
$response = curl_exec($ch);
$error = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($error) {
return [
'success' => false,
'error' => 'CURL_ERROR',
'message' => $error
];
}
$logger->info("Disbursement Response from Airtel MW : " . $response);
return [
'success' => ($code === 200),
'http_code' => $code,
'response' => json_decode($response, true),
'raw' => $response
];
}
public function authenticateAirtel($baseURL, $wallet, $password){
// JSON payload
$postData = json_encode([
"client_id" => $wallet,
"client_secret" => $password,
"grant_type" => "client_credentials"
]);
if (json_last_error() !== JSON_ERROR_NONE) {
return [
"status" => "failed",
"message" => "Bad Request. Invalid JSON Object" . json_last_error_msg(),
];
}
$ch = curl_init($baseURL);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Content-Length: ' . strlen($postData)
]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
$curl_error = curl_error($ch);
curl_close($ch);
return [
'success' => false,
'error' => 'Curl error: ' . $curl_error
];
}
// Get HTTP status code
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Decode JSON response
$result = json_decode($response, true);
// Check if token is present
if ($httpCode === 200 && isset($result['access_token'])) {
return [
'success' => true,
'token' => $result['access_token']
];
} else {
return [
'success' => false,
'error' => $result['error_description'] ?? 'Unknown error',
'details' => $result['error'] ?? []
];
}
}
#Mpamba Scripts
public function authenticateMpamba($wallet, $password){
$logger = new Logger();
$url = rtrim($_ENV['MPAMBA_BASE_URL'], '/') . '/authenticate';
// JSON payload
$postData = json_encode([
'wallet' => $wallet,
'password' => $password
]);
$logger->info("Mpamba Authentication Requests : " . $postData );
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Return the response
curl_setopt($ch, CURLOPT_POST, true); // Use POST method
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);// Set the request body
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Content-Length: ' . strlen($postData)
]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
$curl_error = curl_error($ch);
$logger->info("Mpamba Authentication Error : " . $curl_error );
curl_close($ch);
return [
'success' => false,
'error' => 'Error: ' . $curl_error
];
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Decode JSON response
$result = json_decode($response, true);
// Check if token is present
$logger->info("Mpamba Authentication Response : " . json_encode($result));
if ($httpCode === 200 && isset($result['data']['token'])) {
return [
'success' => true,
'token' => $result['data']['token'],
'expires_at' => $result['data']['expires_at']
];
} else {
return [
'success' => false,
'error' => $result['message'] ?? 'Unknown error',
'details' => $result['errors'] ?? []
];
}
}
public function mpambaDisbursement($params){
$logger = new Logger();
$url = $_ENV['MPAMBA_BASE_URL'] . "/payments";
$payload = array(
"msisdn" => $params['msisdn'],
"amount" => (int)$params['amount'],
"transaction_id" => $params['transaction_id'],
"narration" => $params['narration']
);
$logger->info("Mpamba Disbursement Requests : " . json_encode($payload));
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
"Content-Type: application/json",
"Authorization: Bearer " . $params['token']
));
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
if ($response === false) {
$error = curl_error($ch);
$logger->info("Mpamba Disbursement Response Error : " . $error);
curl_close($ch);
return array(
"status" => "ERROR",
"message" => "Error: " . $error
);
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$logger->info("Mpamba Disbursement Response : " . $response);
$responseData = json_decode($response, true);
return array(
"http_code" => $httpCode,
"response" => $responseData
);
}
}
?>

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\User;
class HomeController extends Controller {
public function index() {
echo json_encode(['code' => 1, 'msg' => 'heere at the wall']);
}
}
?>

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\User;
class UserController extends Controller {
public function index() {
// 1. Get data from a Model
$users = User::all();
// 2. Pass data to the view via the base render() method
return $this->render('users/index', [
'title' => 'All Users',
'users' => $users
]);
}
public function store() {
User::create([
'username' => $_POST['username'],
'email' => $_POST['email'],
'password' => password_hash($_POST['password'], PASSWORD_DEFAULT)
]);
header('Location: /users');
}
// Update a user
public function update() {
User::updateById($_POST['id'], [
'email' => $_POST['email']
]);
}
// Delete a user
public function destroy() {
User::deleteById($_GET['id']);
}
}
?>

37
app/Core/Auth.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace App\Core;
class Auth {
public static function login($user) {
session_start();
session_regenerate_id(true); // Prevents session hijacking
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['username'];
}
public static function check() {
if (session_status() === PHP_SESSION_NONE) session_start();
return isset($_SESSION['user_id']);
}
public static function user() {
return $_SESSION['user_name'] ?? null;
}
public static function logout() {
session_start();
session_destroy();
header('Location: /login');
exit;
}
public static function getBearerToken(): ?string {
$headers = $_SERVER['Authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? null;
if (!$headers && function_exists('apache_request_headers')) {
$req = apache_request_headers();
$headers = $req['Authorization'] ?? $req['authorization'] ?? null;
}
return ($headers && preg_match('/Bearer\s(\S+)/', $headers, $matches)) ? $matches[1] : null;
}
}
?>

51
app/Core/Config.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace App\Core;
class Config {
private static $instance = null;
private array $settings = [];
private function __construct() {
// Map environment variables to internal keys with optional defaults
$this->settings = [
'db' => [
'host' => $_ENV['DB_HOST'] ?? 'localhost',
'name' => $_ENV['DB_NAME'] ?? 'my_app',
'user' => $_ENV['DB_USER'] ?? 'root',
'pass' => $_ENV['DB_PASS'] ?? '',
],
'app' => [
'name' => $_ENV['APP_NAME'] ?? 'Vanilla PHP App',
'env' => $_ENV['APP_ENV'] ?? 'production',
'debug' => ($_ENV['APP_DEBUG'] ?? 'false') === 'true',
'url' => $_ENV['APP_URL'] ?? 'http://localhost',
],
'mail' => [
'host' => $_ENV['MAIL_HOST'] ?? 'smtp.mailtrap.io',
'port' => $_ENV['MAIL_PORT'] ?? 2525,
]
];
}
public static function get(string $key, $default = null) {
if (self::$instance === null) {
self::$instance = new self();
}
// Support dot notation (e.g., 'db.host')
$keys = explode('.', $key);
$value = self::$instance->settings;
foreach ($keys as $k) {
if (!isset($value[$k])) {
return $default;
}
$value = $value[$k];
}
return $value;
}
}
?>

87
app/Core/Controller.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace App\Core;
abstract class Controller {
/**
* Helper to render a view file with data
*/
protected function renderOld(string $view, array $data = []) {
// Find the view file in the app/Views folder
$viewFile = __DIR__ . "/../Views/{$view}.phtml";
if (!file_exists($viewFile)) {
die("View file $view not found.");
}
// Extract data array into local variables for the view
extract($data);
// Start output buffering to capture the template content
ob_start();
include $viewFile;
echo ob_get_clean();
}
protected function render(string $view, array $data = []) {
extract($data);
// 1. Render the specific page view into a variable
ob_start();
include __DIR__ . "/../Views/{$view}.phtml";
$content = ob_get_clean();
// 2. Render the main layout and pass the $content into it
include __DIR__ . "/../Views/layouts/main.phtml";
}
protected function json(array $data, int $code = 200) {
// Set header to JSON
header('Content-Type: application/json; charset=utf-8');
// Set HTTP status code (200, 201, 400, 404, etc.)
http_response_code($code);
// Output the data
echo json_encode($data);
exit; // End execution to prevent accidental HTML output
}
protected function getNetwork($msisdn){
$phone = str_replace(' ', '', $msisdn);
$phone = str_replace('+', '', $msisdn);
$pattern = "/^\+?(265|0)?[89]\d{8}$/i";
$retval = preg_match($pattern, $phone, $matches, PREG_OFFSET_CAPTURE);
if (count($matches) < 1) {
return false;
}
elseif (strlen($phone) == 9) {
$msisdn = "265" . $phone;
}
elseif (strlen($phone) == 10) {
$phone = ltrim($phone, 0);
$msisdn = "265" . $phone;
}
elseif (strlen($phone) == 12) {
$msisdn = $phone;
}
if (!isset($msisdn)) {
return FALSE;
}
$prefix = substr($msisdn, 0, 4);
if ($prefix == '2658') {
$network = 'tnm';
}
elseif ($prefix == '2659' ) {
$network = 'airtel';
}
else{
return FALSE;
}
return $network;
}
}
?>

17
app/Core/Csrf.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace App\Core;
class Csrf {
public static function generate() {
Session::start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
public static function verify($token) {
Session::start();
return hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
}

33
app/Core/Database.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Core;
use PDO;
class Database {
private static $instance = null;
private $connection;
private function __construct() {
$config = require __DIR__ . '/../Config/database.php';
$dsn = "mysql:host={$config['host']};dbname={$config['db']};charset=utf8mb4";
$this->connection = new PDO($dsn, $config['user'], $config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);
}
public static function getInstance() {
if (!self::$instance) {
self::$instance = new self();
}
return self::$instance->connection;
}
}
// Usage in a Model:
// $db = \App\Core\Database::getInstance();
// $stmt = $db->query("SELECT * FROM users");
?>

60
app/Core/Logger.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
namespace App\Core;
class Logger {
private string $logFile;
private int $maxSize = 5242880;
private int $maxFiles = 5;
public function __construct(string $filename = 'app.log') {
// Place logs in storage/logs/ at the project root
$this->logFile = __DIR__ . "/../../storage/logs/" . $filename;
}
public function log(string $message, string $level = 'INFO'): void {
$this->checkRotation();
$timestamp = date('Y-m-d H:i:s');
$formattedMessage = "[$timestamp] [$level] $message" . PHP_EOL;
file_put_contents($this->logFile, $formattedMessage, FILE_APPEND | LOCK_EX);
}
private function checkRotation(): void {
if (file_exists($this->logFile) && filesize($this->logFile) >= $this->maxSize) {
$this->rotate();
}
}
private function rotate(): void {
// Delete the oldest file if it exists (e.g., app.log.5)
$oldestFile = $this->logFile . '.' . $this->maxFiles;
if (file_exists($oldestFile)) {
unlink($oldestFile);
}
// Shift existing files up (4 becomes 5, 3 becomes 4, etc.)
for ($i = $this->maxFiles - 1; $i >= 1; $i--) {
$currentFile = $this->logFile . '.' . $i;
$nextFile = $this->logFile . '.' . ($i + 1);
if (file_exists($currentFile)) {
rename($currentFile, $nextFile);
}
}
// Move the main log file to .1
rename($this->logFile, $this->logFile . '.1');
// Create a new empty log file
touch($this->logFile);
chmod($this->logFile, 0664);
}
// Shorthand helpers
public function info($msg) { $this->log($msg, 'INFO'); }
public function error($msg) { $this->log($msg, 'ERROR'); }
}
?>

33
app/Core/Model.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Core;
abstract class Model {
protected static $table;
protected static function builder() {
$pdo = Database::getInstance();
return (new QueryBuilder($pdo))->table(static::$table);
}
public static function all() {
return self::builder()->get();
}
public static function find($id) {
return self::builder()->where('id', $id)->get()[0] ?? null;
}
public static function create(array $data) {
return static::builder()->insert($data);
}
public static function updateById($id, array $data) {
return static::builder()->where('id', $id)->update($data);
}
public static function deleteById($id) {
return static::builder()->where('id', $id)->delete();
}
}
?>

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Core;
use PDO;
class OldQueryBuilder {
protected $pdo;
protected $table;
protected $where = [];
protected $params = [];
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function table($table) {
$this->table = $table;
return $this;
}
public function where($column, $value) {
$this->where[] = "{$column} = :{$column}";
$this->params[$column] = $value;
return $this;
}
public function get() {
$sql = "SELECT * FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->params);
return $stmt->fetchAll();
}
public function insert(array $data) {
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
return $this->pdo->prepare($sql)->execute($data);
}
public function update(array $data) {
$fields = "";
foreach ($data as $key => $value) {
$fields .= "{$key} = :{$key}, ";
}
$fields = rtrim($fields, ', ');
$sql = "UPDATE {$this->table} SET {$fields}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
// Merge update data with where parameters
return $this->pdo->prepare($sql)->execute(array_merge($data, $this->params));
}
public function delete() {
$sql = "DELETE FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
return $this->pdo->prepare($sql)->execute($this->params);
}
}
?>

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Core;
use PDO;
class QueryBuilder {
protected $pdo;
protected $table;
protected $where = [];
protected $params = [];
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function table($table) {
$this->table = $table;
return $this;
}
public function where($column, $operator, $value = null) {
if ($value === null) {
$value = $operator;
$operator = '=';
}
$paramName = 'where_' . str_replace('.', '_', $column) . '_' . count($this->params);
$this->where[] = "{$column} {$operator} :{$paramName}";
$this->params[$paramName] = $value;
return $this;
}
public function get() {
$sql = "SELECT * FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
// echo "<b>SQL:</b> " . $sql . "<br><br>";
// echo "<b>Params:</b><pre>"; print_r($this->params); echo "</pre>";
// // die();
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->params);
return $stmt->fetchAll();
}
public function insert(array $data) {
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
return $this->pdo->prepare($sql)->execute($data);
}
public function update(array $data) {
$fields = "";
foreach ($data as $key => $value) {
$fields .= "{$key} = :{$key}, ";
}
$fields = rtrim($fields, ', ');
$sql = "UPDATE {$this->table} SET {$fields}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
return $this->pdo->prepare($sql)->execute(array_merge($data, $this->params));
}
public function delete() {
$sql = "DELETE FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
return $this->pdo->prepare($sql)->execute($this->params);
}
}
?>

163
app/Core/QueryBuilder.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
namespace App\Core;
use PDO;
class QueryBuilder {
protected $pdo;
protected $table;
protected $where = [];
protected $params = [];
protected $orderBy = [];
protected $limit = null;
protected $selects = '*'; // Defaults to everything
protected $joins = [];
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function table($table) {
$this->table = $table;
return $this;
}
// Notice the new $boolean parameter (defaults to 'AND')
public function where($column, $operator, $value = null, $boolean = 'AND') {
if ($value === null) {
$value = $operator;
$operator = '=';
}
$paramName = 'where_' . str_replace('.', '_', $column) . '_' . count($this->params);
// If this is the first where clause, we don't need 'AND' or 'OR'
$prefix = empty($this->where) ? '' : strtoupper($boolean) . ' ';
$this->where[] = "{$prefix}{$column} {$operator} :{$paramName}";
$this->params[$paramName] = $value;
return $this;
}
// orWhere simply calls where() but passes 'OR'
public function orWhere($column, $operator, $value = null) {
return $this->where($column, $operator, $value, 'OR');
}
// Sort the results
public function orderBy($column, $direction = 'ASC') {
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
$this->orderBy[] = "{$column} {$direction}";
return $this;
}
// Limit the number of results
public function limit(int $limit) {
$this->limit = $limit;
return $this;
}
// Fetch a single record instead of an array of arrays
public function first() {
$this->limit(1);
$result = $this->get();
return $result ? $result[0] : null;
}
public function getOld() {
$sql = "SELECT * FROM {$this->table}";
// Changed implode from ' AND ' to ' ' because the prefix handles it now
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' ', $this->where);
}
if (!empty($this->orderBy)) {
$sql .= " ORDER BY " . implode(', ', $this->orderBy);
}
if ($this->limit !== null) {
$sql .= " LIMIT {$this->limit}";
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->params);
return $stmt->fetchAll();
}
public function get() {
// Use the new selects property instead of hardcoded '*'
$sql = "SELECT {$this->selects} FROM {$this->table}";
// Add joins immediately after the FROM clause
if (!empty($this->joins)) {
$sql .= " " . implode(' ', $this->joins);
}
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' ', $this->where);
}
if (!empty($this->orderBy)) {
$sql .= " ORDER BY " . implode(', ', $this->orderBy);
}
if ($this->limit !== null) {
$sql .= " LIMIT {$this->limit}";
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->params);
return $stmt->fetchAll();
}
public function insert(array $data) {
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
return $this->pdo->prepare($sql)->execute($data);
}
public function update(array $data) {
$fields = "";
foreach ($data as $key => $value) {
$fields .= "{$key} = :{$key}, ";
}
$fields = rtrim($fields, ', ');
$sql = "UPDATE {$this->table} SET {$fields}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' ', $this->where);
}
return $this->pdo->prepare($sql)->execute(array_merge($data, $this->params));
}
public function delete() {
$sql = "DELETE FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' ', $this->where);
}
return $this->pdo->prepare($sql)->execute($this->params);
}
// Specify exactly which columns to return
public function select($columns) {
// Allow passing an array ['id', 'name'] or a raw string 'id, name'
$this->selects = is_array($columns) ? implode(', ', $columns) : $columns;
return $this;
}
// The main join method
public function join($table, $firstColumn, $operator, $secondColumn, $type = 'INNER') {
$this->joins[] = "{$type} JOIN {$table} ON {$firstColumn} {$operator} {$secondColumn}";
return $this;
}
// A helpful shortcut for LEFT JOINS
public function leftJoin($table, $firstColumn, $operator, $secondColumn) {
return $this->join($table, $firstColumn, $operator, $secondColumn, 'LEFT');
}
}
?>

66
app/Core/Router.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
namespace App\Core;
class Router {
protected $routes = [];
public function get($path, $callback) {
$this->routes['GET'][$path] = $callback;
}
// Registers POST routes
public function post($path, $callback) {
$this->routes['POST'][$path] = $callback;
}
public function resolve($uri, $method) {
// Strip query strings (e.g., /users?id=1 becomes /users)
// In public/index.php
$path = parse_url($uri, PHP_URL_PATH);
// var_dump($path);
$callback = $this->routes[$method][$path] ?? null;
if (!$callback) {
http_response_code(404);
// echo "404 Not Found";
$this->handleNotFound($path, $method);
return;
}
// Handle 'Controller@method' strings
if (is_string($callback)) {
[$controllerName, $methodName] = explode('@', $callback);
$controllerClass = "\\App\\Controllers\\" . $controllerName;
$controller = new $controllerClass();
$controller->$methodName();
}
}
private function handleNotFound($path, $method) {
$logger = new Logger();
$isDebug = ($_ENV['APP_DEBUG'] ?? 'false') === 'true';
// 1. Log the failure for the developer
$logMessage = "404 Not Found | Method: $method | URI: $path";
$logger->error($logMessage);
http_response_code(404);
// 2. If Debug is ON, show detailed info in the browser
if ($isDebug) {
// echo "<h1>404 Not Found (Debug Mode)</h1>";
// echo "<p><strong>Method:</strong> $method</p>";
// echo "<p><strong>Attempted URI:</strong> $path</p>";
// echo "<p><strong>Defined Routes:</strong></p><pre>";
// print_r($this->routes[$method] ?? []);
// echo "</pre>";
echo json_encode([ 'message' => 'Not Found', 'method' => $method, 'path' => $path]);
} else {
// Simple message for production
echo "404 Not Found";
}
exit;
}
}
?>

22
app/Core/Session.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace App\Core;
class Session {
public static function start() {
if (session_status() === PHP_SESSION_NONE) session_start();
}
public static function setFlash($key, $message) {
self::start();
$_SESSION['flash'][$key] = $message;
}
public static function getFlash($key) {
self::start();
$message = $_SESSION['flash'][$key] ?? null;
unset($_SESSION['flash'][$key]); // Delete after retrieval
return $message;
}
}
?>

24
app/Core/Validator.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Core;
class Validator {
public static function validate(array $data, array $rules) {
$errors = [];
foreach ($rules as $field => $rule) {
if (strpos($rule, 'required') !== false && empty($data[$field])) {
$errors[$field] = ucfirst($field) . " is required.";
}
if (strpos($rule, 'email') !== false && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) {
$errors[$field] = "Invalid email format.";
}
if (strpos($rule, 'min:8') !== false && strlen($data[$field] ?? '') < 8) {
$errors[$field] = ucfirst($field) . " must be at least 8 characters.";
}
}
return $errors;
}
}
?>

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use App\Core\Model;
class AirtelBroker extends Model {
protected static $table = 'airtel_money_wallets';
public static function findActive() {
return self::builder()->where('status', 'active')->get();
}
public static function getBroker($wallet_id){
$details = self::builder()->where('id', $wallet_id)->get();
if ($details == false) {
return false;
}
return $details;
}
}
?>

47
app/Models/Auth.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
class Auth {
private $connection;
// private $bearer_token;
public function __construct($db) {
$this->connection = $db;
}
public function read_api_auth(){
// $bearer = $this->bearer_token;
if(!function_exists('getallheaders')){
return false;
}
$headers = [];
foreach (getallheaders() as $name => $value) {
// echo "$name: $value <br>" . PHP_EOL;
$headers[$name] = $value;
}
$check = array_key_exists('Authorization', $headers);
if ($check == false) {
return false;
}
list($type, $bearer_token) = explode(" ", $headers['Authorization'], 2);
$query = 'SELECT id, name FROM auth_users WHERE bearer_token = ? LIMIT 0,1';
$statement = $this->connection->prepare($query);
$statement->bindParam(1, $bearer_token);
$statement->execute();
$row = $statement->fetch(PDO::FETCH_ASSOC);
if ($row == false) {
return false;
}
else{
return true;
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Core\Model;
class Disbursement extends Model {
protected static $table = 'disbursements';
public static function findActive() {
return self::builder()->where('status', 'active')->get();
}
public static function varifyTransaction($transaction_id, $reference_number){
$transaction = self::builder()->where('transaction_id', $transaction_id)
->where('infotech_transaction_id', $reference_number)
->get();
if ($transaction == false) {
// code...
return false;
}
return $transaction;
}
public static function updateTransaction($id, $params){
$transaction = self::builder()->where('transaction_id', $transaction_id)
->where('infotech_transaction_id', $reference_number)
->get();
if ($transaction == false) {
// code...
return false;
}
return $transaction;
}
}
?>

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use App\Core\Model;
class MpambaBroker extends Model {
protected static $table = 'mpamba_tnm_wallets';
public static function findActive() {
return self::builder()->where('status', 'active')->get();
}
public static function getBroker($wallet_id){
$details = self::builder()->where('wallet_id', $wallet_id)->get();
if ($details == false) {
return false;
}
return $details;
}
}
?>

20
app/Models/User.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use App\Core\Model;
class User extends Model {
// Tell the model which database table to use
protected static $table = 'auth_users';
// You can add custom business logic here
public static function findActive() {
return self::builder()->where('status', 'active')->get();
}
public static function getByToken($token) {
return self::builder()->where('bearer_token', $token)->get();
}
}
?>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><?= $title ?? 'My PHP App' ?></title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav>
<a href="/">Home</a>
<?php if (\App\Core\Auth::check()): ?>
<span>Welcome, <?= htmlspecialchars(\App\Core\Auth::user()) ?></span>
<a href="/logout">Logout</a>
<?php else: ?>
<a href="/login">Login</a>
<?php endif; ?>
</nav>
<main>
<!-- This is where the page content goes -->
<?= $content ?>
</main>
<footer>&copy; <?= date('Y') ?> My Vanilla App</footer>
<script src="/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
<!-- The $title and $users variables were passed from the controller -->
<h1><?= htmlspecialchars($title) ?></h1>
<ul>
<?php foreach ($users as $user): ?>
<li><?= htmlspecialchars($user['name']) ?> (<?= htmlspecialchars($user['email']) ?>)</li>
<?php endforeach; ?>
</ul>

19
app/auth_user.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
// Headers
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/json');
header('Access-Control-Allow-Methods: POST');
header('Access-Control-Allow-Headers: Access-Control-Allow-Headers,Content-Type,Access-Control-Allow-Methods, Authorization, X-Requested-With');
include_once '../models/Auth.php';
include_once '../config/Database.php';
//require_once("../getrequestheaders.php");
$database = new Database();
$db = $database->connect();
$auth = new Auth($db);
$retval = $auth->read_api_auth();
var_dump($retval);

69
app/disbursement.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
// Headersa
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/json');
header('Access-Control-Allow-Methods: POST');
// header('Access-Control-Allow-Headers: Access-Control-Allow-Headers, Content-Type,Access-Control-Allow-Methods, Authorization, X-Requested-With');
include_once 'models/Auth.php';
include_once 'config/Database.php';
include_once 'models/Disbursement.php';
$database = new Database();
$db = $database->connect();
$auth = new Auth($db);
$retval = $auth->read_api_auth();
$data = json_decode(file_get_contents("php://input") , true);
@file_put_contents("logs/" . date("Y_m_d_") . "contact_centre_electricity_purchase_requests.txt", json_encode($data) . PHP_EOL, FILE_APPEND);
if ($retval == false) {
http_response_code(401);
echo json_encode(["status" => "fail", "message" => "Unauthorised Access"]);
exit();
}
$requested_keys = ['transaction_id', 'reference_id', 'mobile_number', 'amount'];
$missing = [];
$missing = array_diff_key(array_flip($requested_keys), $data);
if (count($missing) > 0) {
$missing_string = implode(", ", array_flip($missing));
http_response_code(400);
echo json_encode(["status" => "fail", "message" => "Required parameter(s) missing : $missing_string"]);
exit();
}
// var_dump($missing);
// var_dump($data); die;
// TODO: check if transaction ID matches our record in DB
$result = varifyTransaction($data['transaction_id'], $data['mobile_number']);
// var_dump($result);
if ($result == false) {
http_response_code(200);
echo json_encode(["status" => "fail", "message" => "Transaction ID not found"]);
exit();
}
if($result !== false) {
// TODO: send request to ultima to purchase electricty
$result = processDisbursement($data['transaction_id'], $data['mobile_number'], $data['amount'], $data['reference_id']);
if ($electricity_token !== false) {
http_response_code(200);
echo json_encode(["status" => "success", "reference_id" => $data['reference_id']]);
exit();
}
http_response_code(200);
echo json_encode(["status" => "fail", "reference_id" => $data['reference_id'], "message" => "Disbursement could not be processed at this time"]);
exit();
}
else {
http_response_code(200);
echo json_encode(["status" => "fail", "reference_id" => $data['reference_id'], "message" => "Disbursement failed"]);
exit();
}

View File

@@ -0,0 +1,88 @@
<?php
defined('SMS_API_KEY') OR define("SMS_API_KEY", "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1NDciLCJvaWQiOjU0NywidWlkIjoiNmFmMmMyZDktZTIwZS00YmYwLTg4ZTgtOGEwMzY0YmU5YTAyIiwiYXBpZCI6MzM1LCJpYXQiOjE3MTIyMjcyNDcsImV4cCI6MjA1MjIyNzI0N30.CG5VW2FU18yx-1OUMtPqFMce06LFUZwai-ecdmb79Ls67T7k4L7RuinSluZMWn1epP883ongI-E5fklSBVaEvQ");
function httpPostNew($params){
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => 'http://206.225.81.36:8989/api/messaging/sendsms',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $params,
CURLOPT_HTTPHEADER => array(
'Content-Type: application/json',
'Authorization: Bearer ' . SMS_API_KEY
),
));
$response = curl_exec($curl);
curl_close($curl);
return $response;
}
function slackCurl($message){
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => "https://hooks.slack.com/services/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",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => $message,
CURLOPT_HTTPHEADER => array(
"cache-control: no-cache",
"content-type: application/json"
),
));
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
return "cURL Error #:" . $err;
} else {
return $response;
}
}
function sendSlack($message){
$retval = array('text' => $message);
$response = slackCurl(json_encode($retval));
return $response;
}
function sendNtfy($data){
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_HTTPHEADER => array(
'Content-Type: application/json'
),
CURLOPT_URL => 'https://ntfy.sh/bpc_rest_api',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $data
));
$response = curl_exec($curl);
return $response;
}
?>