recreate project
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Libraries\AttendanceAutoPublish as AutoPublishLib;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
class AttendanceAutoPublishCommand extends BaseCommand
|
||||
{
|
||||
protected $group = 'Attendance';
|
||||
protected $name = 'attendance:auto-publish';
|
||||
protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$db = db_connect();
|
||||
$zone = AutoPublishLib::tz();
|
||||
$now = new \DateTimeImmutable('now', $zone);
|
||||
$nowStr = $now->format('Y-m-d H:i:s');
|
||||
|
||||
$cutoffDate = AutoPublishLib::secondSundayBackwardDate($now);
|
||||
|
||||
$builder = $db->table('attendance_day');
|
||||
$builder->where('status', 'submitted')
|
||||
->groupStart()
|
||||
->where('auto_publish_at <=', $nowStr)
|
||||
->orGroupStart()
|
||||
->where('auto_publish_at IS NULL', null, false)
|
||||
->where('date <=', $cutoffDate)
|
||||
->groupEnd()
|
||||
->groupEnd();
|
||||
|
||||
$count = $builder->countAllResults(false); // keep the WHEREs
|
||||
|
||||
if ($count > 0) {
|
||||
$builder->update([
|
||||
'status' => 'published',
|
||||
'published_by' => 0, // system
|
||||
'published_at' => $nowStr,
|
||||
'updated_at' => $nowStr,
|
||||
]);
|
||||
}
|
||||
|
||||
CLI::write("Auto-publish checked at {$nowStr} (TZ: {$zone->getName()}). Published rows: {$count}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\NotificationService;
|
||||
|
||||
class CheckMissedPayments extends BaseCommand
|
||||
{
|
||||
protected $group = 'Payments';
|
||||
protected $name = 'payments:check-missed';
|
||||
protected $description = 'Checks for users who missed payments and sends reminders.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$userModel = new UserModel();
|
||||
|
||||
// Fetch users who missed payment (you need to implement this query)
|
||||
$missedUsers = $userModel->getUsersWithMissedPayments();
|
||||
|
||||
foreach ($missedUsers as $user) {
|
||||
NotificationService::toUser(
|
||||
$user['id'],
|
||||
'Payment Missed',
|
||||
'You have a missed payment. Please pay to avoid penalty.',
|
||||
['in_app', 'email', 'sms']
|
||||
);
|
||||
CLI::write("Reminder sent to {$user['email']}", 'yellow');
|
||||
}
|
||||
|
||||
CLI::write("Finished checking missed payments.", 'green');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\NotificationModel;
|
||||
|
||||
class CleanupExpiredNotifications extends BaseCommand
|
||||
{
|
||||
protected $group = 'Maintenance';
|
||||
protected $name = 'notifications:cleanup';
|
||||
protected $description = 'Deletes expired notifications from the database.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$model = new NotificationModel();
|
||||
|
||||
// Fetch expired notifications
|
||||
$expired = $model->where('expires_at IS NOT NULL')
|
||||
->where('expires_at < NOW()')
|
||||
->findAll();
|
||||
|
||||
if (empty($expired)) {
|
||||
CLI::write("ℹ No expired notifications found to soft delete.", 'yellow');
|
||||
return;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($expired as $note) {
|
||||
$model->delete($note['id']); // Soft delete
|
||||
$count++;
|
||||
}
|
||||
|
||||
CLI::write("✅ Soft-deleted {$count} expired notifications.", 'green');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\PasswordResetRequestModel;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class CleanupPasswordResets extends BaseCommand
|
||||
{
|
||||
protected $group = 'Maintenance';
|
||||
protected $name = 'cleanup:password-resets';
|
||||
protected $description = 'Delete password reset requests older than 30 days';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$model = new PasswordResetRequestModel();
|
||||
$threshold = Time::now()->subDays(30)->toDateTimeString();
|
||||
|
||||
$count = $model->where('requested_at <', $threshold)->delete();
|
||||
|
||||
CLI::write("Deleted $count old password reset request(s).", 'green');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
Add this cron job to run every 24hrs
|
||||
0 0 * * * /usr/bin/php /path/to/project/public/index.php cleanup:password-resets >> /path/to/project/writable/logs/cleanup.log 2>&1
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Models\ConfigurationModel;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
|
||||
class ConfigUpdate extends BaseCommand
|
||||
{
|
||||
protected $group = 'Maintenance';
|
||||
protected $name = 'config:update';
|
||||
protected $description = 'Run a configuration update task (weekly cron).';
|
||||
protected $arguments = [];
|
||||
protected $usage = 'php spark config:update [task] [--task task|-t task] [--dry] [--force] [--tz=<timezone>]';
|
||||
protected $options = [
|
||||
'task' => 'Task name (or pass as first positional arg)',
|
||||
't' => 'Short form of --task',
|
||||
'dry' => 'Dry run (no DB writes)',
|
||||
'force' => 'Ignore lock and run anyway',
|
||||
'tz' => 'Timezone (default: configured school timezone)',
|
||||
];
|
||||
|
||||
|
||||
/** @var ConfigurationModel */
|
||||
protected $configModel;
|
||||
|
||||
/** Map of available task names -> handler methods. */
|
||||
protected array $tasks = [
|
||||
'update_date_age_reference' => 'taskUpdateDateAgeReference',
|
||||
'enable_attendance_on' => 'taskEnableAttendanceOn',
|
||||
'enable_attendance_off' => 'taskEnableAttendanceOff',
|
||||
'set_semester_spring' => 'taskSetSemesterSpring',
|
||||
'set_semester_fall' => 'taskSetSemesterFall',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Set enable_attendance = 1
|
||||
*/
|
||||
protected function taskEnableAttendanceOn(bool $dry, DateTimeZone $tz): bool
|
||||
{
|
||||
return $this->setConfig('enable_attendance', '1', $dry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set enable_attendance = 0
|
||||
*/
|
||||
protected function taskEnableAttendanceOff(bool $dry, DateTimeZone $tz): bool
|
||||
{
|
||||
return $this->setConfig('enable_attendance', '0', $dry);
|
||||
}
|
||||
|
||||
protected function taskUpdateDateAgeReference(bool $dry, DateTimeZone $tz): bool
|
||||
{
|
||||
$now = new DateTime('now', $tz);
|
||||
$isJune1 = ($now->format('n') === '6' && $now->format('j') === '1');
|
||||
$forced = (CLI::getOption('force') !== null);
|
||||
|
||||
if (!$isJune1 && !$forced) {
|
||||
CLI::write(
|
||||
"Today is {$now->format('Y-m-d')} (not June 1) — skipping. Use --force to override.",
|
||||
'yellow'
|
||||
);
|
||||
return true; // no-op, not an error
|
||||
}
|
||||
|
||||
$year = (int) $now->format('Y');
|
||||
$value = sprintf('%04d-12-31', $year);
|
||||
|
||||
CLI::write("Set date_age_reference = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray');
|
||||
if ($dry) return true;
|
||||
|
||||
// Use your model method
|
||||
$ok = (bool) $this->configModel->setConfigValueByKey('date_age_reference', $value);
|
||||
|
||||
if ($ok) {
|
||||
CLI::write("date_age_reference updated to {$value}", 'green');
|
||||
} else {
|
||||
CLI::error("Failed to update date_age_reference");
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
protected function taskSetSemesterSpring(bool $dry, DateTimeZone $tz): bool
|
||||
{
|
||||
$now = new DateTime('now', $tz);
|
||||
$isFeb1 = ($now->format('n') === '2' && $now->format('j') === '1');
|
||||
$forced = (CLI::getOption('force') !== null);
|
||||
|
||||
if (!$isFeb1 && !$forced) {
|
||||
CLI::write(
|
||||
"Today is {$now->format('Y-m-d')} (not Feb 1) — skipping. Use --force to override.",
|
||||
'yellow'
|
||||
);
|
||||
return true; // no-op is success
|
||||
}
|
||||
|
||||
CLI::write("Set semester = Spring" . ($dry ? ' [DRY]' : ''), 'light_gray');
|
||||
if ($dry) return true;
|
||||
|
||||
return (bool) $this->configModel->setConfigValueByKey('semester', 'Spring');
|
||||
}
|
||||
|
||||
protected function taskSetSemesterFall(bool $dry, DateTimeZone $tz): bool
|
||||
{
|
||||
$now = new DateTime('now', $tz);
|
||||
$isJun1 = ($now->format('n') === '6' && $now->format('j') === '1');
|
||||
$forced = (CLI::getOption('force') !== null);
|
||||
|
||||
if (!$isJun1 && !$forced) {
|
||||
CLI::write(
|
||||
"Today is {$now->format('Y-m-d')} (not Jun 1) — skipping. Use --force to override.",
|
||||
'yellow'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
CLI::write("Set semester = Fall" . ($dry ? ' [DRY]' : ''), 'light_gray');
|
||||
if ($dry) return true;
|
||||
|
||||
return (bool) $this->configModel->setConfigValueByKey('semester', 'Fall');
|
||||
}
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$this->configModel = model(ConfigurationModel::class);
|
||||
|
||||
$tz = $this->getOptionString('tz', $params) ?? ((string)(config('School')->attendance['timezone'] ?? 'UTC'));
|
||||
$task = $this->getOptionString('task', $params, 't');
|
||||
|
||||
// Fallback: first positional arg (php spark config:update enable_attendance_on)
|
||||
if (!$task && !empty($params) && strpos($params[0], '-') !== 0) {
|
||||
$task = trim($params[0]);
|
||||
}
|
||||
|
||||
$dry = $this->hasFlag('dry', $params);
|
||||
$force = $this->hasFlag('force', $params);
|
||||
$dtz = new DateTimeZone($tz);
|
||||
|
||||
if ($task === '' || !isset($this->tasks[$task])) {
|
||||
CLI::error('Invalid or missing --task. Available: ' . implode(', ', array_keys($this->tasks)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-task lock
|
||||
$lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "ci4_config_update_{$task}.lock";
|
||||
$fp = @fopen($lockFile, 'c+');
|
||||
if (!$fp) {
|
||||
CLI::error("Unable to open lock file: $lockFile");
|
||||
return;
|
||||
}
|
||||
if (!$force && !flock($fp, LOCK_EX | LOCK_NB)) {
|
||||
CLI::error("Task '$task' is already running (lock held). Use --force to override.");
|
||||
fclose($fp);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$method = $this->tasks[$task];
|
||||
CLI::write("Running task: {$task}" . ($dry ? ' [DRY RUN]' : ''), 'yellow');
|
||||
$ok = $this->{$method}($dry, $dtz);
|
||||
if ($ok === true) {
|
||||
CLI::write("Task '{$task}' finished successfully.", 'green');
|
||||
} else {
|
||||
CLI::error("Task '{$task}' finished with warnings or no changes.");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
CLI::error("Task '{$task}' failed: " . $e->getMessage());
|
||||
} finally {
|
||||
try {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
@unlink($lockFile);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts --name=value, --name value, -s value, or scans $params. */
|
||||
private function getOptionString(string $name, array $params, ?string $short = null): ?string
|
||||
{
|
||||
$v = CLI::getOption($name);
|
||||
if (is_string($v) && $v !== '') return trim($v);
|
||||
if ($short) {
|
||||
$v = CLI::getOption($short);
|
||||
if (is_string($v) && $v !== '') return trim($v);
|
||||
}
|
||||
foreach ($params as $i => $p) {
|
||||
if (strpos($p, "--{$name}=") === 0) return trim(substr($p, strlen($name) + 3));
|
||||
if ($short && strpos($p, "-{$short}=") === 0) return trim(substr($p, strlen($short) + 2));
|
||||
if ($p === "--{$name}" || ($short && $p === "-{$short}")) {
|
||||
return $params[$i + 1] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True if flag present as --name or -s (no value needed). */
|
||||
private function hasFlag(string $name, array $params, ?string $short = null): bool
|
||||
{
|
||||
if (CLI::getOption($name) !== null) return true;
|
||||
if ($short && CLI::getOption($short) !== null) return true;
|
||||
foreach ($params as $p) {
|
||||
if ($p === "--{$name}" || ($short && $p === "-{$short}")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/* ------------------------- Helpers ------------------------- */
|
||||
protected function setConfig(string $key, string $value, bool $dry): bool
|
||||
{
|
||||
// show current value
|
||||
$current = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
|
||||
CLI::write("{$key}: current={$current}", 'light_gray');
|
||||
|
||||
CLI::write("Set {$key} = {$value}" . ($dry ? ' [DRY]' : ''), 'light_gray');
|
||||
if ($dry) return true;
|
||||
|
||||
// ✅ use your model function
|
||||
$ok = (bool) $this->configModel->setConfigValueByKey($key, $value);
|
||||
|
||||
// read-back to verify what’s persisted
|
||||
$after = $this->configModel->where('config_key', $key)->first()['config_value'] ?? '<NULL>';
|
||||
CLI::write("{$key}: after={$after}", $ok ? 'green' : 'red');
|
||||
|
||||
return $ok && ($after === $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use CodeIgniter\Events\Events;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class DeleteInactiveUsers extends BaseCommand
|
||||
{
|
||||
protected $group = 'Maintenance';
|
||||
protected $name = 'users:delete-inactive-users';
|
||||
protected $description = 'Delete users that are inactive and created more than 15 minutes ago, along with their entries in the parents table and user_roles table if applicable.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
try {
|
||||
CLI::write('Running deletion of inactive users...', 'yellow');
|
||||
$cutoffTime = Time::now()->subMinutes(15)->toDateTimeString();
|
||||
CLI::write("Cutoff time for deletion: $cutoffTime", 'blue');
|
||||
log_message('debug', 'Cutoff time for deletion: ' . $cutoffTime);
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 1 Fetch inactive users older than 15 min
|
||||
// ─────────────────────────────────────────────────────
|
||||
$users = $db->table('users')
|
||||
->select('id, firstname, lastname, email, created_at')
|
||||
->where('status', 'Inactive')
|
||||
->where('created_at <', $cutoffTime)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($users)) {
|
||||
CLI::write('No inactive users found to delete.', 'yellow');
|
||||
$this->purgeOrphanedUserRoles($db);
|
||||
return;
|
||||
}
|
||||
|
||||
CLI::write('Found ' . count($users) . ' users for deletion.', 'green');
|
||||
|
||||
// collect IDs
|
||||
$userIds = array_column($users, 'id');
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 2 Transaction: delete parents → user_roles → users
|
||||
// ─────────────────────────────────────────────────────
|
||||
$db->transStart();
|
||||
|
||||
// 2-a Delete any secondary-parent rows tied to these users
|
||||
$parentsBuilder = $db->table('parents');
|
||||
$deletedParents = $parentsBuilder->whereIn('firstparent_id', $userIds)->delete();
|
||||
CLI::write("Deleted $deletedParents associated second-parent record(s).", 'blue');
|
||||
log_message('info', "Deleted $deletedParents rows from parents table.");
|
||||
|
||||
// 2-b Delete user_roles rows
|
||||
$db->table('user_roles')->whereIn('user_id', $userIds)->delete();
|
||||
CLI::write('Deleted related user_roles rows.', 'blue');
|
||||
|
||||
// 2-c Delete users
|
||||
$db->table('users')->whereIn('id', $userIds)->delete();
|
||||
CLI::write('Deleted users: ' . implode(', ', $userIds), 'green');
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
if (!$db->transStatus()) {
|
||||
CLI::write('Transaction failed; rolled back.', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
// Purge any stray user_role rows left behind (defensive)
|
||||
$this->purgeOrphanedUserRoles($db);
|
||||
|
||||
$msg = 'Deleted ' . count($users) . " inactive users plus $deletedParents parents rows.";
|
||||
CLI::write($msg, 'green');
|
||||
log_message('info', $msg);
|
||||
} catch (\Throwable $e) {
|
||||
CLI::write('Error deleting inactive users: ' . $e->getMessage(), 'red');
|
||||
log_message('error', 'Error deleting inactive users: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user_roles rows that point to non-existent users.
|
||||
*/
|
||||
private function purgeOrphanedUserRoles(\CodeIgniter\Database\BaseConnection $db): void
|
||||
{
|
||||
$orphaned = $db->table('user_roles')
|
||||
->whereNotIn('user_id', function ($q) use ($db) {
|
||||
$q->select('id')->from('users');
|
||||
})
|
||||
->delete();
|
||||
|
||||
if ($orphaned) {
|
||||
CLI::write("Deleted $orphaned orphaned user_roles rows.", 'green');
|
||||
log_message('info', "Deleted $orphaned orphaned user_roles rows.");
|
||||
} else {
|
||||
CLI::write('No orphaned user_roles rows found.', 'yellow');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use Config\Database;
|
||||
|
||||
class RecalculateAttendance extends BaseCommand
|
||||
{
|
||||
protected $group = 'Attendance';
|
||||
protected $name = 'attendance:recalculate-summary';
|
||||
protected $description = 'Recalculates the attendance summary records (total absences, etc.) from the raw attendance data.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$db = Database::connect();
|
||||
|
||||
CLI::write('Starting attendance summary recalculation...', 'yellow');
|
||||
|
||||
// 1. Truncate the attendance_record table
|
||||
try {
|
||||
$db->table('attendance_record')->truncate();
|
||||
CLI::write('Successfully truncated attendance_record table.', 'green');
|
||||
} catch (\Throwable $e) {
|
||||
CLI::error('Failed to truncate attendance_record table: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Get all attendance data
|
||||
$attendanceData = $db->table('attendance_data')
|
||||
->orderBy('student_id', 'ASC')
|
||||
->orderBy('school_year', 'ASC')
|
||||
->orderBy('semester', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
if (empty($attendanceData)) {
|
||||
CLI::write('No attendance data found to process.', 'yellow');
|
||||
return;
|
||||
}
|
||||
|
||||
$summary = [];
|
||||
|
||||
// 3. Process the data
|
||||
foreach ($attendanceData as $row) {
|
||||
$studentId = $row['student_id'];
|
||||
$schoolYear = $row['school_year'];
|
||||
$semester = $row['semester'];
|
||||
$status = strtolower($row['status']);
|
||||
|
||||
$key = "{$studentId}-{$schoolYear}-{$semester}";
|
||||
|
||||
if (!isset($summary[$key])) {
|
||||
$summary[$key] = [
|
||||
'student_id' => $studentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'class_section_id' => $row['class_section_id'],
|
||||
'school_id' => $row['school_id'],
|
||||
'total_presence' => 0,
|
||||
'total_absence' => 0,
|
||||
'total_late' => 0,
|
||||
'total_attendance' => 0,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
}
|
||||
|
||||
$summary[$key]['total_attendance']++;
|
||||
if ($status === 'present') {
|
||||
$summary[$key]['total_presence']++;
|
||||
} elseif ($status === 'absent') {
|
||||
$summary[$key]['total_absence']++;
|
||||
} elseif ($status === 'late') {
|
||||
$summary[$key]['total_late']++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Insert the new summary records
|
||||
if (!empty($summary)) {
|
||||
$builder = $db->table('attendance_record');
|
||||
try {
|
||||
$builder->insertBatch(array_values($summary));
|
||||
CLI::write('Successfully inserted ' . count($summary) . ' summary records.', 'green');
|
||||
} catch (\Throwable $e) {
|
||||
CLI::error('Failed to insert summary records: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
CLI::write('Attendance summary recalculation finished.', 'green');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\AttendanceDataModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\NotificationService;
|
||||
|
||||
class SendAbsenteesSummary extends BaseCommand
|
||||
{
|
||||
protected $group = 'Attendance';
|
||||
protected $name = 'attendance:absentees-summary';
|
||||
protected $description = 'Sends daily attendance summaries to parents.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$attendanceModel = new AttendanceDataModel();
|
||||
$userModel = new UserModel();
|
||||
|
||||
// Fetch today’s absent records (replace with your logic)
|
||||
$absentRecords = $attendanceModel->getTodayAbsentees();
|
||||
|
||||
foreach ($absentRecords as $record) {
|
||||
// Get parent user ID
|
||||
$parent = $userModel->find($record['parent_id']);
|
||||
if (!$parent) continue;
|
||||
|
||||
NotificationService::toUser(
|
||||
$parent['id'],
|
||||
'Daily Attendance Update',
|
||||
"{$record['student_name']} was marked absent on {$record['date']}.",
|
||||
['in_app', 'email']
|
||||
);
|
||||
|
||||
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
|
||||
}
|
||||
|
||||
CLI::write("Attendance summary completed.", 'green');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\AttendanceDataModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\NotificationService;
|
||||
|
||||
class SendLatesSummary extends BaseCommand
|
||||
{
|
||||
protected $group = 'Attendance';
|
||||
protected $name = 'attendance:lates-summary';
|
||||
protected $description = 'Sends daily attendance summaries to parents.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$attendanceModel = new AttendanceDataModel();
|
||||
$userModel = new UserModel();
|
||||
|
||||
// Fetch today’s late records (replace with your logic)
|
||||
$lateRecords = $attendanceModel->getTodayLates();
|
||||
|
||||
foreach ($lateRecords as $record) {
|
||||
// Get parent user ID
|
||||
$parent = $userModel->find($record['parent_id']);
|
||||
if (!$parent) continue;
|
||||
|
||||
NotificationService::toUser(
|
||||
$parent['id'],
|
||||
'Daily Attendance Update',
|
||||
"{$record['student_name']} was marked late on {$record['date']}.",
|
||||
['in_app', 'email']
|
||||
);
|
||||
|
||||
CLI::write("Sent summary to parent: {$parent['email']}", 'yellow');
|
||||
}
|
||||
|
||||
CLI::write("Attendance summary completed.", 'green');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\PaymentNotificationLogModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Models\FamilyGuardianModel;
|
||||
use App\Services\EmailService;
|
||||
use App\Services\NotificationService;
|
||||
|
||||
class SendMonthlyPaymentNotifications extends BaseCommand
|
||||
{
|
||||
protected $group = 'Payments';
|
||||
protected $name = 'payments:monthly-reminder';
|
||||
protected $description = 'Send monthly payment reminders on the first Saturday of every month.';
|
||||
|
||||
protected EmailService $emailService;
|
||||
protected ConfigurationModel $configModel;
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected PaymentNotificationLogModel $logModel;
|
||||
protected UserModel $userModel;
|
||||
protected FamilyGuardianModel $familyGuardianModel;
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
// Lazy init to avoid BaseCommand constructor issues during discovery
|
||||
$this->emailService = new EmailService();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->logModel = new PaymentNotificationLogModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->familyGuardianModel = new FamilyGuardianModel();
|
||||
|
||||
// Parse params: --force, --email=addr, --type=no_payment|installment
|
||||
$force = false;
|
||||
$targetEmail = null;
|
||||
$targetType = null;
|
||||
foreach ($params as $p) {
|
||||
if ($p === '--force') { $force = true; continue; }
|
||||
if (strpos($p, '--email=') === 0) { $targetEmail = trim(substr($p, 8)); continue; }
|
||||
if (strpos($p, '--type=') === 0) { $targetType = trim(substr($p, 7)); continue; }
|
||||
}
|
||||
// Also support CI4 options parser
|
||||
$optEmail = CLI::getOption('email');
|
||||
if ($optEmail !== null) $targetEmail = $optEmail;
|
||||
$optType = CLI::getOption('type');
|
||||
if ($optType !== null) $targetType = $optType;
|
||||
if (CLI::getOption('force') !== null) $force = true;
|
||||
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC');
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$now = new \DateTime('now', $tz);
|
||||
$year = (int) $now->format('Y');
|
||||
$month = (int) $now->format('n');
|
||||
|
||||
if (!$targetEmail && !$force && !$this->isFirstSaturday($now)) {
|
||||
CLI::write('Not the first Saturday of the month. Use --force to override.', 'yellow');
|
||||
return;
|
||||
}
|
||||
|
||||
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? $year);
|
||||
|
||||
// Targeted test mode: only send to a specific email
|
||||
if ($targetEmail) {
|
||||
if (!filter_var($targetEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
CLI::write('Invalid --email provided', 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
$userRow = $this->userModel->where('email', $targetEmail)->first();
|
||||
$parentId = (int)($userRow['id'] ?? 0);
|
||||
|
||||
$invRows = $parentId ? $this->invoiceModel->getAllInvoicesByUserIds([$parentId], $schoolYear) : [];
|
||||
$totalBalance = 0.0;
|
||||
$latestInvoiceId = null;
|
||||
foreach ($invRows as $ir) {
|
||||
$totalBalance += (float)($ir['balance'] ?? 0);
|
||||
if ($latestInvoiceId === null) {
|
||||
$latestInvoiceId = (int) ($ir['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$type = in_array($targetType, ['no_payment','installment'], true) ? $targetType : 'no_payment';
|
||||
$ccEmail = $parentId ? $this->getSecondaryGuardianEmail($parentId) : null;
|
||||
|
||||
[$subject, $body] = $this->composeEmail($parentId ?: 0, $schoolYear, $type, $totalBalance, $now);
|
||||
|
||||
$sentOk = $this->emailService->send($targetEmail, $subject, $body, 'finance');
|
||||
if ($sentOk && $ccEmail && strcasecmp($ccEmail, $targetEmail) !== 0) {
|
||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
// Upsert log for this month
|
||||
$existing = $this->logModel->where('parent_id', $parentId)
|
||||
->where('period_year', $year)
|
||||
->where('period_month', $month)
|
||||
->where('type', $type)
|
||||
->first();
|
||||
|
||||
$payload = [
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $latestInvoiceId,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $targetEmail,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => 0,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $sentOk ? 'sent' : 'failed',
|
||||
'error_message' => $sentOk ? null : 'Email send failed (see logs)',
|
||||
'balance_snapshot' => $totalBalance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$this->logModel->update($existing['id'], $payload);
|
||||
} else {
|
||||
$this->logModel->insert($payload);
|
||||
}
|
||||
|
||||
CLI::write(($sentOk ? 'Sent' : 'Failed') . " test reminder to {$targetEmail}", $sentOk ? 'green' : 'red');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all parents with invoices for the current school year
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('parent_id')
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('parent_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$parentIds = array_values(array_unique(array_map(static fn($r) => (int) $r['parent_id'], $rows)));
|
||||
if (empty($parentIds)) {
|
||||
CLI::write('No invoices found for current school year. Nothing to do.', 'yellow');
|
||||
return;
|
||||
}
|
||||
|
||||
$sentCount = 0;
|
||||
foreach ($parentIds as $parentId) {
|
||||
// Compute total balance across invoices for this year
|
||||
$invRows = $this->invoiceModel->getAllInvoicesByUserIds([$parentId], $schoolYear);
|
||||
$totalBalance = 0.0;
|
||||
$latestInvoiceId = null;
|
||||
foreach ($invRows as $ir) {
|
||||
$totalBalance += (float)($ir['balance'] ?? 0);
|
||||
if ($latestInvoiceId === null) {
|
||||
$latestInvoiceId = (int) ($ir['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalBalance <= 0.0) {
|
||||
continue; // up to date
|
||||
}
|
||||
|
||||
// Determine if parent has any payments this school year
|
||||
$hasPayments = $db->table('payments')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->countAllResults() > 0;
|
||||
|
||||
$type = $hasPayments ? 'installment' : 'no_payment';
|
||||
|
||||
// Idempotency guard: skip if already sent this period for this type
|
||||
if ($this->logModel->existsForPeriod($parentId, $year, $month, $type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recipient emails: primary guardian (current parent), CC second guardian in same family
|
||||
$toEmail = $this->getUserEmail($parentId);
|
||||
$ccEmail = $this->getSecondaryGuardianEmail($parentId);
|
||||
|
||||
if (!$toEmail) {
|
||||
// Log failed attempt due to missing email and continue
|
||||
$this->logModel->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $latestInvoiceId,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => null,
|
||||
'cc_email' => $ccEmail,
|
||||
'subject' => 'Monthly Tuition Reminder',
|
||||
'body' => null,
|
||||
'status' => 'failed',
|
||||
'error_message' => 'No primary email on file',
|
||||
'balance_snapshot' => $totalBalance,
|
||||
]);
|
||||
CLI::write("Skipped parent {$parentId} due to missing email", 'yellow');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compose email
|
||||
[$subject, $body] = $this->composeEmail($parentId, $schoolYear, $type, $totalBalance, $now);
|
||||
|
||||
$sentOk = $this->emailService->send($toEmail, $subject, $body, 'finance');
|
||||
if ($sentOk && $ccEmail && strcasecmp($ccEmail, $toEmail) !== 0) {
|
||||
// Send a separate copy to the secondary guardian
|
||||
$this->emailService->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
// Notify Head of Finance (in-app)
|
||||
$headFaUsers = $this->getHeadOfFinanceUsers();
|
||||
foreach ($headFaUsers as $u) {
|
||||
NotificationService::toUser((int)$u['id'], 'Payment Reminder Sent',
|
||||
sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $parentId, $totalBalance),
|
||||
['in_app']
|
||||
);
|
||||
}
|
||||
|
||||
$this->logModel->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $latestInvoiceId,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $toEmail,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => !empty($headFaUsers) ? 1 : 0,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $sentOk ? 'sent' : 'failed',
|
||||
'error_message' => $sentOk ? null : 'Email send failed (see logs)',
|
||||
'balance_snapshot' => $totalBalance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($sentOk) {
|
||||
$sentCount++;
|
||||
CLI::write("Reminder sent to {$toEmail}" . ($ccEmail ? ", CC {$ccEmail}" : ''), 'green');
|
||||
} else {
|
||||
CLI::write("Failed to send to {$toEmail}", 'red');
|
||||
}
|
||||
}
|
||||
|
||||
// Summary to head of finance (in-app broadcast)
|
||||
$headFaUsers = $this->getHeadOfFinanceUsers();
|
||||
foreach ($headFaUsers as $u) {
|
||||
NotificationService::toUser((int)$u['id'], 'Monthly Payment Reminders Summary',
|
||||
sprintf('Total reminders sent this run: %d (School Year: %s).', $sentCount, $schoolYear),
|
||||
['in_app']
|
||||
);
|
||||
}
|
||||
|
||||
CLI::write("Done. Total sent: {$sentCount}", 'green');
|
||||
}
|
||||
|
||||
private function isFirstSaturday(\DateTimeInterface $dt): bool
|
||||
{
|
||||
// Saturday = 6 (PHP: 0 Sun .. 6 Sat)
|
||||
$isSaturday = ((int)$dt->format('w')) === 6;
|
||||
$isFirstWeek = ((int)$dt->format('j')) <= 7;
|
||||
return $isSaturday && $isFirstWeek;
|
||||
}
|
||||
|
||||
private function getUserEmail(int $userId): ?string
|
||||
{
|
||||
$u = $this->userModel->select('email')->find($userId);
|
||||
$email = $u['email'] ?? null;
|
||||
return $email && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
|
||||
}
|
||||
|
||||
private function getSecondaryGuardianEmail(int $primaryGuardianUserId): ?string
|
||||
{
|
||||
// Find family of primary guardian
|
||||
$row = $this->familyGuardianModel->where('user_id', $primaryGuardianUserId)->first();
|
||||
if (!$row || empty($row['family_id'])) {
|
||||
return null;
|
||||
}
|
||||
$familyId = (int)$row['family_id'];
|
||||
|
||||
$others = $this->familyGuardianModel
|
||||
->where('family_id', $familyId)
|
||||
->where('user_id !=', $primaryGuardianUserId)
|
||||
->where('receive_emails', 1)
|
||||
->findAll();
|
||||
|
||||
foreach ($others as $g) {
|
||||
$email = $this->getUserEmail((int)$g['user_id']);
|
||||
if ($email) return $email;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function composeEmail(int $parentId, string $schoolYear, string $type, float $balance, \DateTimeInterface $now): array
|
||||
{
|
||||
$parent = $this->userModel->find($parentId) ?: [];
|
||||
$parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''));
|
||||
$monthYear = $now->format('F Y');
|
||||
|
||||
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
|
||||
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
|
||||
$balanceFmt = '$' . number_format($balance, 2);
|
||||
|
||||
if ($type === 'no_payment') {
|
||||
$intro = "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet.";
|
||||
} else {
|
||||
$intro = "This is your monthly installment reminder for {$schoolYear}.";
|
||||
}
|
||||
|
||||
// Compute remaining and max installments similar to Manual Payment UI
|
||||
$installmentEndRaw = (string)$this->configModel->getConfig('installment_date');
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; }
|
||||
$remMonths = 0;
|
||||
if ($end) {
|
||||
$y = (int)$end->format('Y') - (int)$today->format('Y');
|
||||
$m = (int)$end->format('n') - (int)$today->format('n');
|
||||
$remMonths = $y * 12 + $m;
|
||||
if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1;
|
||||
if ($remMonths < 0) $remMonths = 0;
|
||||
}
|
||||
$remainingInst = max(1, $remMonths);
|
||||
$maxInst = max(($remMonths ?: 0), ($balance > 0 ? 2 : 0));
|
||||
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2);
|
||||
|
||||
$bodyHtml = <<<HTML
|
||||
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
|
||||
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
|
||||
<p>{$greeting}</p>
|
||||
<p>{$intro}</p>
|
||||
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
|
||||
<ul>
|
||||
<li><strong>Remaining installments:</strong> {$remainingInst}</li>
|
||||
<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>
|
||||
<li><strong>Maximum installments available:</strong> {$maxInst}</li>
|
||||
</ul>
|
||||
<p>
|
||||
As a friendly reminder, our tuition installments are due at the beginning of each month.
|
||||
You can review your invoice and payment options by logging in to your parent portal.
|
||||
</p>
|
||||
<p>
|
||||
If you have any questions or need to arrange a different plan, please reply to this email.
|
||||
</p>
|
||||
<p>Thank you for your prompt attention.</p>
|
||||
<p><em>Reminder Period:</em> {$monthYear}</p>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
$body = view('emails/_wrap_layout', [
|
||||
'title' => 'Monthly Tuition Reminder',
|
||||
'body_html' => $bodyHtml,
|
||||
], ['saveData' => true]);
|
||||
|
||||
return [$subject, $body];
|
||||
}
|
||||
|
||||
private function getHeadOfFinanceUsers(): array
|
||||
{
|
||||
// Try the explicit role label used in the UI first
|
||||
$heads = $this->userModel->getUsersByRole('head of department (finance)');
|
||||
if (!empty($heads)) return $heads;
|
||||
// fallback to accountant role if needed
|
||||
$acct = $this->userModel->getUsersByRole('accountant');
|
||||
return $acct ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentNotificationLogModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\FamilyGuardianModel;
|
||||
use App\Services\EmailService;
|
||||
|
||||
class SendTestPaymentNotification extends BaseCommand
|
||||
{
|
||||
protected $group = 'Payments';
|
||||
protected $name = 'payments:send-test';
|
||||
protected $description = 'Send a single test non-payment or installment reminder to a specific email.';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$email = CLI::getOption('email');
|
||||
$type = CLI::getOption('type') ?? 'no_payment';
|
||||
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
CLI::write('Usage: php spark payments:send-test --email=addr@example.com [--type=no_payment|installment]', 'yellow');
|
||||
return;
|
||||
}
|
||||
|
||||
$config = new ConfigurationModel();
|
||||
$user = new UserModel();
|
||||
$invoice= new InvoiceModel();
|
||||
$logs = new PaymentNotificationLogModel();
|
||||
$fam = new FamilyGuardianModel();
|
||||
$mailer = new EmailService();
|
||||
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC');
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$now = new \DateTime('now', $tz);
|
||||
$year = (int)$now->format('Y');
|
||||
$month= (int)$now->format('n');
|
||||
$schoolYear = (string) ($config->getConfig('school_year') ?? $year);
|
||||
|
||||
$userRow = $user->where('email', $email)->first();
|
||||
$parentId = (int)($userRow['id'] ?? 0);
|
||||
|
||||
$invRows = $parentId ? $invoice->getAllInvoicesByUserIds([$parentId], $schoolYear) : [];
|
||||
$totalBalance = 0.0; $latestInvoiceId = null;
|
||||
foreach ($invRows as $ir) {
|
||||
$totalBalance += (float)($ir['balance'] ?? 0);
|
||||
if ($latestInvoiceId === null) $latestInvoiceId = (int)($ir['id'] ?? 0);
|
||||
}
|
||||
|
||||
// Secondary guardian if available
|
||||
$ccEmail = null;
|
||||
if ($parentId) {
|
||||
$row = $fam->where('user_id', $parentId)->first();
|
||||
if ($row && !empty($row['family_id'])) {
|
||||
$others = $fam->where('family_id', (int)$row['family_id'])
|
||||
->where('user_id !=', $parentId)
|
||||
->where('receive_emails', 1)->findAll();
|
||||
foreach ($others as $g) {
|
||||
$ccEmail = $user->select('email')->find((int)$g['user_id'])['email'] ?? null;
|
||||
if ($ccEmail && filter_var($ccEmail, FILTER_VALIDATE_EMAIL)) break;
|
||||
$ccEmail = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compose body (reuse layout)
|
||||
$parentName = trim(($userRow['firstname'] ?? '') . ' ' . ($userRow['lastname'] ?? ''));
|
||||
$monthYear = $now->format('F Y');
|
||||
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
|
||||
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
|
||||
$balanceFmt = '$' . number_format($totalBalance, 2);
|
||||
$intro = ($type === 'no_payment')
|
||||
? "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet."
|
||||
: "This is your monthly installment reminder for {$schoolYear}.";
|
||||
|
||||
$bodyHtml = <<<HTML
|
||||
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
|
||||
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder (Test)</h2>
|
||||
<p>{$greeting}</p>
|
||||
<p>{$intro}</p>
|
||||
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
|
||||
<p>
|
||||
As a friendly reminder, our tuition installments are due at the beginning of each month.
|
||||
You can review your invoice and payment options by logging in to your parent portal.
|
||||
</p>
|
||||
<p>Thank you for your prompt attention.</p>
|
||||
<p><em>Reminder Period:</em> {$monthYear}</p>
|
||||
</div>
|
||||
HTML;
|
||||
// Compute remaining and max installments similar to Manual Payment UI
|
||||
$installmentEndRaw = (string)$config->getConfig('installment_date');
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; }
|
||||
$remMonths = 0;
|
||||
if ($end) {
|
||||
$y = (int)$end->format('Y') - (int)$today->format('Y');
|
||||
$m = (int)$end->format('n') - (int)$today->format('n');
|
||||
$remMonths = $y * 12 + $m;
|
||||
if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1;
|
||||
if ($remMonths < 0) $remMonths = 0;
|
||||
}
|
||||
$remainingInst = max(1, $remMonths);
|
||||
$maxInst = max(($remMonths ?: 0), ($totalBalance > 0 ? 2 : 0));
|
||||
$instDueFmt = '$' . number_format($remainingInst > 0 ? ($totalBalance / $remainingInst) : 0, 2);
|
||||
|
||||
$bodyHtml .= "<ul>"
|
||||
. "<li><strong>Remaining installments:</strong> {$remainingInst}</li>"
|
||||
. "<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>"
|
||||
. "<li><strong>Maximum installments available:</strong> {$maxInst}</li>"
|
||||
. "</ul>";
|
||||
|
||||
$body = view('emails/_wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml ], ['saveData' => true]);
|
||||
|
||||
$ok = $mailer->send($email, $subject, $body, 'finance');
|
||||
if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) {
|
||||
$mailer->send($ccEmail, $subject, $body, 'finance');
|
||||
}
|
||||
|
||||
$existing = $logs->where('parent_id', $parentId)
|
||||
->where('period_year', $year)
|
||||
->where('period_month', $month)
|
||||
->where('type', $type)
|
||||
->first();
|
||||
|
||||
$payload = [
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $latestInvoiceId,
|
||||
'school_year' => $schoolYear,
|
||||
'period_year' => $year,
|
||||
'period_month' => $month,
|
||||
'type' => $type,
|
||||
'to_email' => $email,
|
||||
'cc_email' => $ccEmail,
|
||||
'head_fa_notified' => 0,
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'status' => $ok ? 'sent' : 'failed',
|
||||
'error_message' => $ok ? null : 'Email send failed (see logs)',
|
||||
'balance_snapshot' => $totalBalance,
|
||||
'sent_at' => $now->format('Y-m-d H:i:s'),
|
||||
];
|
||||
if ($existing) $logs->update($existing['id'], $payload); else $logs->insert($payload);
|
||||
|
||||
CLI::write(($ok ? 'Sent' : 'Failed') . " test reminder to {$email}", $ok ? 'green' : 'red');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\PayPalPaymentModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
|
||||
class SyncPaypalPayments extends BaseCommand
|
||||
{
|
||||
protected $group = 'Payments';
|
||||
protected $name = 'payments:sync-paypal';
|
||||
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments';
|
||||
protected $configModel;
|
||||
protected $semester;
|
||||
protected $schoolYear;
|
||||
protected $paypalModel;
|
||||
protected $paymentModel;
|
||||
protected $userModel;
|
||||
protected $invoiceModel;
|
||||
protected $studentModel;
|
||||
protected $enrollmentModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->paypalModel = new PayPalPaymentModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$dryRun = CLI::getOption('dry-run');
|
||||
$reportOnly = CLI::getOption('report-only');
|
||||
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
|
||||
|
||||
$paypalEntries = $this->paypalModel
|
||||
->where('status', 'COMPLETED')
|
||||
->where('synced', 0)
|
||||
->where('sync_attempts <', 3)
|
||||
->where('transaction_id IS NOT NULL')
|
||||
->findAll();
|
||||
|
||||
$syncedCount = 0;
|
||||
$failed = [];
|
||||
|
||||
foreach ($paypalEntries as $entry) {
|
||||
$parentId = null;
|
||||
$invoiceId = 0;
|
||||
|
||||
$users = $this->userModel->getUsersBySchoolId($entry['parent_school_id']);
|
||||
$user = $users[0] ?? null;
|
||||
|
||||
// Always increment sync_attempts unless report-only
|
||||
if (!$reportOnly) {
|
||||
$this->paypalModel->update($entry['id'], [
|
||||
'sync_attempts' => $entry['sync_attempts'] + 1
|
||||
]);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
$parentId = $user['id'];
|
||||
|
||||
$invoice = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
|
||||
|
||||
if (!$reportOnly && !$dryRun) {
|
||||
if ($invoice) {
|
||||
$invoiceId = $invoice['id'];
|
||||
|
||||
$success = $this->processPayment(
|
||||
$invoiceId,
|
||||
$entry['amount'],
|
||||
'PayPal',
|
||||
null,
|
||||
$entry['transaction_id'],
|
||||
date('Y-m-d', strtotime($entry['created_at'])),
|
||||
$this->schoolYear,
|
||||
$this->semester
|
||||
);
|
||||
|
||||
if (!$success) {
|
||||
$failed[] = $entry['transaction_id'];
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$this->paymentModel->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => 0,
|
||||
'total_amount' => $entry['amount'],
|
||||
'paid_amount' => $entry['amount'],
|
||||
'balance' => 0.00,
|
||||
'number_of_installments' => 1,
|
||||
'transaction_id' => $entry['transaction_id'],
|
||||
'payment_method' => 'PayPal',
|
||||
'payment_date' => date('Y-m-d', strtotime($entry['created_at'])),
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'status' => 'Completed',
|
||||
'updated_by' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Mark as synced only in LIVE mode
|
||||
$this->paypalModel->update($entry['id'], ['synced' => 1]);
|
||||
}
|
||||
|
||||
$syncedCount++;
|
||||
} else {
|
||||
log_message('error', "[PAYPAL SYNC FAILED] No user found for parent_school_id: {$entry['parent_school_id']}");
|
||||
$failed[] = $entry['transaction_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// === Logging ===
|
||||
log_message('info', "[$mode] PAYPAL SYNC: $syncedCount processed.");
|
||||
if (!empty($failed)) {
|
||||
log_message('error', "[$mode] PAYPAL SYNC Failed: " . implode(', ', $failed));
|
||||
}
|
||||
|
||||
// === CLI Output ===
|
||||
CLI::write("[$mode] $syncedCount PayPal payments processed.", 'green');
|
||||
if (!empty($failed)) {
|
||||
CLI::error("[$mode] Failed transactions: " . implode(', ', $failed));
|
||||
}
|
||||
|
||||
// === Email Report: Only if there's any update ===
|
||||
if ($syncedCount > 0 || !empty($failed)) {
|
||||
helper('email');
|
||||
$email = \Config\Services::email();
|
||||
$email->setTo('support@alrahmaisgl.org');
|
||||
$email->setFrom('no-parentsreply@alrahmaisgl.org', 'PayPal Sync Report');
|
||||
$email->setSubject("[$mode] PayPal Sync Report - " . date('Y-m-d H:i'));
|
||||
|
||||
$body = "PayPal Sync Mode: $mode\n\n";
|
||||
$body .= "$syncedCount PayPal payments processed.\n\n";
|
||||
|
||||
if (!empty($failed)) {
|
||||
$body .= count($failed) . " failed transactions:\n";
|
||||
$body .= implode("\n", $failed);
|
||||
} else {
|
||||
$body .= "No failed transactions.\n";
|
||||
}
|
||||
|
||||
$email->setMessage(nl2br($body));
|
||||
|
||||
if ($email->send()) {
|
||||
CLI::write("[$mode] Email report sent successfully.", 'yellow');
|
||||
} else {
|
||||
CLI::error("[$mode] Failed to send email report.");
|
||||
log_message('error', 'Email send error: ' . $email->printDebugger(['headers']));
|
||||
}
|
||||
} else {
|
||||
log_message('info', "[$mode] No PayPal sync updates. Email not sent.");
|
||||
CLI::write("[$mode] No changes to report. Email not sent.", 'blue');
|
||||
}
|
||||
}
|
||||
|
||||
private function processPayment($invoiceId, $amount, $paymentMethod, $checkFile = null, $transactionId = null, $paymentDate = null, $schoolYear = null, $semester = null)
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time();
|
||||
$paymentDate = $paymentDate ?? date('Y-m-d');
|
||||
|
||||
$newPaid = $invoice['paid_amount'] + $amount;
|
||||
$newBalance = $invoice['balance'] - $amount;
|
||||
|
||||
$invoiceUpdateData = [
|
||||
'paid_amount' => $newPaid,
|
||||
'balance' => $newBalance,
|
||||
'status' => ($newBalance <= 0) ? 'Paid' : $invoice['status'],
|
||||
];
|
||||
|
||||
if (!$this->invoiceModel->update($invoiceId, $invoiceUpdateData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->paymentModel->insert([
|
||||
'parent_id' => $invoice['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $invoice['total_amount'],
|
||||
'paid_amount' => $amount,
|
||||
'balance' => $newBalance,
|
||||
'number_of_installments' => 1,
|
||||
'transaction_id' => $transactionId,
|
||||
'payment_method' => $paymentMethod,
|
||||
'payment_date' => $paymentDate,
|
||||
'status' => ($newBalance <= 0) ? 'Full' : 'Partial',
|
||||
'check_file' => $checkFile,
|
||||
'updated_by' => null, // Avoid using session()->get() in CLI
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester
|
||||
]);
|
||||
|
||||
$this->updateEnrollmentStatusIfPaid($invoiceId, $schoolYear);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function updateEnrollmentStatusIfPaid($invoiceId, $schoolYear)
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice || $invoice['balance'] > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$students = $this->studentModel->where('parent_id', $invoice['parent_id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($students as $student) {
|
||||
$this->enrollmentModel->set(['enrollment_status' => 'enrolled'])
|
||||
->where('student_id', $student['id'])
|
||||
->update();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user