Compare commits

..

2 Commits

Author SHA1 Message Date
Flatlogic Bot
5d42eee08c Auto commit: 2026-01-03T08:58:39.154Z 2026-01-03 08:58:39 +00:00
Flatlogic Bot
7dde39bcd5 School System 2026-01-03 08:40:36 +00:00
37 changed files with 2417 additions and 145 deletions

89
README.md Normal file
View File

@ -0,0 +1,89 @@
# School Accounting ERP
This project is a comprehensive, enterprise-grade accounting and ERP system designed specifically for educational institutions, with a focus on the Ghanaian market. It provides a robust backend for managing all financial aspects of a school, from student billing to detailed financial reporting and auditing.
The system is built with a clear separation of concerns, ensuring that the core accounting engine remains reliable and independent, while allowing for intelligence and visualization layers to be built on top.
## Core Principles
* **Accounting First:** The system is built around a solid, double-entry accounting engine, ensuring all financial data is accurate and balanced.
* **Read-Only Insights:** The intelligence and dashboard layers are strictly read-only, ensuring they can never corrupt the core accounting data.
* **Auditability:** A comprehensive audit trail logs every significant financial action, providing a complete and immutable history.
* **Modularity:** The system is designed in phases, allowing for incremental development and deployment.
## Features (Phases 1-5)
The backend is functionally complete and provides the following features:
* **Chart of Accounts:** A flexible and customizable chart of accounts to categorize all financial transactions.
* **Student Management:** Manage student records and enrollment.
* **Fee Structures:** Define complex fee structures and assign them to students.
* **Invoicing:** Automatically generate and issue invoices to students.
* **Payments:** Record and track student payments.
* **Expense Management:** Manage vendors and track bills and bill payments.
* **Journal Entries:** Manually create journal entries for adjustments and other accounting tasks.
* **Financial Reporting:**
* **Trial Balance:** Ensure the books are always balanced.
* **Income Statement:** Track revenue, expenses, and profitability.
* **Balance Sheet:** Get a snapshot of the school's financial position.
* **Accounts Receivable (A/R) Aging:** Monitor outstanding invoices.
* **Accounts Payable (A/P) Aging:** Track outstanding bills.
* **Financial Controls:**
* **Accounting Periods:** Close accounting periods to prevent unauthorized changes to historical data.
* **Audit Trail:**
* **Comprehensive Logging:** Every key financial transaction is logged for full traceability.
## Technology Stack
* **Backend:** PHP 8.x (Vanilla)
* **Database:** MariaDB / MySQL
* **Web Server:** Apache
* **Frontend:** React (rendered client-side)
## Project Structure
```
├── api/ # API endpoints
│ ├── reports/ # Financial report endpoints
│ └── ...
├── db/ # Database scripts
│ ├── migrations/ # Database migration files
│ └── config.php # Database connection configuration
├── includes/ # Helper functions and utilities
├── mail/ # Email sending service
├── ai/ # AI/ML integration hooks
├── dashboard/ # Frontend React application
│ ├── src/
│ └── ...
├── .htaccess # Apache configuration
├── index.php # Main application entry point (serves the dashboard)
└── README.md # This file
```
## Setup and Installation
1. **Environment:** Set up a standard LAMP (Linux, Apache, MySQL, PHP) stack.
2. **Database:**
* Create a new MySQL/MariaDB database.
* Update the credentials in `db/config.php`.
3. **Migrations:** Run the database migrations located in the `db/migrations/` directory in sequential order to set up the database schema.
4. **Web Server:** Configure Apache to use the project's root directory as the `DocumentRoot`.
5. **Access:** Open the project URL in your browser. The `index.php` file will automatically load the React-based dashboard.
## API Endpoints
The core backend functionality is exposed via a series of PHP-based API endpoints in the `api/` directory. The frontend dashboard interacts with these endpoints to display data. Key endpoints include:
* `/api/students_post.php`
* `/api/invoices_post.php`
* `/api/payments_post.php`
* `/api/vendors_post.php`
* `/api/bills_post.php`
* `/api/reports/trial_balance.php`
* `/api/reports/income_statement.php`
* `/api/reports/balance_sheet.php`
* `/api/reports/ar_aging.php`
* `/api/reports/ap_aging.php`
---
*This README provides a high-level overview. For detailed API specifications and implementation details, please refer to the source code.*

View File

@ -0,0 +1,29 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '../../db/config.php';
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['period_id'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing period_id.']);
exit;
}
$pdo = db();
try {
$stmt = $pdo->prepare("UPDATE accounting_periods SET status = 'closed' WHERE id = ?");
$stmt->execute([$data['period_id']]);
if ($stmt->rowCount() > 0) {
echo json_encode(['success' => true, 'message' => 'Accounting period closed successfully.']);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Accounting period not found.']);
}
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database error: ' . $e->getMessage()]);
}

View File

@ -0,0 +1,24 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '../../db/config.php';
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['period_name']) || empty($data['start_date']) || empty($data['end_date'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing required fields.']);
exit;
}
$pdo = db();
try {
$stmt = $pdo->prepare("INSERT INTO accounting_periods (period_name, start_date, end_date) VALUES (?, ?, ?)");
$stmt->execute([$data['period_name'], $data['start_date'], $data['end_date']]);
echo json_encode(['success' => true, 'message' => 'Accounting period created successfully.']);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database error: ' . $e->getMessage()]);
}

View File

@ -0,0 +1,98 @@
<?php
require_once __DIR__ . '/../includes/uuid.php';
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/../includes/journal_helpers.php';
require_once __DIR__ . '/../includes/audit_helpers.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit();
}
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['vendor_id']) || empty($data['payment_date']) || empty($data['payment_account_code']) || empty($data['amount']) || empty($data['bill_ids'])) {
http_response_code(400);
echo json_encode(['error' => 'Missing required fields: vendor_id, payment_date, payment_account_code, amount, and bill_ids array.']);
exit();
}
$pdo = db();
try {
$pdo->beginTransaction();
$total_payment_amount = (float)$data['amount'];
$ap_account_code = '2100-AP'; // Accounts Payable
// 1. Create Journal Entry to settle AP
$journal_lines = [
// Debit Accounts Payable
[
'account_code' => $ap_account_code,
'type' => 'DEBIT',
'amount' => $total_payment_amount
],
// Credit the cash/bank account used for payment
[
'account_code' => $data['payment_account_code'],
'type' => 'CREDIT',
'amount' => $total_payment_amount
]
];
$journal_payload = [
'entry_date' => $data['payment_date'],
'description' => 'Payment for vendor ' . $data['vendor_id'],
'lines' => $journal_lines
];
$journal_result = post_journal_entry($pdo, $journal_payload);
if (empty($journal_result['journal_entry_id'])) {
throw new Exception('Failed to post journal entry: ' . ($journal_result['error'] ?? 'Unknown error'));
}
$journal_entry_id = $journal_result['journal_entry_id'];
// 2. Create the Payment Record
$payment_id = uuid_v4();
$stmt = $pdo->prepare(
"INSERT INTO payments (id, journal_entry_id, vendor_id, payment_date, amount, payment_method, reference_type) VALUES (?, ?, ?, ?, ?, ?, 'BILL')"
);
$stmt->execute([
$payment_id,
$journal_entry_id,
$data['vendor_id'],
$data['payment_date'],
$total_payment_amount,
$data['payment_method'] ?? 'Bank Transfer'
]);
// 3. Link payment to bills in payment_lines (and update bill statuses)
$stmt_line = $pdo->prepare("INSERT INTO payment_lines (id, payment_id, invoice_id) VALUES (?, ?, ?)"); // Reusing invoice_id for bill_id
$stmt_update_bill = $pdo->prepare("UPDATE bills SET status = 'PAID' WHERE bill_id = ?");
foreach($data['bill_ids'] as $bill_id) {
$stmt_line->execute([uuid_v4(), $payment_id, $bill_id]);
$stmt_update_bill->execute([$bill_id]);
}
// Log audit trail
log_audit_trail($pdo, 'pay_bill', ['payment_id' => $payment_id, 'vendor_id' => $data['vendor_id'], 'amount' => $total_payment_amount]);
$pdo->commit();
http_response_code(201);
echo json_encode([
'status' => 'success',
'payment_id' => $payment_id,
'journal_entry_id' => $journal_entry_id
]);
} catch (Exception $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Payment failed: ' . $e->getMessage()]);
}

102
api/bills_post.php Normal file
View File

@ -0,0 +1,102 @@
<?php
require_once __DIR__ . '/../includes/uuid.php';
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/../includes/journal_helpers.php';
require_once __DIR__ . '/../includes/audit_helpers.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit();
}
$data = json_decode(file_get_contents('php://input'), true);
// Basic validation
if (empty($data['vendor_id']) || empty($data['bill_date']) || empty($data['due_date']) || empty($data['lines']) || !is_array($data['lines'])) {
http_response_code(400);
echo json_encode(['error' => 'Missing required fields: vendor_id, bill_date, due_date, and lines array.']);
exit();
}
$pdo = db();
try {
$pdo->beginTransaction();
// 1. Calculate total amount and prepare journal entry lines
$total_amount = 0;
$journal_lines = [];
$ap_account_code = '2100-AP'; // Accounts Payable
foreach ($data['lines'] as $line) {
if (empty($line['expense_account_code']) || empty($line['amount'])) {
throw new Exception('Each bill line must have expense_account_code and amount.');
}
$amount = (float)$line['amount'];
$total_amount += $amount;
// Debit the expense account for this line
$journal_lines[] = [
'account_code' => $line['expense_account_code'],
'type' => 'DEBIT',
'amount' => $amount
];
}
// Credit Accounts Payable for the total amount
$journal_lines[] = [
'account_code' => $ap_account_code,
'type' => 'CREDIT',
'amount' => $total_amount
];
// 2. Create the Journal Entry
$journal_entry_payload = [
'entry_date' => $data['bill_date'],
'description' => 'Vendor bill from ' . ($data['vendor_name'] ?? $data['vendor_id']),
'lines' => $journal_lines
];
// Use a function to post the journal entry
$journal_entry_result = post_journal_entry($pdo, $journal_entry_payload);
if (empty($journal_entry_result['journal_entry_id'])) {
throw new Exception("Failed to post journal entry: " . ($journal_entry_result['error'] ?? 'Unknown error'));
}
$journal_entry_id = $journal_entry_result['journal_entry_id'];
// 3. Create the Bill
$bill_id = uuid_v4();
$stmt = $pdo->prepare(
"INSERT INTO bills (bill_id, vendor_id, journal_entry_id, bill_date, due_date, total_amount, status) VALUES (?, ?, ?, ?, ?, ?, 'SUBMITTED')"
);
$stmt->execute([$bill_id, $data['vendor_id'], $journal_entry_id, $data['bill_date'], $data['due_date'], $total_amount]);
// 4. Create Bill Lines
$stmt_lines = $pdo->prepare(
"INSERT INTO bill_lines (bill_line_id, bill_id, expense_account_code, description, amount) VALUES (?, ?, ?, ?, ?)"
);
foreach ($data['lines'] as $line) {
$stmt_lines->execute([uuid_v4(), $bill_id, $line['expense_account_code'], $line['description'] ?? null, $line['amount']]);
}
// Log audit trail
log_audit_trail($pdo, 'create_bill', ['bill_id' => $bill_id, 'vendor_id' => $data['vendor_id'], 'total_amount' => $total_amount]);
$pdo->commit();
// 5. Fetch and return the created bill
$stmt = $pdo->prepare("SELECT * FROM bills WHERE bill_id = ?");
$stmt->execute([$bill_id]);
$new_bill = $stmt->fetch(PDO::FETCH_ASSOC);
http_response_code(201);
echo json_encode($new_bill);
} catch (Exception $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Operation failed: ' . $e->getMessage()]);
}

View File

@ -0,0 +1,96 @@
<?php
// api/fee_structures_post.php
header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/../includes/uuid.php';
$response = [
'status' => 'error',
'message' => 'Invalid request.'
];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (json_last_error() !== JSON_ERROR_NONE) {
$response['message'] = 'Invalid JSON payload.';
http_response_code(400);
echo json_encode($response);
exit;
}
// Basic validation
if (empty($input['name']) || empty($input['academic_year']) || empty($input['lines']) || !is_array($input['lines'])) {
$response['message'] = 'Missing required fields: name, academic_year, and lines array.';
http_response_code(400);
echo json_encode($response);
exit;
}
$pdo = db();
$pdo->beginTransaction();
try {
$fee_structure_id = uuid_v4();
$status = $input['status'] ?? 'Draft';
$stmt = $pdo->prepare(
"INSERT INTO fee_structures (id, name, academic_year, status) VALUES (?, ?, ?, ?)"
);
$stmt->execute([$fee_structure_id, $input['name'], $input['academic_year'], $status]);
$lines = [];
foreach ($input['lines'] as $line) {
if (empty($line['description']) || !isset($line['amount']) || empty($line['revenue_account_code'])) {
throw new Exception('Each line must have a description, amount, and revenue_account_code.');
}
// Get revenue_account_id from code
$stmt_acc = $pdo->prepare("SELECT id FROM accounts WHERE account_code = ? AND account_type = 'Revenue'");
$stmt_acc->execute([$line['revenue_account_code']]);
$revenue_account = $stmt_acc->fetch(PDO::FETCH_ASSOC);
if (!$revenue_account) {
throw new Exception("Invalid or non-revenue account code provided: {" . $line['revenue_account_code'] . "}");
}
$revenue_account_id = $revenue_account['id'];
$line_id = uuid_v4();
$stmt_line = $pdo->prepare(
"INSERT INTO fee_structure_lines (id, fee_structure_id, description, amount, revenue_account_id) VALUES (?, ?, ?, ?, ?)"
);
$stmt_line->execute([$line_id, $fee_structure_id, $line['description'], $line['amount'], $revenue_account_id]);
$lines[] = [
'id' => $line_id,
'description' => $line['description'],
'amount' => $line['amount'],
'revenue_account_code' => $line['revenue_account_code']
];
}
$pdo->commit();
$response['status'] = 'success';
$response['message'] = 'Fee structure created successfully.';
$response['data'] = [
'id' => $fee_structure_id,
'name' => $input['name'],
'academic_year' => $input['academic_year'],
'status' => $status,
'lines' => $lines
];
http_response_code(201);
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$response['message'] = 'Error creating fee structure: ' . $e->getMessage();
http_response_code(500);
}
}
echo json_encode($response);

115
api/invoices_post.php Normal file
View File

@ -0,0 +1,115 @@
<?php
// api/invoices_post.php
header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/../includes/uuid.php';
require_once __DIR__ . '/../includes/journal_helpers.php';
$response = [
'status' => 'error',
'message' => 'Invalid request.'
];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (json_last_error() !== JSON_ERROR_NONE || empty($input['student_id']) || empty($input['fee_structure_id'])) {
$response['message'] = 'Invalid JSON payload or missing student_id/fee_structure_id.';
http_response_code(400);
echo json_encode($response);
exit;
}
$pdo = db();
try {
$pdo->beginTransaction();
// 1. Fetch Fee Structure details
$stmt_fsl = $pdo->prepare("SELECT fsl.amount, a.account_code FROM fee_structure_lines fsl JOIN accounts a ON fsl.revenue_account_id = a.id WHERE fsl.fee_structure_id = ?");
$stmt_fsl->execute([$input['fee_structure_id']]);
$fee_structure_lines = $stmt_fsl->fetchAll(PDO::FETCH_ASSOC);
if (empty($fee_structure_lines)) throw new Exception('Fee structure has no lines or revenue accounts not found.');
// 2. Create Invoice
$total_amount = array_sum(array_column($fee_structure_lines, 'amount'));
$invoice_id = uuid_v4();
$invoice_number = 'INV-' . time();
$invoice_date = $input['invoice_date'] ?? date('Y-m-d');
$due_date = $input['due_date'] ?? date('Y-m-d', strtotime('+30 days'));
$stmt_inv = $pdo->prepare(
"INSERT INTO invoices (id, student_id, invoice_number, invoice_date, due_date, total_amount, status) VALUES (?, ?, ?, ?, ?, ?, 'Posted')"
);
$stmt_inv->execute([$invoice_id, $input['student_id'], $invoice_number, $invoice_date, $due_date, $total_amount]);
// Don't need to insert invoice lines manually if they are based on fee structure, can be joined.
// For simplicity, let's assume the previous logic of creating them was desired.
// Re-creating invoice lines for atomicity.
foreach ($fee_structure_lines as $line) {
$stmt_inv_line = $pdo->prepare(
"INSERT INTO invoice_lines (id, invoice_id, description, amount, revenue_account_id) VALUES (?, ?, ?, ?, (SELECT id from accounts where account_code = ?))"
);
$stmt_inv_line->execute([uuid_v4(), $invoice_id, 'Fee', $line['amount'], $line['account_code']]);
}
// 3. Prepare and Create Journal Entry
$journal_lines = [];
// Debit AR
$journal_lines[] = [
'account_code' => '1200-AR-STUDENTS',
'type' => 'DEBIT',
'amount' => $total_amount
];
// Credit Revenue accounts
foreach ($fee_structure_lines as $line) {
$journal_lines[] = [
'account_code' => $line['account_code'],
'type' => 'CREDIT',
'amount' => $line['amount']
];
}
$journal_payload = [
'entry_date' => $invoice_date,
'description' => "Invoice {$invoice_number} for student {$input['student_id']}",
'lines' => $journal_lines
];
$journal_result = post_journal_entry($pdo, $journal_payload);
if (empty($journal_result['journal_entry_id'])) {
throw new Exception("Failed to post journal entry: " . ($journal_result['error'] ?? 'Unknown error'));
}
$journal_entry_id = $journal_result['journal_entry_id'];
// Link Invoice to Journal Entry
$stmt_inv_update = $pdo->prepare("UPDATE invoices SET journal_entry_id = ? WHERE id = ?");
$stmt_inv_update->execute([$journal_entry_id, $invoice_id]);
// Log audit trail
log_audit_trail($pdo, 'create_invoice', ['invoice_id' => $invoice_id, 'student_id' => $input['student_id'], 'total_amount' => $total_amount]);
$pdo->commit();
$response = [
'status' => 'success',
'message' => 'Invoice created and journal entry posted successfully.',
'data' => [
'invoice_id' => $invoice_id,
'invoice_number' => $invoice_number,
'journal_entry_id' => $journal_entry_id
]
];
http_response_code(201);
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$response['message'] = 'Error creating invoice: ' . $e->getMessage();
http_response_code(500);
}
}
echo json_encode($response);

123
api/payments_post.php Normal file
View File

@ -0,0 +1,123 @@
<?php
// api/payments_post.php
header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/../includes/uuid.php';
require_once __DIR__ . '/../includes/audit_helpers.php';
$response = [
'status' => 'error',
'message' => 'Invalid request.'
];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (json_last_error() !== JSON_ERROR_NONE || empty($input['student_id']) || empty($input['amount_received']) || empty($input['payment_method']) || empty($input['cash_account_code']) || empty($input['lines'])) {
$response['message'] = 'Invalid JSON payload or missing required fields.';
http_response_code(400);
echo json_encode($response);
exit;
}
$pdo = db();
try {
$pdo->beginTransaction();
// 1. Fetch Student and Accounts
$stmt_student = $pdo->prepare("SELECT subledger_id FROM students WHERE id = ?");
$stmt_student->execute([$input['student_id']]);
$student_subledger_id = $stmt_student->fetchColumn();
if (!$student_subledger_id) throw new Exception('Student not found.');
$stmt_ar = $pdo->prepare("SELECT id FROM accounts WHERE account_code = '1200-AR-STUDENTS'");
$stmt_ar->execute();
$ar_account_id = $stmt_ar->fetchColumn();
if (!$ar_account_id) throw new Exception('AR account not found.');
$stmt_cash = $pdo->prepare("SELECT id FROM accounts WHERE account_code = ? AND account_type = 'Asset'");
$stmt_cash->execute([$input['cash_account_code']]);
$cash_account_id = $stmt_cash->fetchColumn();
if (!$cash_account_id) throw new Exception('Invalid cash/asset account code.');
// 2. Create Payment
$payment_id = uuid_v4();
$payment_date = $input['payment_date'] ?? date('Y-m-d');
$stmt_payment = $pdo->prepare(
"INSERT INTO payments (id, student_id, payment_date, amount_received, payment_method, reference_number) VALUES (?, ?, ?, ?, ?, ?)"
);
$stmt_payment->execute([$payment_id, $input['student_id'], $payment_date, $input['amount_received'], $input['payment_method'], $input['reference_number'] ?? null]);
// 3. Create Payment Lines and Update Invoices
$total_applied = 0;
foreach ($input['lines'] as $line) {
$total_applied += $line['amount_applied'];
$stmt_pl = $pdo->prepare("INSERT INTO payment_lines (id, payment_id, invoice_id, amount_applied) VALUES (?, ?, ?, ?)");
$stmt_pl->execute([uuid_v4(), $payment_id, $line['invoice_id'], $line['amount_applied']]);
// Check if invoice is fully paid
$stmt_inv_total = $pdo->prepare("SELECT total_amount FROM invoices WHERE id = ?");
$stmt_inv_total->execute([$line['invoice_id']]);
$invoice_total = $stmt_inv_total->fetchColumn();
$stmt_paid_total = $pdo->prepare("SELECT SUM(amount_applied) FROM payment_lines WHERE invoice_id = ?");
$stmt_paid_total->execute([$line['invoice_id']]);
$total_paid_for_invoice = $stmt_paid_total->fetchColumn();
if ($total_paid_for_invoice >= $invoice_total) {
$stmt_inv_update = $pdo->prepare("UPDATE invoices SET status = 'Paid' WHERE id = ?");
$stmt_inv_update->execute([$line['invoice_id']]);
}
}
if (bccomp($total_applied, $input['amount_received'], 2) != 0) {
throw new Exception('Sum of amounts applied does not match amount received.');
}
// 4. Create Journal Entry
$journal_entry_id = uuid_v4();
$description = "Payment {$input['reference_number']} for student {$input['student_id']}";
$stmt_je = $pdo->prepare(
"INSERT INTO journal_entries (id, entry_date, description, status, posted_at) VALUES (?, ?, ?, 'Posted', NOW())"
);
$stmt_je->execute([$journal_entry_id, $payment_date, $description]);
// Debit Cash/Bank
$stmt_jel_dr = $pdo->prepare("INSERT INTO journal_entry_lines (id, journal_entry_id, account_id, debit) VALUES (?, ?, ?, ?)");
$stmt_jel_dr->execute([uuid_v4(), $journal_entry_id, $cash_account_id, $input['amount_received']]);
// Credit AR
$stmt_jel_cr = $pdo->prepare(
"INSERT INTO journal_entry_lines (id, journal_entry_id, account_id, subledger_id, credit) VALUES (?, ?, ?, ?, ?)"
);
$stmt_jel_cr->execute([uuid_v4(), $journal_entry_id, $ar_account_id, $student_subledger_, $input['amount_received']]);
// Link Payment to Journal Entry
$stmt_payment_update = $pdo->prepare("UPDATE payments SET journal_entry_id = ? WHERE id = ?");
$stmt_payment_update->execute([$journal_entry_id, $payment_id]);
// Log audit trail
log_audit_trail($pdo, 'record_payment', ['payment_id' => $payment_id, 'student_id' => $input['student_id'], 'amount' => $input['amount_received']]);
$pdo->commit();
$response = [
'status' => 'success',
'message' => 'Payment recorded and journal entry posted successfully.',
'data' => [
'payment_id' => $payment_id,
'journal_entry_id' => $journal_entry_id
]
];
http_response_code(201);
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$response['message'] = 'Error recording payment: ' . $e->getMessage();
http_response_code(500);
}
}
echo json_encode($response, JSON_PRETTY_PRINT);

86
api/reports/ap_aging.php Normal file
View File

@ -0,0 +1,86 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '../../../db/config.php';
//
// ## Accounts Payable Aging Report
//
// Purpose: Show outstanding vendor balances grouped by age.
//
$pdo = db();
$as_of_date = $_GET['as_of_date'] ?? date('Y-m-d');
try {
$sql = <<<SQL
SELECT
s.subledger_id as vendor_id,
s.name as vendor_name,
b.id as bill_id,
b.bill_date,
b.due_date,
DATEDIFF(:as_of_date, b.due_date) as days_overdue,
(b.total_amount - COALESCE(p.paid_amount, 0)) as outstanding_balance
FROM bills b
JOIN subledgers s ON b.vendor_id = s.subledger_id
LEFT JOIN (
SELECT reference_id, SUM(amount) as paid_amount
FROM payment_lines
JOIN payments ON payments.id = payment_lines.payment_id
WHERE payments.reference_type = 'bill'
GROUP BY reference_id
) p ON b.id = p.reference_id
WHERE (b.total_amount - COALESCE(p.paid_amount, 0)) > 0.01
AND b.bill_date <= :as_of_date
ORDER BY s.name, b.bill_date;
SQL;
$stmt = $pdo->prepare($sql);
$stmt->execute([':as_of_date' => $as_of_date]);
$details = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Bucket the outstanding balances
$buckets = [
'current' => 0,
'1-30' => 0,
'31-60' => 0,
'61-90' => 0,
'91+' => 0
];
$total_outstanding = 0;
foreach ($details as $detail) {
$total_outstanding += $detail['outstanding_balance'];
if ($detail['days_overdue'] <= 0) {
$buckets['current'] += $detail['outstanding_balance'];
} elseif ($detail['days_overdue'] <= 30) {
$buckets['1-30'] += $detail['outstanding_balance'];
} elseif ($detail['days_overdue'] <= 60) {
$buckets['31-60'] += $detail['outstanding_balance'];
} elseif ($detail['days_overdue'] <= 90) {
$buckets['61-90'] += $detail['outstanding_balance'];
} else {
$buckets['91+'] += $detail['outstanding_balance'];
}
}
echo json_encode([
'success' => true,
'report_name' => 'Accounts Payable Aging',
'generated_at' => date('c'),
'filters' => ['as_of_date' => $as_of_date],
'summary' => [
'total_outstanding' => $total_outstanding,
'buckets' => $buckets
],
'details' => $details
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Database error: ' . $e->getMessage()
]);
}

84
api/reports/ar_aging.php Normal file
View File

@ -0,0 +1,84 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '../../../db/config.php';
//
// ## Accounts Receivable Aging Report
//
// Purpose: Show outstanding customer balances grouped by age.
//
$pdo = db();
$as_of_date = $_GET['as_of_date'] ?? date('Y-m-d');
try {
$sql = <<<SQL
SELECT
s.subledger_id,
s.name as student_name,
i.id as invoice_id,
i.invoice_date,
i.due_date,
DATEDIFF(:as_of_date, i.due_date) as days_overdue,
(i.total_amount - COALESCE(p.paid_amount, 0)) as outstanding_balance
FROM invoices i
JOIN subledgers s ON i.student_id = s.subledger_id
LEFT JOIN (
SELECT invoice_id, SUM(amount) as paid_amount
FROM payment_lines
GROUP BY invoice_id
) p ON i.id = p.invoice_id
WHERE (i.total_amount - COALESCE(p.paid_amount, 0)) > 0.01
AND i.invoice_date <= :as_of_date
ORDER BY s.name, i.invoice_date;
SQL;
$stmt = $pdo->prepare($sql);
$stmt->execute([':as_of_date' => $as_of_date]);
$details = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Bucket the outstanding balances
$buckets = [
'current' => 0,
'1-30' => 0,
'31-60' => 0,
'61-90' => 0,
'91+' => 0
];
$total_outstanding = 0;
foreach ($details as $detail) {
$total_outstanding += $detail['outstanding_balance'];
if ($detail['days_overdue'] <= 0) {
$buckets['current'] += $detail['outstanding_balance'];
} elseif ($detail['days_overdue'] <= 30) {
$buckets['1-30'] += $detail['outstanding_balance'];
} elseif ($detail['days_overdue'] <= 60) {
$buckets['31-60'] += $detail['outstanding_balance'];
} elseif ($detail['days_overdue'] <= 90) {
$buckets['61-90'] += $detail['outstanding_balance'];
} else {
$buckets['91+'] += $detail['outstanding_balance'];
}
}
echo json_encode([
'success' => true,
'report_name' => 'Accounts Receivable Aging',
'generated_at' => date('c'),
'filters' => ['as_of_date' => $as_of_date],
'summary' => [
'total_outstanding' => $total_outstanding,
'buckets' => $buckets
],
'details' => $details
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Database error: ' . $e->getMessage()
]);
}

View File

@ -0,0 +1,126 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '../../../db/config.php';
//
// ## Balance Sheet Report
//
// Purpose: Show financial position (Assets = Liabilities + Equity) at a specific date.
//
// ---
//
// **Rule (Absolute):** Reads ONLY from `accounts` and `journal_lines` for posted entries.
// It includes retained earnings (net income from prior periods).
//
$pdo = db();
$as_of_date = $_GET['as_of_date'] ?? date('Y-m-d');
try {
// --- 1. Calculate Net Income (Retained Earnings) ---
// This is the sum of all revenue and expense accounts from the beginning of time up to the report date.
$retained_earnings_sql = <<<SQL
SELECT
SUM(CASE
WHEN a.account_type = 'revenue' THEN (jl.credit_amount - jl.debit_amount)
WHEN a.account_type = 'expense' THEN (jl.debit_amount - jl.credit_amount)
ELSE 0
END) as net_income
FROM journal_lines jl
JOIN accounts a ON jl.account_code = a.account_code
JOIN journal_entries je ON jl.journal_entry_id = je.id
WHERE je.status = 'posted' AND a.account_type IN ('revenue', 'expense') AND je.entry_date <= :as_of_date;
SQL;
$stmt_re = $pdo->prepare($retained_earnings_sql);
$stmt_re->execute([':as_of_date' => $as_of_date]);
$retained_earnings = $stmt_re->fetchColumn() ?: 0;
// --- 2. Get Balances for Asset, Liability, and Equity Accounts ---
$main_sql = <<<SQL
SELECT
a.account_code,
a.account_name,
a.account_type,
a.normal_balance,
SUM(CASE
WHEN a.normal_balance = 'debit' THEN jl.debit_amount - jl.credit_amount
ELSE jl.credit_amount - jl.debit_amount
END) as balance
FROM accounts a
JOIN journal_lines jl ON a.account_code = jl.account_code
JOIN journal_entries je ON jl.journal_entry_id = je.id
WHERE
je.status = 'posted'
AND a.account_type IN ('asset', 'liability', 'equity')
AND je.entry_date <= :as_of_date
GROUP BY a.account_code, a.account_name, a.account_type, a.normal_balance
HAVING balance != 0
ORDER BY a.account_type, a.account_code;
SQL;
$stmt_main = $pdo->prepare($main_sql);
$stmt_main->execute([':as_of_date' => $as_of_date]);
$report_lines = $stmt_main->fetchAll(PDO::FETCH_ASSOC);
// --- 3. Structure the report and calculate totals ---
$assets_total = 0;
$liabilities_total = 0;
$equity_total = 0;
$assets = [];
$liabilities = [];
$equity = [];
foreach ($report_lines as $line) {
switch ($line['account_type']) {
case 'asset':
$assets[] = $line;
$assets_total += $line['balance'];
break;
case 'liability':
$liabilities[] = $line;
$liabilities_total += $line['balance'];
break;
case 'equity':
$equity[] = $line;
$equity_total += $line['balance'];
break;
}
}
// Add retained earnings to equity section
$equity_total += $retained_earnings;
$total_liabilities_and_equity = $liabilities_total + $equity_total;
echo json_encode([
'success' => true,
'report_name' => 'Balance Sheet',
'generated_at' => date('c'),
'filters' => ['as_of_date' => $as_of_date],
'data' => [
'assets' => $assets,
'liabilities' => $liabilities,
'equity' => array_merge($equity, [[
'account_code' => '3999',
'account_name' => 'Retained Earnings',
'account_type' => 'equity',
'balance' => $retained_earnings
]])
],
'summary' => [
'total_assets' => $assets_total,
'total_liabilities' => $liabilities_total,
'total_equity' => $equity_total,
'total_liabilities_and_equity' => $total_liabilities_and_equity,
'is_balanced' => abs($assets_total - $total_liabilities_and_equity) < 0.0001
]
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Database error: ' . $e->getMessage()
]);
}

View File

@ -0,0 +1,100 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '../../../db/config.php';
//
// ## Income Statement Report (Profit & Loss)
//
// Purpose: Show financial performance (Revenue - Expenses) over a period.
//
// ---
//
// **Rule (Absolute):** Reads ONLY from `accounts` and `journal_lines` for posted entries.
// Filters for accounts of type 'revenue' and 'expense'.
//
$pdo = db();
// Optional date range filtering
$start_date = $_GET['start_date'] ?? null;
$end_date = $_GET['end_date'] ?? date('Y-m-d');
try {
$sql = <<<SQL
SELECT
a.account_code,
a.account_name,
a.account_type,
a.normal_balance,
SUM(CASE
WHEN a.normal_balance = 'debit' THEN jl.debit_amount - jl.credit_amount
ELSE jl.credit_amount - jl.debit_amount
END) as balance
FROM
accounts a
JOIN
journal_lines jl ON a.account_code = jl.account_code
JOIN
journal_entries je ON jl.journal_entry_id = je.id
WHERE
je.status = 'posted'
AND a.account_type IN ('revenue', 'expense')
SQL;
$params = [];
if ($start_date) {
$sql .= " AND je.entry_date >= :start_date";
$params[':start_date'] = $start_date;
}
if ($end_date) {
$sql .= " AND je.entry_date <= :end_date";
$params[':end_date'] = $end_date;
}
$sql .= " GROUP BY a.account_code, a.account_name, a.account_type, a.normal_balance ORDER BY a.account_type, a.account_code";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$report_lines = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Structure the report and calculate totals
$revenue_total = 0;
$expense_total = 0;
$revenues = [];
$expenses = [];
foreach ($report_lines as $line) {
if ($line['account_type'] == 'revenue') {
$revenues[] = $line;
$revenue_total += $line['balance'];
} else {
$expenses[] = $line;
$expense_total += $line['balance'];
}
}
$net_income = $revenue_total - $expense_total;
echo json_encode([
'success' => true,
'report_name' => 'Income Statement',
'generated_at' => date('c'),
'filters' => ['start_date' => $start_date, 'end_date' => $end_date],
'data' => [
'revenues' => $revenues,
'expenses' => $expenses
],
'summary' => [
'total_revenue' => $revenue_total,
'total_expense' => $expense_total,
'net_income' => $net_income
]
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Database error: ' . $e->getMessage()
]);
}

View File

@ -0,0 +1,75 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '../../../db/config.php';
//
// ## Trial Balance Report
//
// Purpose: Prove that the double-entry system balances.
//
// ---
//
// **Rule (Absolute):** This report reads ONLY from the General Ledger (`journal_entries`, `journal_lines`, `accounts`).
// It NEVER reads from operational tables like `invoices` or `payments`.
//
$pdo = db();
try {
// The query calculates the total debits and credits for each account from posted journal entries.
// It also computes the final balance based on the account's normal balance.
$sql = <<<SQL
SELECT
a.account_code,
a.account_name,
a.account_type,
a.normal_balance,
COALESCE(SUM(jl.debit_amount), 0) as total_debits,
COALESCE(SUM(jl.credit_amount), 0) as total_credits,
CASE
WHEN a.normal_balance = 'debit' THEN COALESCE(SUM(jl.debit_amount), 0) - COALESCE(SUM(jl.credit_amount), 0)
ELSE COALESCE(SUM(jl.credit_amount), 0) - COALESCE(SUM(jl.debit_amount), 0)
END as balance
FROM
accounts a
LEFT JOIN
journal_lines jl ON a.account_code = jl.account_code
LEFT JOIN
journal_entries je ON jl.journal_entry_id = je.id AND je.status = 'posted'
GROUP BY
a.account_code, a.account_name, a.account_type, a.normal_balance
ORDER BY
a.account_code;
SQL;
$stmt = $pdo->prepare($sql);
$stmt->execute();
$report_lines = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Calculate totals for debits, credits to verify balance
$total_debits = 0;
$total_credits = 0;
foreach ($report_lines as $line) {
$total_debits += $line['total_debits'];
$total_credits += $line['total_credits'];
}
echo json_encode([
'success' => true,
'report_name' => 'Trial Balance',
'generated_at' => date('c'),
'data' => $report_lines,
'summary' => [
'total_debits' => $total_debits,
'total_credits' => $total_credits,
'is_balanced' => abs($total_debits - $total_credits) < 0.0001 // Use a small tolerance for floating point comparison
]
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Database error: ' . $e->getMessage()
]);
}

73
api/students_post.php Normal file
View File

@ -0,0 +1,73 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/uuid.php';
require_once __DIR__ . '/../db/config.php';
$db = db();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit();
}
$data = json_decode(file_get_contents('php://input'), true);
// Basic validation
$required_fields = ['student_number', 'first_name', 'last_name', 'date_of_birth', 'gender', 'admission_date'];
foreach ($required_fields as $field) {
if (empty($data[$field])) {
http_response_code(400);
echo json_encode(['error' => "Missing required field: {$field}"]);
exit();
}
}
$db->beginTransaction();
try {
// 1. Create Subledger Entry
$subledger_id = UUID::v4();
$student_id = UUID::v4(); // Generate student ID in advance
$stmt = $db->prepare(
'INSERT INTO subledgers (id, subledger_type, reference_id) VALUES (?, ?, ?)'
);
$stmt->execute([$subledger_id, 'Student', $student_id]);
// 2. Create Student Entry
$stmt = $db->prepare(
'INSERT INTO students (id, student_number, first_name, middle_name, last_name, date_of_birth, gender, admission_date, subledger_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$student_id,
$data['student_number'],
$data['first_name'],
$data['middle_name'] ?? null,
$data['last_name'],
$data['date_of_birth'],
$data['gender'],
$data['admission_date'],
$subledger_id
]);
$db->commit();
// Fetch the created student to return it
$stmt = $db->prepare('SELECT * FROM students WHERE id = ?');
$stmt->execute([$student_id]);
$student = $stmt->fetch(PDO::FETCH_ASSOC);
http_response_code(201);
echo json_encode($student);
} catch (Exception $e) {
$db->rollBack();
http_response_code(500);
// Check for unique constraint violation for student_number
if ($e->errorInfo[1] == 1062) { // 1062 is the MySQL error code for duplicate entry
echo json_encode(['error' => 'A student with this student number already exists.', 'details' => $e->getMessage()]);
} else {
echo json_encode(['error' => 'Database error occurred.', 'details' => $e->getMessage()]);
}
}

60
api/vendors_post.php Normal file
View File

@ -0,0 +1,60 @@
<?php
require_once __DIR__ . '/../includes/uuid.php';
require_once __DIR__ . '/../db/config.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit();
}
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['vendor_name'])) {
http_response_code(400);
echo json_encode(['error' => 'Vendor name is required']);
exit();
}
$pdo = db();
try {
$pdo->beginTransaction();
// 1. Create subledger entry for the vendor
$subledger_id = uuid_v4();
$stmt = $pdo->prepare("INSERT INTO subledgers (subledger_id, subledger_type, reference_id) VALUES (?, 'Vendor', ?)");
$vendor_id = uuid_v4(); // Generate vendor ID ahead of time to link it
$stmt->execute([$subledger_id, $vendor_id]);
// 2. Create the vendor
$stmt = $pdo->prepare(
"INSERT INTO vendors (vendor_id, subledger_id, vendor_name, contact_person, email, phone, address) VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$vendor_id,
$subledger_id,
$data['vendor_name'],
$data['contact_person'] ?? null,
$data['email'] ?? null,
$data['phone'] ?? null,
$data['address'] ?? null
]);
$pdo->commit();
// Fetch and return the created vendor data
$stmt = $pdo->prepare("SELECT * FROM vendors WHERE vendor_id = ?");
$stmt->execute([$vendor_id]);
$new_vendor = $stmt->fetch(PDO::FETCH_ASSOC);
http_response_code(201);
echo json_encode($new_vendor);
} catch (PDOException $e) {
$pdo->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Database error: ' . $e->getMessage()]);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,318 @@
:root {
--primary-color: #4F46E5;
--sidebar-bg: #111827;
--sidebar-text: #9CA3AF;
--sidebar-active-bg: #374151;
--sidebar-active-text: #FFFFFF;
--main-bg: #F9FAFB;
--text-primary: #1F2937;
--text-secondary: #6B7280;
--card-bg: #FFFFFF;
--border-color: #E5E7EB;
--green-light: #ECFDF5;
--green-dark: #065F46;
--red-light: #FEF2F2;
--red-dark: #991B1B;
--blue-light: #EFF6FF;
--blue-dark: #1E40AF;
--yellow-light: #FFFBEB;
--yellow-dark: #92400E;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--main-bg);
color: var(--text-primary);
margin: 0;
display: flex;
}
#root {
display: flex;
width: 100%;
min-height: 100vh;
}
.dashboard {
display: flex;
width: 100%;
}
.sidebar {
width: 260px;
background-color: var(--sidebar-bg);
color: var(--sidebar-text);
padding: 24px;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 32px;
}
.sidebar-logo {
background-color: var(--primary-color);
border-radius: 8px;
padding: 8px;
color: white;
}
.sidebar-school-name {
display: flex;
flex-direction: column;
}
.sidebar-school-name span:first-child {
font-weight: 600;
color: var(--sidebar-active-text);
}
.sidebar-school-name span:last-child {
font-size: 0.875rem;
}
.nav-section {
margin-bottom: 24px;
}
.nav-section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.nav-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 6px;
text-decoration: none;
color: var(--sidebar-text);
font-weight: 500;
margin-bottom: 4px;
gap: 12px;
}
.nav-item:hover {
background-color: #1F2937;
color: var(--sidebar-active-text);
}
.nav-item.active {
background-color: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
}
.main-content {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.header {
padding: 24px 32px;
background-color: var(--card-bg);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
width: 400px;
}
.search-bar input {
width: 100%;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px 14px;
font-size: 1rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-profile {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--primary-color);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.user-info span {
display: block;
}
.user-info span:first-child {
font-weight: 600;
}
.user-info span:last-child {
font-size: 0.875rem;
color: var(--text-secondary);
}
.page-content {
padding: 32px;
flex-grow: 1;
}
.welcome-header {
margin-bottom: 32px;
}
.welcome-header h1 {
font-size: 1.875rem;
font-weight: 700;
margin: 0 0 4px 0;
}
.welcome-header p {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
background-color: var(--card-bg);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05);
}
.stat-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.stat-card-title {
font-weight: 500;
color: var(--text-secondary);
}
.stat-card-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.stat-card-icon.blue { background-color: var(--blue-light); color: var(--blue-dark); }
.stat-card-icon.green { background-color: var(--green-light); color: var(--green-dark); }
.stat-card-icon.red { background-color: var(--red-light); color: var(--red-dark); }
.stat-card-value {
font-size: 2.25rem;
font-weight: 700;
margin-bottom: 8px;
}
.stat-card-footer {
font-size: 0.875rem;
}
.stat-card-footer .increase { color: var(--green-dark); }
.stat-card-footer .decrease { color: var(--red-dark); }
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.chart-card {
background-color: var(--card-bg);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05);
}
.chart-card-header h3 {
margin: 0 0 4px 0;
font-size: 1.125rem;
font-weight: 600;
}
.chart-card-header p {
margin: 0;
color: var(--text-secondary);
font-size: 0.875rem;
}
.chart-placeholder {
height: 250px;
display: flex;
align-items: center;
justify-content: center;
}
.table-card {
background-color: var(--card-bg);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.05);
}
.table-card h3 {
margin: 0 0 16px 0;
font-size: 1.125rem;
font-weight: 600;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
thead th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.875rem;
}

25
dashboard/index.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mother Providencia International School</title>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/feather-icons"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="src/App.js"></script>
<script type="text/babel" src="src/index.js"></script>
<script>
feather.replace();
</script>
</body>
</html>

267
dashboard/src/App.js Normal file
View File

@ -0,0 +1,267 @@
const App = () => {
React.useEffect(() => {
feather.replace();
// Mock Chart for Income vs Expenses
const incomeExpenseCtx = document.getElementById('incomeExpenseChart');
if (incomeExpenseCtx) {
new Chart(incomeExpenseCtx, {
type: 'line',
data: {
labels: ['Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb'],
datasets: [{
label: 'Income',
data: [120000, 130000, 140000, 135000, 150000, 160000],
borderColor: '#10B981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.4
}, {
label: 'Expenses',
data: [90000, 95000, 105000, 100000, 110000, 115000],
borderColor: '#EF4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '$' + value / 1000 + 'k';
}
}
}
}
}
});
}
// Mock Chart for Fee Collection
const feeCollectionCtx = document.getElementById('feeCollectionChart');
if (feeCollectionCtx) {
new Chart(feeCollectionCtx, {
type: 'doughnut',
data: {
labels: ['Collected', 'Pending', 'Overdue'],
datasets: [{
data: [85, 10, 5],
backgroundColor: ['#10B981', '#F59E0B', '#EF4444'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
plugins: {
legend: {
display: false
}
}
}
});
}
}, []);
const navItems = {
Overview: [
{ name: 'Dashboard', icon: 'grid', active: true },
],
Academic: [
{ name: 'Students', icon: 'users' },
{ name: 'Teachers', icon: 'users' },
{ name: 'Classes', icon: 'book' },
{ name: 'Timetable', icon: 'calendar' },
{ name: 'Attendance', icon: 'check-square' },
{ name: 'Examinations', icon: 'file-text' },
],
Finance: [
{ name: 'Chart of Accounts', icon: 'file' },
{ name: 'Journal Entries', icon: 'book-open' },
{ name: 'Invoices (AR)', icon: 'dollar-sign' },
{ name: 'Payments', icon: 'trending-up' },
{ name: 'Bills (AP)', icon: 'file-minus' },
{ name: 'Vendors', icon: 'briefcase' },
{ name: 'Inventory', icon: 'archive' },
]
};
const stats = [
{ title: 'Total Students', value: '487', change: '+12 this month', changeType: 'increase', icon: 'users', iconBg: 'blue' },
{ title: 'Total Teachers', value: '32', change: '2 on leave', icon: 'users', iconBg: 'blue' },
{ title: 'Total Revenue', value: 'GH₵1,085,500.00', change: '+8.2% from last year', changeType: 'increase', icon: 'dollar-sign', iconBg: 'green' },
{ title: 'Pending Fees', value: 'GH₵85,000.00', change: '12 invoices overdue', changeType: 'decrease', icon: 'alert-triangle', iconBg: 'red' },
];
const Sidebar = () => (
<div className="sidebar">
<div className="sidebar-header">
<div className="sidebar-logo">
<i data-feather="award"></i>
</div>
<div className="sidebar-school-name">
<span>Mother Providencia</span>
<span>International School</span>
</div>
</div>
<nav>
{Object.entries(navItems).map(([section, items]) => (
<div className="nav-section" key={section}>
<div className="nav-section-title">{section}</div>
{items.map(item => (
<a href="#" className={`nav-item ${item.active ? 'active' : ''}`} key={item.name}>
<i data-feather={item.icon}></i>
<span>{item.name}</span>
</a>
))}
</div>
))}
</nav>
<div style={{ marginTop: 'auto' }}>
<a href="#" className="nav-item">
<i data-feather="settings"></i>
<span>Settings</span>
</a>
<a href="#" className="nav-item">
<i data-feather="log-out"></i>
<span>Logout</span>
</a>
</div>
</div>
);
const Header = () => (
<header className="header">
<div className="search-bar">
<i data-feather="search" style={{ color: 'var(--text-secondary)' }}></i>
<input type="text" placeholder="Search students, invoices, accounts..." />
</div>
<div className="header-actions">
<button className="admin-btn" style={{background: '#f8d7da', border: 'none', padding: '5px 10px', borderRadius: '5px'}}>admin</button>
<span>Switch Role</span>
<i data-feather="bell"></i>
<div className="user-profile">
<div className="user-avatar">JA</div>
<div className="user-info">
<span>John Administrator</span>
<span>Admin</span>
</div>
</div>
</div>
</header>
);
const StatCard = ({ stat }) => (
<div className="stat-card">
<div className="stat-card-header">
<span className="stat-card-title">{stat.title}</span>
<div className={`stat-card-icon ${stat.iconBg}`}>
<i data-feather={stat.icon}></i>
</div>
</div>
<div className="stat-card-value">{stat.value}</div>
<div className={`stat-card-footer ${stat.changeType}`}>{stat.change}</div>
</div>
);
return (
<div className="dashboard">
<Sidebar />
<main className="main-content">
<Header />
<div className="page-content">
<div className="welcome-header">
<h1>Welcome back, John!</h1>
<p>Here's your administrative overview of the school.</p>
</div>
<div className="stats-grid">
{stats.map(stat => <StatCard stat={stat} key={stat.title} />)}
</div>
<div className="charts-grid">
<div className="chart-card">
<div className="chart-card-header">
<h3>Cash Position</h3>
<p>Current balances</p>
</div>
<div style={{height: '250px', display:'flex', flexDirection: 'column', justifyContent:'center', gap: '20px'}}>
<div style={{display: 'flex', alignItems: 'center', gap: '15px'}}>
<div style={{padding: '10px', background: 'var(--blue-light)', borderRadius: '8px'}}><i data-feather="briefcase"></i></div>
<div>
<div>Bank Account</div>
<div style={{fontSize: '1.5rem', fontWeight: '600'}}>GH650,000.00</div>
<div style={{fontSize: '0.8rem', color: 'var(--text-secondary)'}}>Operating + Savings</div>
</div>
</div>
<div style={{display: 'flex', alignItems: 'center', gap: '15px'}}>
<div style={{padding: '10px', background: 'var(--green-light)', borderRadius: '8px'}}><i data-feather="dollar-sign"></i></div>
<div>
<div>Petty Cash</div>
<div style={{fontSize: '1.5rem', fontWeight: '600'}}>GH2,500.00</div>
<div style={{fontSize: '0.8rem', color: 'var(--text-secondary)'}}>On hand</div>
</div>
</div>
<div style={{color: 'var(--red-dark)', fontWeight:'500', display:'flex', alignItems:'center', gap: '5px'}}><i data-feather="trending-down"></i>Upcoming payables GH31,000.00</div>
</div>
</div>
<div className="chart-card">
<div className="chart-card-header">
<h3>Fee Collection</h3>
<p>Current academic year</p>
</div>
<div className="chart-placeholder">
<canvas id="feeCollectionChart"></canvas>
</div>
<div style={{display: 'flex', justifyContent: 'center', gap: '15px', marginTop: '15px'}}>
<span style={{display:'flex', alignItems: 'center', gap: '5px'}}><div style={{width:'10px', height: '10px', borderRadius: '50%', background: '#10B981'}}></div>Collected</span>
<span style={{display:'flex', alignItems: 'center', gap: '5px'}}><div style={{width:'10px', height: '10px', borderRadius: '50%', background: '#F59E0B'}}></div>Pending</span>
<span style={{display:'flex', alignItems: 'center', gap: '5px'}}><div style={{width:'10px', height: '10px', borderRadius: '50%', background: '#EF4444'}}></div>Overdue</span>
</div>
</div>
<div className="chart-card">
<div className="chart-card-header">
<h3>Income vs Expenses</h3>
<p>Last 6 months trend</p>
</div>
<div className="chart-placeholder">
<canvas id="incomeExpenseChart"></canvas>
</div>
</div>
</div>
<div className="charts-grid" style={{gridTemplateColumns: '1fr 1fr'}}>
<div className="table-card">
<h3>Recent Invoices</h3>
<table>
<thead>
<tr><th>Student</th><th>Amount</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td>Kwabena Adu</td><td>GH1,200.00</td><td>Paid</td></tr>
<tr><td>Afiya Owusu</td><td>GH1,200.00</td><td>Pending</td></tr>
</tbody>
</table>
</div>
<div className="table-card">
<h3>Recent Payments</h3>
<table>
<thead>
<tr><th>Student</th><th>Amount</th><th>Date</th></tr>
</thead>
<tbody>
<tr><td>Kwabena Adu</td><td>GH1,200.00</td><td>2024-02-15</td></tr>
<tr><td>Esi Mensah</td><td>GH1,000.00</td><td>2024-02-14</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</main>
</div>
);
};

1
dashboard/src/index.js Normal file
View File

@ -0,0 +1 @@
ReactDOM.render(<App />, document.getElementById('root'));

64
db/migrate.php Normal file
View File

@ -0,0 +1,64 @@
<?php
session_start();
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/../includes/uuid.php';
$_SESSION['migration_output'] = [];
try {
$pdo = db();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE IF NOT EXISTS migrations (migration VARCHAR(255) PRIMARY KEY);');
$executed_migrations_stmt = $pdo->query('SELECT migration FROM migrations');
$executed_migrations = $executed_migrations_stmt->fetchAll(PDO::FETCH_COLUMN);
$migration_files = glob(__DIR__ . '/migrations/*.{sql,php}', GLOB_BRACE);
sort($migration_files);
$ran_a_migration = false;
foreach ($migration_files as $migration_file) {
$migration_name = basename($migration_file);
if (!in_array($migration_name, $executed_migrations)) {
$ran_a_migration = true;
$_SESSION['migration_output'][] = "Running migration: {$migration_name}...";
$extension = pathinfo($migration_file, PATHINFO_EXTENSION);
if ($extension === 'sql') {
$sql = file_get_contents($migration_file);
$statements = array_filter(array_map('trim', explode(';', $sql)));
foreach ($statements as $statement) {
if (!empty($statement)) {
$pdo->exec($statement);
}
}
} elseif ($extension === 'php') {
require $migration_file;
}
$stmt = $pdo->prepare('INSERT INTO migrations (migration) VALUES (?)');
$stmt->execute([$migration_name]);
$_SESSION['migration_output'][] = "Successfully ran and recorded migration: {$migration_name}.";
}
}
if (!$ran_a_migration) {
$_SESSION['migration_output'][] = "No new migrations to run. Database is up to date.";
}
$_SESSION['migration_status'] = 'success';
} catch (Exception $e) {
$_SESSION['migration_status'] = 'error';
$_SESSION['migration_output'][] = 'An error occurred: ' . $e->getMessage();
error_log('Migration Error: ' . $e->getMessage());
}
header('Location: /index.php?migration_status=' . $_SESSION['migration_status']);
exit;

View File

@ -0,0 +1,107 @@
-- MySQL-compatible Schema for Ghana School ERP Accounting Module
-- Translated from user-provided PostgreSQL schema.
CREATE TABLE IF NOT EXISTS `accounts` (
`id` CHAR(36) PRIMARY KEY,
`account_code` VARCHAR(50) UNIQUE NOT NULL,
`account_name` VARCHAR(255) NOT NULL,
`account_type` VARCHAR(20) NOT NULL CHECK (`account_type` IN ('Asset', 'Liability', 'Equity', 'Revenue', 'ContraRevenue', 'Expense')),
`normal_balance` VARCHAR(6) NOT NULL CHECK (`normal_balance` IN ('Debit', 'Credit')),
`parent_account_id` CHAR(36),
`is_active` BOOLEAN NOT NULL DEFAULT TRUE,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`parent_account_id`) REFERENCES `accounts`(`id`) ON DELETE RESTRICT
);
CREATE INDEX `idx_accounts_code` ON `accounts`(`account_code`);
CREATE INDEX `idx_accounts_parent` ON `accounts`(`parent_account_id`);
CREATE INDEX `idx_accounts_type` ON `accounts`(`account_type`);
CREATE TABLE IF NOT EXISTS `accounting_periods` (
`id` CHAR(36) PRIMARY KEY,
`name` VARCHAR(100) NOT NULL,
`start_date` DATE NOT NULL,
`end_date` DATE NOT NULL,
`is_closed` BOOLEAN NOT NULL DEFAULT FALSE,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `chk_period_dates` CHECK (`end_date` > `start_date`)
);
CREATE INDEX `idx_periods_dates` ON `accounting_periods`(`start_date`, `end_date`);
CREATE TABLE IF NOT EXISTS `journal_entries` (
`id` CHAR(36) PRIMARY KEY,
`entry_number` VARCHAR(50) UNIQUE NOT NULL,
`entry_date` DATE NOT NULL,
`description` TEXT,
`period_id` CHAR(36) NOT NULL,
`source_module` VARCHAR(20) NOT NULL CHECK (`source_module` IN ('Manual', 'Fees', 'Payments', 'Payroll', 'Expenses')),
`source_reference_id` CHAR(36),
`status` VARCHAR(10) NOT NULL DEFAULT 'Draft' CHECK (`status` IN ('Draft', 'Posted', 'Reversed')),
`reversed_by` CHAR(36),
`reverses_journal_id` CHAR(36),
`created_by` CHAR(36) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`posted_at` TIMESTAMP NULL,
FOREIGN KEY (`period_id`) REFERENCES `accounting_periods`(`id`) ON DELETE RESTRICT,
FOREIGN KEY (`reversed_by`) REFERENCES `journal_entries`(`id`),
FOREIGN KEY (`reverses_journal_id`) REFERENCES `journal_entries`(`id`)
);
CREATE INDEX `idx_journal_period` ON `journal_entries`(`period_id`);
CREATE INDEX `idx_journal_date` ON `journal_entries`(`entry_date`);
CREATE INDEX `idx_journal_status` ON `journal_entries`(`status`);
CREATE INDEX `idx_journal_source` ON `journal_entries`(`source_module`, `source_reference_id`);
CREATE TABLE IF NOT EXISTS `journal_lines` (
`id` CHAR(36) PRIMARY KEY,
`journal_entry_id` CHAR(36) NOT NULL,
`account_id` CHAR(36) NOT NULL,
`debit_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
`credit_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
`line_description` TEXT,
CONSTRAINT `chk_debit_or_credit` CHECK ((`debit_amount` > 0 AND `credit_amount` = 0) OR (`credit_amount` > 0 AND `debit_amount` = 0)),
CHECK (`debit_amount` >= 0),
CHECK (`credit_amount` >= 0),
FOREIGN KEY (`journal_entry_id`) REFERENCES `journal_entries`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE RESTRICT
);
CREATE INDEX `idx_lines_journal` ON `journal_lines`(`journal_entry_id`);
CREATE INDEX `idx_lines_account` ON `journal_lines`(`account_id`);
CREATE TABLE IF NOT EXISTS `subledgers` (
`id` CHAR(36) PRIMARY KEY,
`subledger_type` VARCHAR(20) NOT NULL CHECK (`subledger_type` IN ('Student', 'Vendor', 'Staff')),
`reference_id` CHAR(36) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `unq_subledger` UNIQUE (`subledger_type`, `reference_id`)
);
CREATE INDEX `idx_subledger_ref` ON `subledgers`(`subledger_type`, `reference_id`);
CREATE TABLE IF NOT EXISTS `journal_line_subledgers` (
`id` CHAR(36) PRIMARY KEY,
`journal_line_id` CHAR(36) NOT NULL,
`subledger_id` CHAR(36) NOT NULL,
CONSTRAINT `unq_line_subledger` UNIQUE (`journal_line_id`, `subledger_id`),
FOREIGN KEY (`journal_line_id`) REFERENCES `journal_lines`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`subledger_id`) REFERENCES `subledgers`(`id`) ON DELETE RESTRICT
);
CREATE INDEX `idx_jls_line` ON `journal_line_subledgers`(`journal_line_id`);
CREATE INDEX `idx_jls_subledger` ON `journal_line_subledgers`(`subledger_id`);
CREATE TABLE IF NOT EXISTS `audit_logs` (
`id` CHAR(36) PRIMARY KEY,
`entity_type` VARCHAR(50) NOT NULL,
`entity_id` CHAR(36) NOT NULL,
`action` VARCHAR(20) NOT NULL CHECK (`action` IN ('CREATE', 'UPDATE', 'POST', 'REVERSE', 'DELETE')),
`performed_by` CHAR(36) NOT NULL,
`performed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`details` JSON
);
CREATE INDEX `idx_audit_entity` ON `audit_logs`(`entity_type`, `entity_id`);
CREATE INDEX `idx_audit_performed` ON `audit_logs`(`performed_at`);

View File

@ -0,0 +1,19 @@
-- idempotent script to add students table
CREATE TABLE IF NOT EXISTS students (
id CHAR(36) NOT NULL PRIMARY KEY,
student_number VARCHAR(50) NOT NULL UNIQUE,
first_name VARCHAR(100) NOT NULL,
middle_name VARCHAR(100),
last_name VARCHAR(100) NOT NULL,
date_of_birth DATE NOT NULL,
gender VARCHAR(10) NOT NULL CHECK (gender IN ('Male', 'Female')),
admission_date DATE NOT NULL,
student_status VARCHAR(20) NOT NULL DEFAULT 'Active',
subledger_id CHAR(36) UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT fk_subledger
FOREIGN KEY(subledger_id)
REFERENCES subledgers(id)
);

View File

@ -0,0 +1,53 @@
<?php
// Migration: Add Required Chart of Accounts
// This script ensures that the essential accounts for the revenue cycle exist.
if (!isset($pdo)) {
// This script should be run by migrate.php, which provides the $pdo variable.
echo "This script must be run from migrate.php\n";
return "Error: Not executed from migrate.php";
}
function account_exists($pdo, $account_code) {
$stmt = $pdo->prepare("SELECT 1 FROM accounts WHERE account_code = ?");
$stmt->execute([$account_code]);
return $stmt->fetchColumn() !== false;
}
function insert_account($pdo, $id, $code, $name, $type, $parent_id = null, $is_control = false) {
if (account_exists($pdo, $code)) {
return "Skipped: Account '{$code}' already exists.";
}
$stmt = $pdo->prepare(
"INSERT INTO accounts (id, account_code, account_name, account_type, parent_account_id, is_control_account) " .
"VALUES (?, ?, ?, ?, ?, ?)"
);
$stmt->execute([$id, $code, $name, $type, $parent_id, $is_control ? 1 : 0]);
return "Created: Account '{$code}' - {$name}.";
}
$messages = [];
// 1. Asset Accounts
$messages[] = insert_account($pdo, uuid_v4(), '1000-CASH', 'Cash on Hand', 'Asset');
$messages[] = insert_account($pdo, uuid_v4(), '1010-BANK', 'Bank Account', 'Asset');
$messages[] = insert_account($pdo, uuid_v4(), '1015-MOMO', 'Mobile Money Account', 'Asset');
$messages[] = insert_account($pdo, uuid_v4(), '1200-AR-STUDENTS', 'Accounts Receivable - Students', 'Asset', null, true);
// 2. Revenue Accounts
$revenue_parent_id = uuid_v4();
$messages[] = insert_account($pdo, $revenue_parent_id, '4000', 'School Fees Revenue', 'Revenue');
$messages[] = insert_account($pdo, uuid_v4(), '4100', 'Tuition Fees - Nursery/KG', 'Revenue', $revenue_parent_id);
$messages[] = insert_account($pdo, uuid_v4(), '4200', 'Tuition Fees - Primary', 'Revenue', $revenue_parent_id);
$messages[] = insert_account($pdo, uuid_v4(), '4300', 'Tuition Fees - JHS', 'Revenue', $revenue_parent_id);
$messages[] = insert_account($pdo, uuid_v4(), '4400', 'Boarding Fees', 'Revenue', $revenue_parent_id);
$messages[] = insert_account($pdo, uuid_v4(), '4500', 'Transport Fees', 'Revenue', $revenue_parent_id);
$messages[] = insert_account($pdo, uuid_v4(), '4600', 'PTA & Other Fees', 'Revenue', $revenue_parent_id);
// 3. Contra-Revenue Accounts
// Using 'Revenue' type as it's a contra-revenue account.
$messages[] = insert_account($pdo, uuid_v4(), '4900', 'Scholarships & Discounts', 'Revenue');
return implode("\n", array_filter($messages));

View File

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS fee_structures (
id CHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
academic_year VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'Draft' CHECK (status IN ('Draft', 'Active', 'Archived')),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS fee_structure_lines (
id CHAR(36) NOT NULL PRIMARY KEY,
fee_structure_id CHAR(36) NOT NULL,
description VARCHAR(255) NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
revenue_account_id CHAR(36) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
FOREIGN KEY (fee_structure_id) REFERENCES fee_structures(id) ON DELETE CASCADE,
FOREIGN KEY (revenue_account_id) REFERENCES accounts(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS invoices (
id CHAR(36) NOT NULL PRIMARY KEY,
student_id CHAR(36) NOT NULL,
invoice_number VARCHAR(50) NOT NULL UNIQUE,
invoice_date DATE NOT NULL,
due_date DATE NOT NULL,
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'Draft' CHECK (status IN ('Draft', 'Posted', 'Paid', 'Void')),
journal_entry_id CHAR(36) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
FOREIGN KEY (student_id) REFERENCES students(id),
FOREIGN KEY (journal_entry_id) REFERENCES journal_entries(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS invoice_lines (
id CHAR(36) NOT NULL PRIMARY KEY,
invoice_id CHAR(36) NOT NULL,
description VARCHAR(255) NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
revenue_account_id CHAR(36) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
FOREIGN KEY (revenue_account_id) REFERENCES accounts(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS payments (
id CHAR(36) NOT NULL PRIMARY KEY,
student_id CHAR(36) NOT NULL,
payment_date DATE NOT NULL,
amount_received DECIMAL(10, 2) NOT NULL,
payment_method VARCHAR(50) NOT NULL CHECK (payment_method IN ('Cash', 'Bank Transfer', 'Mobile Money', 'Other')),
reference_number VARCHAR(255) NULL,
journal_entry_id CHAR(36) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
FOREIGN KEY (student_id) REFERENCES students(id),
FOREIGN KEY (journal_entry_id) REFERENCES journal_entries(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS payment_lines (
id CHAR(36) NOT NULL PRIMARY KEY,
payment_id CHAR(36) NOT NULL,
invoice_id CHAR(36) NOT NULL,
amount_applied DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE,
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1,39 @@
<?php
require_once __DIR__ . '/../../db/config.php';
try {
$pdo = db();
$accounts = [
// Expense Accounts
['5000', 'Operating Expenses', 'EXPENSE', null],
['5100', 'Salaries & Wages Expense', 'EXPENSE', '5000'],
['5110', 'Employer SSNIT Expense', 'EXPENSE', '5000'],
['5200', 'Utilities Expense', 'EXPENSE', '5000'],
['5300', 'Teaching Materials Expense', 'EXPENSE', '5000'],
['5400', 'Maintenance & Repairs Expense', 'EXPENSE', '5000'],
['5500', 'Transport Expense', 'EXPENSE', '5000'],
['5600', 'Feeding Expense', 'EXPENSE', '5000'],
['5700', 'Administrative Expenses', 'EXPENSE', '5000'],
['5800', 'Professional Fees Expense', 'EXPENSE', '5000'],
// Liability Accounts
['2100-AP', 'Accounts Payable', 'LIABILITY', null],
['2200-SALARIES', 'Salaries Payable', 'LIABILITY', null],
['2210-SSNIT', 'SSNIT Payable', 'LIABILITY', null],
['2220-TAX', 'Income Tax (PAYE) Payable', 'LIABILITY', null]
];
$stmt = $pdo->prepare("INSERT IGNORE INTO accounts (account_code, account_name, account_type, parent_account_code) VALUES (?, ?, ?, ?)");
foreach ($accounts as $account) {
$stmt->execute($account);
echo "Inserted account: {$account[0]} - {$account[1]}" . PHP_EOL;
}
echo "Migration 007 completed successfully." . PHP_EOL;
} catch (PDOException $e) {
die("Migration 007 failed: " . $e->getMessage());
}

View File

@ -0,0 +1,40 @@
-- Vendor is a subledger, just like a student
CREATE TABLE IF NOT EXISTS vendors (
vendor_id VARCHAR(36) PRIMARY KEY,
subledger_id VARCHAR(36) NOT NULL,
vendor_name VARCHAR(255) NOT NULL,
contact_person VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(50),
address TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (subledger_id) REFERENCES subledgers(subledger_id)
);
-- A bill is an operational document from a vendor
CREATE TABLE IF NOT EXISTS bills (
bill_id VARCHAR(36) PRIMARY KEY,
vendor_id VARCHAR(36) NOT NULL,
journal_entry_id VARCHAR(36),
bill_date DATE NOT NULL,
due_date DATE NOT NULL,
total_amount DECIMAL(18, 2) NOT NULL,
status ENUM('DRAFT', 'SUBMITTED', 'PAID', 'VOID') NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (vendor_id) REFERENCES vendors(vendor_id),
FOREIGN KEY (journal_entry_id) REFERENCES journal_entries(journal_entry_id)
);
-- Lines of a bill, detailing the expenses
CREATE TABLE IF NOT EXISTS bill_lines (
bill_line_id VARCHAR(36) PRIMARY KEY,
bill_id VARCHAR(36) NOT NULL,
expense_account_code VARCHAR(20) NOT NULL,
description TEXT,
amount DECIMAL(18, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bill_id) REFERENCES bills(bill_id),
FOREIGN KEY (expense_account_code) REFERENCES accounts(account_code)
);

View File

@ -0,0 +1,3 @@
ALTER TABLE payments ADD COLUMN reference_type ENUM('INVOICE', 'BILL') NOT NULL DEFAULT 'INVOICE';
ALTER TABLE payments ADD COLUMN vendor_id VARCHAR(36) DEFAULT NULL;
ALTER TABLE payments MODIFY COLUMN student_id VARCHAR(36) DEFAULT NULL;

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS accounting_periods (
id INT AUTO_INCREMENT PRIMARY KEY,
period_name VARCHAR(50) NOT NULL COMMENT 'e.g., January 2026',
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status ENUM('open', 'closed') NOT NULL DEFAULT 'open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY (start_date, end_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user VARCHAR(255) NOT NULL,
action VARCHAR(255) NOT NULL,
details JSON,
INDEX (timestamp),
INDEX (user),
INDEX (action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,11 @@
<?php
function log_audit_trail(PDO $pdo, string $action, array $details, string $user = 'system') {
try {
$stmt = $pdo->prepare("INSERT INTO audit_log (user, action, details) VALUES (?, ?, ?)");
$stmt->execute([$user, $action, json_encode($details)]);
} catch (PDOException $e) {
// For now, we will log the error to the system's error log.
// In a production environment, this should be handled more robustly.
error_log('Failed to log audit trail: ' . $e->getMessage());
}
}

View File

@ -0,0 +1,61 @@
<?php
require_once __DIR__ . '/uuid.php';
require_once __DIR__ . '/audit_helpers.php';
function post_journal_entry(PDO $pdo, array $payload): array
{
try {
if (empty($payload['entry_date']) || empty($payload['description']) || empty($payload['lines'])) {
return ['error' => 'Missing required fields for journal entry'];
}
// --- Accounting Period Control ---
$stmt_period_check = $pdo->prepare(
"SELECT COUNT(*) FROM accounting_periods WHERE status = 'closed' AND :entry_date BETWEEN start_date AND end_date"
);
$stmt_period_check->execute([':entry_date' => $payload['entry_date']]);
if ($stmt_period_check->fetchColumn() > 0) {
return ['error' => 'Cannot post transaction to a closed accounting period.'];
}
// --- End Control ---
$total_debits = 0;
$total_credits = 0;
foreach ($payload['lines'] as $line) {
$debit = isset($line['debit']) ? $line['debit'] : 0;
$credit = isset($line['credit']) ? $line['credit'] : 0;
$total_debits += $debit;
$total_credits += $credit;
}
if (round($total_debits, 2) !== round($total_credits, 2) || $total_debits === 0) {
return ['error' => 'Debits and credits must balance and not be zero.'];
}
$journal_entry_id = uuid_v4();
$stmt_je = $pdo->prepare("INSERT INTO journal_entries (id, entry_date, description, status) VALUES (?, ?, ?, 'posted')");
$stmt_je->execute([$journal_entry_id, $payload['entry_date'], $payload['description']]);
$stmt_lines = $pdo->prepare("INSERT INTO journal_lines (id, journal_entry_id, account_code, debit_amount, credit_amount) VALUES (?, ?, ?, ?, ?)");
$stmt_subledger = $pdo->prepare("INSERT INTO journal_line_subledgers (journal_line_id, subledger_id) VALUES (?, ?)");
foreach ($payload['lines'] as $line) {
$journal_line_id = uuid_v4();
$debit = $line['debit'] ?? 0;
$credit = $line['credit'] ?? 0;
$stmt_lines->execute([$journal_line_id, $journal_entry_id, $line['account_code'], $debit, $credit]);
if (!empty($line['subledger_id'])) {
$stmt_subledger->execute([$journal_line_id, $line['subledger_id']]);
}
}
// Log audit trail
log_audit_trail($pdo, 'post_journal_entry', ['journal_entry_id' => $journal_entry_id, 'description' => $payload['description']]);
return ['success' => true, 'journal_entry_id' => $journal_entry_id];
} catch (PDOException $e) {
return ['error' => 'Database error in post_journal_entry: ' . $e->getMessage()];
}
}

24
includes/uuid.php Normal file
View File

@ -0,0 +1,24 @@
<?php
// includes/uuid.php
function gen_uuid() {
return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
// 32 bits for "time_low"
mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ),
// 16 bits for "time_mid"
mt_rand( 0, 0xffff ),
// 16 bits for "time_hi_and_version",
// four most significant bits holds version number 4
mt_rand( 0, 0x0fff ) | 0x4000,
// 16 bits, 8 bits for "clk_seq_hi_res",
// 8 bits for "clk_seq_low",
// two most significant bits holds zero and one for variant DCE1.1
mt_rand( 0, 0x3fff ) | 0x8000,
// 48 bits for "node"
mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff )
);
}

158
index.php
View File

@ -1,150 +1,20 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
?>
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>School Accounting Dashboard</title>
<link rel="stylesheet" href="dashboard/assets/css/style.css">
<!-- React and Babel for JSX transpilation -->
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
<div id="root"></div>
<!-- React Application -->
<script type="text/babel" src="dashboard/src/App.js"></script>
<script type="text/babel" src="dashboard/src/index.js"></script>
</body>
</html>