add refund logic and fix books inventory logic
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Converts one legacy distribution movement into immutable, priced issue
|
||||
* evidence. The existing stock movement is linked, never replayed, so this
|
||||
* command cannot decrement inventory twice.
|
||||
*/
|
||||
class ReconcileLegacyBookIssue extends BaseCommand
|
||||
{
|
||||
protected $group = 'Inventory';
|
||||
protected $name = 'inventory:reconcile-legacy-book-issue';
|
||||
protected $description = 'List or explicitly reconcile legacy student book distributions without guessing a price.';
|
||||
protected $usage = 'php spark inventory:reconcile-legacy-book-issue [--school-year=2025-2026] [--movement-id=123 --unit-price=25.00 --reason="approved evidence" --actor-id=7 --commit]';
|
||||
protected $options = [
|
||||
'--school-year' => 'Filter the read-only orphan list.',
|
||||
'--movement-id' => 'One legacy distribution movement to reconcile.',
|
||||
'--unit-price' => 'Admin-approved historical unit charge price.',
|
||||
'--reason' => 'Required audit explanation for the evidence source.',
|
||||
'--actor-id' => 'Required administrator user ID.',
|
||||
'--commit' => 'Actually write; without this flag the command is read-only.',
|
||||
'--json' => 'Print listing or result as JSON.',
|
||||
];
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
foreach (['inventory_movements', 'inventory_item_years', 'student_book_issues', 'withdrawal_financial_calculations', 'refunds'] as $table) {
|
||||
if (! $db->tableExists($table)) {
|
||||
CLI::error('Required table is missing: ' . $table . '. Run migrations first.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$movementId = (int) (CLI::getOption('movement-id') ?? 0);
|
||||
if ($movementId <= 0) {
|
||||
$builder = $db->table('inventory_movements im')
|
||||
->select('im.id, im.item_id, im.student_id, im.class_section_id, im.qty_change, im.school_year, im.created_at, i.name AS book_name')
|
||||
->join('inventory_items i', 'i.id = im.item_id', 'inner')
|
||||
->join('student_book_issues sbi', 'sbi.distribution_movement_id = im.id', 'left')
|
||||
->where('im.movement_type', 'distribution')
|
||||
->where('im.student_id IS NOT NULL', null, false)
|
||||
->where('im.qty_change <', 0)
|
||||
->where('im.status', 'posted')
|
||||
->where('sbi.id', null)
|
||||
->where('i.type', 'book')
|
||||
->orderBy('im.id', 'ASC');
|
||||
$year = trim((string) (CLI::getOption('school-year') ?? ''));
|
||||
if ($year !== '') {
|
||||
$builder->where('im.school_year', $year);
|
||||
}
|
||||
$rows = $builder->get()->getResultArray();
|
||||
if (CLI::getOption('json') !== null) {
|
||||
CLI::write(json_encode(['count' => count($rows), 'legacy_distributions' => $rows], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
return;
|
||||
}
|
||||
CLI::write('Legacy book distributions requiring priced issue evidence: ' . count($rows), 'yellow');
|
||||
foreach ($rows as $row) {
|
||||
CLI::write(sprintf('#%d %s student=%d qty=%d year=%s', (int) $row['id'], (string) $row['book_name'], (int) $row['student_id'], abs((int) $row['qty_change']), (string) $row['school_year']));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$priceCents = $this->priceToCents((string) (CLI::getOption('unit-price') ?? ''));
|
||||
$reason = trim((string) (CLI::getOption('reason') ?? ''));
|
||||
$actorId = (int) (CLI::getOption('actor-id') ?? 0);
|
||||
if ($reason === '' || $actorId <= 0) {
|
||||
throw new InvalidArgumentException('--reason and a positive --actor-id are required.');
|
||||
}
|
||||
|
||||
$db->transBegin();
|
||||
$movement = $db->query('SELECT * FROM inventory_movements WHERE id = ? FOR UPDATE', [$movementId])->getRowArray();
|
||||
if ($movement === null || ($movement['movement_type'] ?? '') !== 'distribution' || ($movement['status'] ?? '') !== 'posted' || (int) ($movement['qty_change'] ?? 0) >= 0 || (int) ($movement['student_id'] ?? 0) <= 0) {
|
||||
throw new InvalidArgumentException('The movement is not a negative student distribution.');
|
||||
}
|
||||
if ($db->table('student_book_issues')->where('distribution_movement_id', $movementId)->countAllResults() > 0) {
|
||||
throw new InvalidArgumentException('This movement is already linked to student issue evidence.');
|
||||
}
|
||||
$book = $db->table('inventory_items')->select('id, name, type')->where('id', (int) $movement['item_id'])->get(1)->getRowArray();
|
||||
if ($book === null || ($book['type'] ?? '') !== 'book') {
|
||||
throw new InvalidArgumentException('The movement item is not a book.');
|
||||
}
|
||||
$schoolYear = trim((string) ($movement['school_year'] ?? ''));
|
||||
if ($schoolYear === '') {
|
||||
throw new InvalidArgumentException('The legacy movement has no school year. Correct that evidence first.');
|
||||
}
|
||||
$itemYear = $db->table('inventory_item_years')
|
||||
->where('inventory_item_id', (int) $movement['item_id'])
|
||||
->where('school_year', $schoolYear)->get(1)->getRowArray();
|
||||
if ($itemYear === null) {
|
||||
throw new InvalidArgumentException('The book has no inventory-year record for ' . $schoolYear . '.');
|
||||
}
|
||||
$enrollments = $db->table('enrollments')->select('id, parent_id, class_section_id')
|
||||
->where('student_id', (int) $movement['student_id'])->where('school_year', $schoolYear)->get()->getResultArray();
|
||||
if (count($enrollments) !== 1 || (int) ($enrollments[0]['parent_id'] ?? 0) <= 0) {
|
||||
throw new InvalidArgumentException('Exactly one parent-linked enrollment must exist for the student and year.');
|
||||
}
|
||||
|
||||
$quantity = abs((int) $movement['qty_change']);
|
||||
if ($quantity <= 0 || $priceCents > intdiv(2147483647, $quantity)) {
|
||||
throw new InvalidArgumentException('The quantity and approved price exceed the supported charge range.');
|
||||
}
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$issuedAt = trim((string) ($movement['created_at'] ?? '')) ?: $now;
|
||||
$issue = [
|
||||
'student_id' => (int) $movement['student_id'],
|
||||
'enrollment_id' => (int) $enrollments[0]['id'],
|
||||
'parent_id' => (int) $enrollments[0]['parent_id'],
|
||||
'inventory_item_id' => (int) $movement['item_id'],
|
||||
'inventory_item_year_id' => (int) $itemYear['id'],
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_id' => (int) ($movement['class_section_id'] ?? $enrollments[0]['class_section_id'] ?? 0) ?: null,
|
||||
'quantity' => $quantity,
|
||||
'unit_charge_price_cents' => $priceCents,
|
||||
'total_charge_cents' => $priceCents * $quantity,
|
||||
'distribution_movement_id' => $movementId,
|
||||
'idempotency_key' => 'legacy-distribution:' . $movementId,
|
||||
'status' => 'issued',
|
||||
'issued_at' => $issuedAt,
|
||||
'issued_by' => $actorId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if (CLI::getOption('commit') === null) {
|
||||
$db->transRollback();
|
||||
$result = ['mode' => 'dry-run', 'movement_id' => $movementId, 'book' => $book['name'], 'issue' => $issue, 'reason' => $reason];
|
||||
CLI::write(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
CLI::write('DRY RUN — add --commit to save.', 'yellow');
|
||||
return;
|
||||
}
|
||||
if (! $db->table('student_book_issues')->insert($issue)) {
|
||||
throw new RuntimeException('Unable to insert issue evidence.');
|
||||
}
|
||||
$issueId = (int) $db->insertID();
|
||||
$db->table('inventory_movements')->where('id', $movementId)->update([
|
||||
'item_year_id' => (int) $itemYear['id'],
|
||||
'idempotency_key' => 'legacy-distribution-movement:' . $movementId,
|
||||
'status' => 'posted',
|
||||
'source_type' => 'student_book_issue',
|
||||
'source_id' => $issueId,
|
||||
'note' => trim((string) ($movement['note'] ?? '') . "\nLegacy evidence approved by user #{$actorId}: {$reason}"),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$affectedCalculations = $db->table('withdrawal_financial_calculations')
|
||||
->select('id')
|
||||
->where('student_id', (int) $movement['student_id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'posted')
|
||||
->where('withdrawal_request_date >=', substr($issuedAt, 0, 10))
|
||||
->get()->getResultArray();
|
||||
$affectedIds = array_values(array_filter(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $affectedCalculations)));
|
||||
if ($affectedIds !== []) {
|
||||
$db->table('withdrawal_financial_calculations')->whereIn('id', $affectedIds)->update([
|
||||
'status' => 'requires_review',
|
||||
'active_posted_key' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$db->table('refunds')->whereIn('withdrawal_calculation_id', $affectedIds)->update([
|
||||
'reconciliation_status' => 'requires_review',
|
||||
'reconciliation_reason' => 'Legacy book issue evidence was added after the withdrawal calculation was posted.',
|
||||
'reconciliation_required_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
if (! $db->transCommit()) {
|
||||
throw new RuntimeException('Unable to commit the reconciliation.');
|
||||
}
|
||||
CLI::write('Reconciled movement #' . $movementId . ' as issue #' . $issueId . ' without changing stock.', 'green');
|
||||
} catch (Throwable $e) {
|
||||
$db->transRollback();
|
||||
CLI::error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function priceToCents(string $price): int
|
||||
{
|
||||
$price = trim($price);
|
||||
if (! preg_match('/^\d+(?:\.\d{1,2})?$/', $price)) {
|
||||
throw new InvalidArgumentException('--unit-price is required with no more than two decimal places.');
|
||||
}
|
||||
[$whole, $fraction] = array_pad(explode('.', $price, 2), 2, '');
|
||||
$cents = ((int) $whole * 100) + (int) str_pad($fraction, 2, '0');
|
||||
if ($cents <= 0 || $cents > 2147483647) {
|
||||
throw new InvalidArgumentException('Unit price must be greater than zero and within range.');
|
||||
}
|
||||
return $cents;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
/** Read-only audit for withdrawal refund posting prerequisites. */
|
||||
class WithdrawalInventoryAudit extends BaseCommand
|
||||
{
|
||||
protected $group = 'Financial';
|
||||
protected $name = 'financial:withdrawal-inventory-audit';
|
||||
protected $description = 'Audit withdrawal policy, book inventory evidence, invoices, and open refunds without changing data.';
|
||||
protected $usage = 'php spark financial:withdrawal-inventory-audit --school-year=2025-2026 [--json]';
|
||||
protected $options = ['--school-year' => 'Required school year.', '--json' => 'Print JSON.'];
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$year = trim((string) (CLI::getOption('school-year') ?? ''));
|
||||
if ($year === '') {
|
||||
CLI::error('--school-year is required; the audit will not guess an active year.');
|
||||
return;
|
||||
}
|
||||
$checks = [];
|
||||
$policy = $db->table('school_years')->where('name', $year)->get(1)->getRowArray();
|
||||
$configWeeksRow = $db->table('configuration')
|
||||
->select('config_value')
|
||||
->where('config_key', 'total_instructional_weeks')
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
$configWeeks = filter_var($configWeeksRow['config_value'] ?? null, FILTER_VALIDATE_INT);
|
||||
$checks['policy'] = $policy !== null
|
||||
&& $configWeeks !== false
|
||||
&& $configWeeks > 0
|
||||
&& (int) ($policy['annual_fee_includes_books'] ?? 0) === 1
|
||||
? $this->check('pass', 'School-year refund inputs are present.')
|
||||
: $this->check('blocking', 'Configuration total_instructional_weeks or book-inclusive policy is missing.');
|
||||
|
||||
$missingPrices = $db->table('inventory_item_years iy')
|
||||
->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner')
|
||||
->where('iy.school_year', $year)->where('i.type', 'book')
|
||||
->groupStart()->where('iy.charge_price_cents <=', 0)->orWhere('iy.price_confirmed !=', 1)->groupEnd()
|
||||
->countAllResults();
|
||||
$checks['book_prices'] = $missingPrices === 0 ? $this->check('pass', 'All book-year prices are confirmed.') : $this->check('blocking', $missingPrices . ' book-year price(s) are missing or unconfirmed.');
|
||||
|
||||
$catalogRows = $db->table('inventory_items')->select('id, name, isbn, edition, sku, author')->where('type', 'book')->get()->getResultArray();
|
||||
$identityCounts = [];
|
||||
$skuCounts = [];
|
||||
foreach ($catalogRows as $catalogRow) {
|
||||
$isbn = strtoupper((string) preg_replace('/[^0-9X]/i', '', (string) ($catalogRow['isbn'] ?? '')));
|
||||
$edition = strtolower(trim((string) ($catalogRow['edition'] ?? '')));
|
||||
$sku = strtolower(trim((string) ($catalogRow['sku'] ?? '')));
|
||||
if ($isbn !== '') {
|
||||
$identityCounts[$isbn . '|' . $edition] = ($identityCounts[$isbn . '|' . $edition] ?? 0) + 1;
|
||||
}
|
||||
if ($sku !== '') {
|
||||
$skuCounts[$sku] = ($skuCounts[$sku] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
$duplicateCount = count(array_filter($identityCounts, static fn (int $count): bool => $count > 1))
|
||||
+ count(array_filter($skuCounts, static fn (int $count): bool => $count > 1));
|
||||
$checks['duplicate_catalog'] = $duplicateCount === 0 ? $this->check('pass', 'No duplicate normalized ISBN/edition or SKU keys found.') : $this->check('blocking', $duplicateCount . ' duplicate normalized ISBN/edition or SKU group(s) require reconciliation.');
|
||||
|
||||
$missingAuthorCount = count(array_filter($catalogRows, static fn (array $row): bool => trim((string) ($row['author'] ?? '')) === ''));
|
||||
$checks['catalog_author'] = $missingAuthorCount === 0
|
||||
? $this->check('pass', 'All book catalog rows have an author.')
|
||||
: $this->check('warning', $missingAuthorCount . ' book catalog row(s) have no author.');
|
||||
$unassignedBooks = $db->table('inventory_items i')
|
||||
->join('inventory_book_class_assignments a', 'a.inventory_item_id = i.id AND a.school_year = ' . $db->escape($year), 'left')
|
||||
->where('i.type', 'book')->where('i.is_active', 1)->where('a.id', null)->countAllResults();
|
||||
$checks['book_class_assignments'] = $unassignedBooks === 0
|
||||
? $this->check('pass', 'All active books have explicit year/class assignments.')
|
||||
: $this->check('warning', $unassignedBooks . ' active book(s) rely on category fallback instead of explicit class assignments.');
|
||||
|
||||
$malformedYearTags = $db->table('inventory_movements')->where('school_year IS NOT NULL', null, false)
|
||||
->where("school_year NOT REGEXP '^[0-9]{4}-[0-9]{4}$'", null, false)->countAllResults();
|
||||
$checks['movement_year_tags'] = $malformedYearTags === 0
|
||||
? $this->check('pass', 'Inventory movement year tags are well formed.')
|
||||
: $this->check('warning', $malformedYearTags . ' inventory movement(s) have malformed school-year tags.');
|
||||
|
||||
$legacyDistributions = $db->table('inventory_movements im')
|
||||
->join('student_book_issues sbi', 'sbi.distribution_movement_id = im.id', 'left')
|
||||
->where('im.school_year', $year)->where('im.movement_type', 'distribution')->where('sbi.id', null)
|
||||
->countAllResults();
|
||||
$checks['legacy_distributions'] = $legacyDistributions === 0 ? $this->check('pass', 'Every distribution has a price-snapshotted issue.') : $this->check('blocking', $legacyDistributions . ' distribution movement(s) lack student issue evidence and cannot be priced automatically.');
|
||||
|
||||
$positiveStudentMovements = $db->table('inventory_movements')
|
||||
->where('school_year', $year)->where('student_id IS NOT NULL', null, false)->where('qty_change >', 0)
|
||||
->groupStart()->where('source_type !=', 'student_book_issue_reversal')->orWhere('source_type', null)->groupEnd()->countAllResults();
|
||||
$checks['possible_returns'] = $positiveStudentMovements === 0 ? $this->check('pass', 'No unclassified positive student book movements found.') : $this->check('blocking', $positiveStudentMovements . ' positive student movement(s) look like legacy returns/corrections and require manual classification.');
|
||||
|
||||
$duplicateOpenings = $db->table('inventory_movements')
|
||||
->select('item_id')->where('school_year', $year)->where('movement_type', 'initial')->groupBy('item_id')->having('COUNT(*) > 1', null, false)->countAllResults();
|
||||
$checks['duplicate_openings'] = $duplicateOpenings === 0 ? $this->check('pass', 'No duplicate legacy opening movements found.') : $this->check('blocking', $duplicateOpenings . ' book/item(s) have duplicate opening movements.');
|
||||
|
||||
$quantityMismatches = $db->query(
|
||||
"SELECT COUNT(*) AS total FROM (
|
||||
SELECT i.id
|
||||
FROM inventory_items i
|
||||
JOIN inventory_item_years iy ON iy.inventory_item_id = i.id AND iy.school_year = ?
|
||||
LEFT JOIN inventory_movements im ON im.item_year_id = iy.id AND im.status IN ('posted','reversed')
|
||||
WHERE i.type = 'book'
|
||||
GROUP BY i.id, i.quantity, iy.opening_quantity
|
||||
HAVING i.quantity != iy.opening_quantity + COALESCE(SUM(im.qty_change), 0)
|
||||
) mismatches",
|
||||
[$year]
|
||||
)->getRowArray();
|
||||
$quantityMismatchCount = (int) ($quantityMismatches['total'] ?? 0);
|
||||
$checks['quantity_projection'] = $quantityMismatchCount === 0
|
||||
? $this->check('pass', 'Book catalog quantities match the year movement ledger.')
|
||||
: $this->check('blocking', $quantityMismatchCount . ' book quantity projection(s) disagree with the year movement ledger.');
|
||||
|
||||
$legacyRefunds = $db->table('refunds')->where('school_year', $year)
|
||||
->whereIn('status', ['Pending', 'pending', 'requested', 'Approved', 'approved', 'Partial', 'partial', 'partially_paid'])
|
||||
->groupStart()->where('withdrawal_calculation_id', null)->orWhere('withdrawal_calculation_id', 0)->groupEnd()
|
||||
->groupStart()->where('source_type', 'tuition_withdrawal')->orLike('reason', 'Withdrawal')->groupEnd()->countAllResults();
|
||||
$checks['legacy_refunds'] = $legacyRefunds === 0 ? $this->check('pass', 'Open withdrawal refunds have calculation links.') : $this->check('blocking', $legacyRefunds . ' open withdrawal refund(s) have no versioned calculation.');
|
||||
|
||||
$comparisonRows = $db->query(
|
||||
"SELECT r.id AS refund_id, r.invoice_id, r.refund_amount,
|
||||
r.requested_amount_cents AS stored_requested_cents,
|
||||
wfc.id AS calculation_id, wfc.new_refund_request_cents AS calculated_requested_cents,
|
||||
wfc.status AS calculation_status
|
||||
FROM refunds r
|
||||
LEFT JOIN withdrawal_financial_calculations wfc ON wfc.id = r.withdrawal_calculation_id
|
||||
WHERE r.school_year = ?
|
||||
AND (r.source_type = 'tuition_withdrawal' OR r.reason LIKE '%Withdrawal%')
|
||||
AND LOWER(COALESCE(r.status,'')) IN ('pending','requested','approved','partial','partially_paid')
|
||||
ORDER BY r.id",
|
||||
[$year]
|
||||
)->getResultArray();
|
||||
$comparisonDifferences = 0;
|
||||
foreach ($comparisonRows as &$comparison) {
|
||||
$storedCents = (int) ($comparison['stored_requested_cents'] ?? 0);
|
||||
if ($storedCents === 0) {
|
||||
$storedCents = (int) round(((float) ($comparison['refund_amount'] ?? 0)) * 100, 0, PHP_ROUND_HALF_UP);
|
||||
}
|
||||
$comparison['stored_requested_cents'] = $storedCents;
|
||||
$comparison['difference_cents'] = $comparison['calculation_id'] === null
|
||||
? null
|
||||
: $storedCents - (int) ($comparison['calculated_requested_cents'] ?? 0);
|
||||
if ($comparison['difference_cents'] !== null && $comparison['difference_cents'] !== 0) {
|
||||
$comparisonDifferences++;
|
||||
}
|
||||
}
|
||||
unset($comparison);
|
||||
$checks['refund_comparison'] = $comparisonDifferences === 0
|
||||
? $this->check('pass', 'Linked open refund requests match their versioned calculations.')
|
||||
: $this->check('blocking', $comparisonDifferences . ' linked open refund request(s) differ from their calculation snapshot.');
|
||||
|
||||
$duplicateInvoices = $db->query(
|
||||
"SELECT COUNT(*) AS total FROM (
|
||||
SELECT parent_id FROM invoices WHERE school_year = ?
|
||||
AND LOWER(COALESCE(status,'')) NOT IN ('void','voided','cancelled','canceled')
|
||||
GROUP BY parent_id HAVING COUNT(*) > 1
|
||||
) duplicates",
|
||||
[$year]
|
||||
)->getRowArray();
|
||||
$duplicateInvoiceCount = (int) ($duplicateInvoices['total'] ?? 0);
|
||||
$checks['duplicate_invoices'] = $duplicateInvoiceCount === 0 ? $this->check('pass', 'No parent has multiple active invoices.') : $this->check('blocking', $duplicateInvoiceCount . ' parent(s) have multiple active invoices and require review.');
|
||||
|
||||
$blocking = count(array_filter($checks, static fn (array $check): bool => $check['severity'] === 'blocking'));
|
||||
$report = ['school_year' => $year, 'generated_at' => date('Y-m-d H:i:s'), 'ready_to_enable' => $blocking === 0, 'blocking_count' => $blocking, 'checks' => $checks, 'open_refund_comparison' => $comparisonRows];
|
||||
if (CLI::getOption('json') !== null) {
|
||||
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
return;
|
||||
}
|
||||
CLI::write('Withdrawal and inventory audit for ' . $year, 'yellow');
|
||||
foreach ($checks as $name => $check) {
|
||||
$color = $check['severity'] === 'blocking' ? 'red' : ($check['severity'] === 'warning' ? 'yellow' : 'green');
|
||||
CLI::write(strtoupper($check['severity']) . ' ' . $name . ': ' . $check['message'], $color);
|
||||
}
|
||||
CLI::write($blocking === 0 ? 'READY TO ENABLE' : 'DO NOT ENABLE — blockers remain', $blocking === 0 ? 'green' : 'red');
|
||||
}
|
||||
|
||||
private function check(string $severity, string $message): array
|
||||
{
|
||||
return ['severity' => $severity, 'message' => $message];
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,12 @@ $routes->get('administrator/financial-aid', 'Administrator\FinancialAidControlle
|
||||
$routes->get('administrator/financial-aid/(:num)', 'Administrator\FinancialAidController::show/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
|
||||
$routes->post('administrator/financial-aid/(:num)/approve', 'Administrator\FinancialAidController::approve/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
|
||||
$routes->post('administrator/financial-aid/(:num)/deny', 'Administrator\FinancialAidController::deny/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
|
||||
$withdrawalFinancialFilter = 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal';
|
||||
$routes->get('administrator/withdrawals/(:num)/review', 'Administrator\WithdrawalFinancialController::review/$1', ['filter' => $withdrawalFinancialFilter]);
|
||||
$routes->post('administrator/withdrawals/(:num)/recalculate', 'Administrator\WithdrawalFinancialController::recalculate/$1', ['filter' => $withdrawalFinancialFilter . ',update']);
|
||||
$routes->post('administrator/withdrawal-calculations/(:num)/confirm', 'Administrator\WithdrawalFinancialController::confirm/$1', ['filter' => $withdrawalFinancialFilter . ',update']);
|
||||
$routes->get('administrator/withdrawal-calculations/(:num)', 'Administrator\WithdrawalFinancialController::calculation/$1', ['filter' => $withdrawalFinancialFilter]);
|
||||
$routes->get('administrator/invoices/(:num)/withdrawal-calculations', 'Administrator\WithdrawalFinancialController::invoiceSummary/$1', ['filter' => $withdrawalFinancialFilter]);
|
||||
// API for report card meta (students, class sections, school years)
|
||||
$routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']);
|
||||
$routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']);
|
||||
@@ -944,6 +950,8 @@ $routes->group('inventory', ['filter' => 'auth:view_inventory|administrator|admi
|
||||
$routes->get('summary-all', 'View\InventoryController::summaryAll');
|
||||
$routes->get('adjust/(:num)', 'View\InventoryController::adjustForm/$1');
|
||||
$routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
$routes->get('book-prices', 'View\InventoryController::bookPrices');
|
||||
$routes->post('book-prices', 'View\InventoryController::updateBookPrices', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
$routes->get('books/distribute', 'View\InventoryController::teacherDistributeForm');
|
||||
$routes->post('books/distribute', 'View\InventoryController::teacherDistributeStore', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
$routes->get('classroom/audit/(:num)', 'View\InventoryController::auditClassroomForm/$1');
|
||||
|
||||
@@ -505,4 +505,21 @@ class Services extends BaseService
|
||||
model(\App\Models\StudentYearStatusModel::class)
|
||||
);
|
||||
}
|
||||
|
||||
public static function withdrawalFinancial(bool $getShared = true): \App\Services\WithdrawalFinancialService
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('withdrawalFinancial');
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
return new \App\Services\WithdrawalFinancialService(
|
||||
$db,
|
||||
new \App\Services\WithdrawalRefundCalculator(),
|
||||
new \App\Services\StudentBookIssueService($db),
|
||||
new \App\Libraries\InvoiceLedgerService(),
|
||||
new \App\Libraries\RefundEligibilityService()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Controllers\Administrator;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\SchoolYearModel;
|
||||
use App\Support\SchoolYear\SchoolYearStatus;
|
||||
use Throwable;
|
||||
@@ -43,6 +44,8 @@ class SchoolYearController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
$configurationModel = new ConfigurationModel();
|
||||
|
||||
return view('school_years/index', [
|
||||
'schoolYears' => $schoolYears,
|
||||
'statuses' => SchoolYearStatus::ALL,
|
||||
@@ -54,6 +57,7 @@ class SchoolYearController extends BaseController
|
||||
'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(),
|
||||
'yearVerification' => $this->verificationByYear($schoolYears),
|
||||
'schoolYearNamesById' => array_column($schoolYears, 'name', 'id'),
|
||||
'totalInstructionalWeeks' => $configurationModel->getConfigValueByKey('total_instructional_weeks'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Administrator;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use Throwable;
|
||||
|
||||
class WithdrawalFinancialController extends BaseController
|
||||
{
|
||||
public function review(int $enrollmentId)
|
||||
{
|
||||
try {
|
||||
$calculation = service('withdrawalFinancial')->latestForEnrollment($enrollmentId);
|
||||
if ($calculation === null || ($calculation['status'] ?? '') === 'superseded') {
|
||||
$calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId());
|
||||
} elseif ($this->hasStaleInstructionalWeeksBlocker($calculation)) {
|
||||
$calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId());
|
||||
}
|
||||
|
||||
return view('withdrawals/review', [
|
||||
'calculation' => $this->withPostingGateBlockers($calculation),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function recalculate(int $enrollmentId)
|
||||
{
|
||||
try {
|
||||
$calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId(), [
|
||||
'enrollment_date' => (string) $this->request->getPost('enrollment_date'),
|
||||
'withdrawal_request_date' => (string) $this->request->getPost('withdrawal_request_date'),
|
||||
'override_reason' => (string) $this->request->getPost('override_reason'),
|
||||
'legacy_invoice_confirmed' => (string) $this->request->getPost('legacy_invoice_confirmed') === '1',
|
||||
]);
|
||||
|
||||
return redirect()->to('/administrator/withdrawals/' . $enrollmentId . '/review')
|
||||
->with('success', 'Calculation preview version ' . (int) $calculation['version'] . ' saved.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function confirm(int $calculationId)
|
||||
{
|
||||
try {
|
||||
$calculation = service('withdrawalFinancial')->post($calculationId, $this->userId());
|
||||
|
||||
return redirect()->to('/administrator/withdrawal-calculations/' . $calculationId)
|
||||
->with('success', ((int) ($calculation['new_refund_request_cents'] ?? 0)) > 0
|
||||
? 'Withdrawal posted and the refund request was created.'
|
||||
: 'Withdrawal posted. No refund is due; the invoice balance was recalculated.');
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function calculation(int $calculationId)
|
||||
{
|
||||
try {
|
||||
$calculation = service('withdrawalFinancial')->details($calculationId);
|
||||
if ($this->hasStaleInstructionalWeeksBlocker($calculation)) {
|
||||
$calculation = service('withdrawalFinancial')->preview((int) $calculation['enrollment_id'], $this->userId());
|
||||
|
||||
return redirect()->to('/administrator/withdrawal-calculations/' . (int) $calculation['id'])
|
||||
->with('success', 'Calculation preview refreshed with the configured total instructional weeks.');
|
||||
}
|
||||
|
||||
return view('withdrawals/calculation', [
|
||||
'calculation' => $calculation,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function invoiceSummary(int $invoiceId)
|
||||
{
|
||||
try {
|
||||
return view('withdrawals/invoice_summary', [
|
||||
'calculations' => service('withdrawalFinancial')->calculationsForInvoice($invoiceId),
|
||||
'invoiceId' => $invoiceId,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function hasStaleInstructionalWeeksBlocker(array $calculation): bool
|
||||
{
|
||||
if (($calculation['status'] ?? '') !== 'requires_review' || ! empty($calculation['posted_at'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$blockers = array_map('strval', (array) ($calculation['blockers'] ?? []));
|
||||
|
||||
return in_array('Set a positive total_instructional_weeks value for this school year.', $blockers, true)
|
||||
|| in_array('Set a positive total_instructional_weeks value in configuration.', $blockers, true);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function withPostingGateBlockers(array $calculation): array
|
||||
{
|
||||
$blockers = array_values(array_filter(array_map('strval', (array) ($calculation['blockers'] ?? []))));
|
||||
foreach (service('withdrawalFinancial')->postingGateBlockers($calculation) as $blocker) {
|
||||
if (! in_array($blocker, $blockers, true)) {
|
||||
$blockers[] = $blocker;
|
||||
}
|
||||
}
|
||||
|
||||
$calculation['blockers'] = $blockers;
|
||||
if (isset($calculation['explanation']) && is_array($calculation['explanation'])) {
|
||||
$calculation['explanation']['blockers'] = $blockers;
|
||||
}
|
||||
|
||||
return $calculation;
|
||||
}
|
||||
|
||||
private function userId(): ?int
|
||||
{
|
||||
$id = session()->get('user_id');
|
||||
|
||||
return $id === null || $id === '' ? null : (int) $id;
|
||||
}
|
||||
}
|
||||
@@ -76,9 +76,14 @@ class InventoryController extends BaseController
|
||||
$selectedSem = $selectedSemRaw === null ? (string) $this->semester : trim((string) $selectedSemRaw);
|
||||
|
||||
$builder = $this->itemModel->where('type', $type);
|
||||
$this->applyInventoryMovementPeriodFilter($builder, 'inventory_items', $selectedYear, $selectedSem);
|
||||
if ($type !== 'book') {
|
||||
$this->applyInventoryMovementPeriodFilter($builder, 'inventory_items', $selectedYear, $selectedSem);
|
||||
}
|
||||
|
||||
$items = $builder->orderBy('name', 'ASC')->findAll();
|
||||
if ($type === 'book') {
|
||||
$items = $this->attachBookYearRows($items, $selectedYear);
|
||||
}
|
||||
$categories = $this->catModel->optionsForType($type);
|
||||
|
||||
// NEW: build UpdatedBy name map
|
||||
@@ -106,6 +111,9 @@ class InventoryController extends BaseController
|
||||
|
||||
// Load categories for THIS item type
|
||||
$categories = $this->catModel->optionsForType($item['type']);
|
||||
if (($item['type'] ?? '') === 'book') {
|
||||
$item = $this->withBookYearData($item);
|
||||
}
|
||||
|
||||
return view($this->viewForForm($item['type']), [
|
||||
'type' => $item['type'],
|
||||
@@ -135,6 +143,9 @@ class InventoryController extends BaseController
|
||||
if (!$this->itemModel->update($id, $data)) {
|
||||
return redirect()->back()->withInput()->with('error', implode(', ', $this->itemModel->errors()));
|
||||
}
|
||||
if (($item['type'] ?? '') === 'book') {
|
||||
$this->saveBookClassAssignments($id);
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('inventory/' . $item['type']))->with('success', 'Item updated.');
|
||||
}
|
||||
@@ -148,6 +159,15 @@ class InventoryController extends BaseController
|
||||
$itemId = $this->itemModel->insert($itemData, true);
|
||||
if ($itemId) {
|
||||
$initialQty = (int) ($itemData['quantity'] ?? 0);
|
||||
if (($itemData['type'] ?? '') === 'book') {
|
||||
$this->saveBookClassAssignments((int) $itemId);
|
||||
if ($initialQty !== 0) {
|
||||
$this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
|
||||
} else {
|
||||
$this->recalcQuantity($itemId);
|
||||
}
|
||||
return redirect()->to(site_url("inventory/{$itemData['type']}"))->with('success', 'Item added.');
|
||||
}
|
||||
if ($initialQty !== 0) {
|
||||
$this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
|
||||
} else {
|
||||
@@ -341,6 +361,13 @@ class InventoryController extends BaseController
|
||||
if ($type === 'book') {
|
||||
$base['isbn'] = $this->post('isbn');
|
||||
$base['edition'] = $this->post('edition');
|
||||
$base['author'] = $this->post('author');
|
||||
$base['is_active'] = 1;
|
||||
$isbn = preg_replace('/[^0-9X]/i', '', strtoupper((string) $base['isbn']));
|
||||
$edition = strtolower(trim((string) $base['edition']));
|
||||
$sku = strtolower(trim((string) $base['sku']));
|
||||
$base['isbn_edition_key'] = $isbn !== '' ? $isbn . '|' . $edition : null;
|
||||
$base['sku_normalized'] = $sku !== '' ? $sku : null;
|
||||
} else {
|
||||
$base['isbn'] = $base['edition'] = null;
|
||||
}
|
||||
@@ -348,6 +375,191 @@ class InventoryController extends BaseController
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function withBookYearData(array $item): array
|
||||
{
|
||||
$itemYear = $this->bookItemYear((int) $item['id'], false);
|
||||
if ($itemYear !== null) {
|
||||
$item['charge_price'] = number_format(((int) ($itemYear['charge_price_cents'] ?? 0)) / 100, 2, '.', '');
|
||||
$item['price_confirmed'] = (int) ($itemYear['price_confirmed'] ?? 0);
|
||||
$item['item_year_id'] = (int) $itemYear['id'];
|
||||
$item['opening_quantity'] = (int) ($itemYear['opening_quantity'] ?? 0);
|
||||
}
|
||||
$item['classes'] = array_map(
|
||||
'intval',
|
||||
array_column(
|
||||
$this->db->table('inventory_book_class_assignments')
|
||||
->select('class_number')
|
||||
->where('inventory_item_id', (int) $item['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->orderBy('class_number', 'ASC')
|
||||
->get()->getResultArray(),
|
||||
'class_number'
|
||||
)
|
||||
);
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function attachBookYearRows(array $items, string $schoolYear): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map(static fn (array $item): int => (int) ($item['id'] ?? 0), $items)));
|
||||
if ($ids === []) {
|
||||
return $items;
|
||||
}
|
||||
$rows = $this->db->table('inventory_item_years')
|
||||
->whereIn('inventory_item_id', $ids)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getResultArray();
|
||||
$byItem = [];
|
||||
foreach ($rows as $row) {
|
||||
$byItem[(int) $row['inventory_item_id']] = $row;
|
||||
}
|
||||
foreach ($items as &$item) {
|
||||
$row = $byItem[(int) ($item['id'] ?? 0)] ?? null;
|
||||
if ($row !== null) {
|
||||
$item['charge_price_cents'] = (int) ($row['charge_price_cents'] ?? 0);
|
||||
$item['price_confirmed'] = (int) ($row['price_confirmed'] ?? 0);
|
||||
$item['opening_quantity'] = (int) ($row['opening_quantity'] ?? 0);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function bookPrices()
|
||||
{
|
||||
$books = $this->itemModel
|
||||
->where('type', 'book')
|
||||
->orderBy('name', 'ASC')
|
||||
->findAll();
|
||||
$books = $this->attachBookYearRows($books, (string) $this->schoolYear);
|
||||
|
||||
return view('inventory/book/prices', [
|
||||
'books' => $books,
|
||||
'categories' => $this->catModel->optionsForType('book'),
|
||||
'schoolYear' => $this->schoolYear,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateBookPrices()
|
||||
{
|
||||
$prices = (array) $this->request->getPost('prices');
|
||||
$confirmed = (array) $this->request->getPost('confirmed');
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', array_keys($prices)))));
|
||||
|
||||
if ($ids === []) {
|
||||
return redirect()->back()->with('warning', 'No book prices were submitted.');
|
||||
}
|
||||
|
||||
$books = $this->itemModel
|
||||
->where('type', 'book')
|
||||
->whereIn('id', $ids)
|
||||
->findAll();
|
||||
$booksById = [];
|
||||
foreach ($books as $book) {
|
||||
$booksById[(int) $book['id']] = $book;
|
||||
}
|
||||
|
||||
$year = $this->currentSchoolYearRow();
|
||||
$updated = 0;
|
||||
|
||||
try {
|
||||
$this->db->transStart();
|
||||
foreach ($ids as $id) {
|
||||
if (! isset($booksById[$id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isConfirmed = isset($confirmed[$id]) && (string) $confirmed[$id] === '1';
|
||||
$priceCents = $this->moneyValueToCents($prices[$id] ?? '');
|
||||
if ($isConfirmed && $priceCents <= 0) {
|
||||
throw new \InvalidArgumentException('Confirmed book prices must be greater than $0.00.');
|
||||
}
|
||||
|
||||
$itemYear = $this->bookItemYear($id, false);
|
||||
$payload = [
|
||||
'inventory_item_id' => $id,
|
||||
'school_year_id' => (int) ($year['id'] ?? 0) ?: null,
|
||||
'school_year' => $this->schoolYear,
|
||||
'charge_price_cents' => $priceCents,
|
||||
'currency' => 'USD',
|
||||
'price_confirmed' => $isConfirmed ? 1 : 0,
|
||||
'status' => 'open',
|
||||
'updated_by' => $this->currentUserId(),
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($itemYear === null) {
|
||||
$payload['opening_quantity'] = (int) ($booksById[$id]['quantity'] ?? 0);
|
||||
$payload['created_by'] = $this->currentUserId();
|
||||
$payload['created_at'] = utc_now();
|
||||
$this->db->table('inventory_item_years')->insert($payload);
|
||||
} else {
|
||||
$this->db->table('inventory_item_years')->where('id', (int) $itemYear['id'])->update($payload);
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new \RuntimeException('Book prices could not be saved.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('inventory/book-prices'))->with('success', 'Saved prices for ' . $updated . ' book(s).');
|
||||
}
|
||||
|
||||
private function saveBookClassAssignments(int $itemId): void
|
||||
{
|
||||
$classes = array_values(array_unique(array_filter(
|
||||
array_map('intval', (array) $this->request->getPost('classes')),
|
||||
static fn (int $class): bool => $class >= 1 && $class <= 13
|
||||
)));
|
||||
$this->db->table('inventory_book_class_assignments')
|
||||
->where('inventory_item_id', $itemId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->delete();
|
||||
foreach ($classes as $classNumber) {
|
||||
$this->db->table('inventory_book_class_assignments')->insert([
|
||||
'inventory_item_id' => $itemId,
|
||||
'school_year' => $this->schoolYear,
|
||||
'class_number' => $classNumber,
|
||||
'created_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function moneyValueToCents($value): int
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return 0;
|
||||
}
|
||||
if (! preg_match('/^\d+(?:\.\d{1,2})?$/', $raw)) {
|
||||
throw new \InvalidArgumentException('Book charge price must be a valid dollar amount with at most two decimals.');
|
||||
}
|
||||
return (int) round(((float) $raw) * 100);
|
||||
}
|
||||
|
||||
private function currentSchoolYearRow(): ?array
|
||||
{
|
||||
return $this->db->table('school_years')
|
||||
->where('name', $this->schoolYear)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
}
|
||||
|
||||
private function bookItemYear(int $itemId, bool $forUpdate = false): ?array
|
||||
{
|
||||
$sql = 'SELECT * FROM inventory_item_years WHERE inventory_item_id = ? AND school_year = ? LIMIT 1';
|
||||
if ($forUpdate) {
|
||||
$sql .= ' FOR UPDATE';
|
||||
}
|
||||
return $this->db->query($sql, [$itemId, $this->schoolYear])->getRowArray();
|
||||
}
|
||||
|
||||
public function auditClassroomForm(int $itemId)
|
||||
{
|
||||
$item = $this->itemModel->find($itemId);
|
||||
@@ -742,12 +954,17 @@ class InventoryController extends BaseController
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $db->table('inventory_items i')
|
||||
->select('i.id,i.name,i.isbn,i.edition,i.quantity,i.category_id,i.type,
|
||||
iy.id AS item_year_id, iy.charge_price_cents, iy.price_confirmed,
|
||||
c.name AS category_name, c.grade_min, c.grade_max')
|
||||
->join('inventory_categories c', 'c.id = i.category_id', 'left')
|
||||
->join('inventory_item_years iy', 'iy.inventory_item_id = i.id AND iy.school_year = ' . $db->escape($this->schoolYear), 'left')
|
||||
->join('inventory_book_class_assignments bca', 'bca.inventory_item_id = i.id AND bca.school_year = ' . $db->escape($this->schoolYear), 'left')
|
||||
->where('i.type', 'book');
|
||||
|
||||
if (!empty($classID)) {
|
||||
$builder->groupStart()
|
||||
->where('bca.class_number', (int) $classID)
|
||||
->orGroupStart()
|
||||
// Case A: both bounds set and classID between [min..max]
|
||||
->groupStart()
|
||||
->where('c.grade_min IS NOT NULL', null, false)
|
||||
@@ -769,10 +986,11 @@ class InventoryController extends BaseController
|
||||
->where('c.grade_min', null) // IS NULL
|
||||
->where('c.grade_max', (int)$classID) // =
|
||||
->groupEnd()
|
||||
->groupEnd()
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$rows = $builder->orderBy('i.name', 'ASC')->get()->getResultArray();
|
||||
$rows = $builder->groupBy('i.id')->orderBy('i.name', 'ASC')->get()->getResultArray();
|
||||
|
||||
// Build CATEGORY groups keyed by category_id
|
||||
$labelFor = static function (?array $r): string {
|
||||
@@ -803,6 +1021,8 @@ class InventoryController extends BaseController
|
||||
'id' => (int)$r['id'],
|
||||
'display' => $display,
|
||||
'quantity' => (int)($r['quantity'] ?? 0),
|
||||
'charge_price_cents' => (int)($r['charge_price_cents'] ?? 0),
|
||||
'price_confirmed' => (int)($r['price_confirmed'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -819,16 +1039,16 @@ class InventoryController extends BaseController
|
||||
// 7) History map: how many of the selected book each student already received this school year
|
||||
$already = [];
|
||||
if ($itemId && $classSectionId) {
|
||||
$rowsHist = $this->movModel
|
||||
->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty')
|
||||
->where([
|
||||
'item_id' => $itemId,
|
||||
'movement_type' => 'distribution',
|
||||
'class_section_id' => $classSectionId,
|
||||
'school_year' => $ctx['schoolYear'],
|
||||
])
|
||||
$itemYear = $this->bookItemYear($itemId, false);
|
||||
$rowsHist = $this->db->table('student_book_issues')
|
||||
->select('student_id, SUM(quantity) AS qty')
|
||||
->where('inventory_item_id', $itemId)
|
||||
->where('inventory_item_year_id', (int) ($itemYear['id'] ?? 0))
|
||||
->where('class_section_id', $classSectionId)
|
||||
->where('school_year', $ctx['schoolYear'])
|
||||
->where('status', 'issued')
|
||||
->groupBy('student_id')
|
||||
->findAll();
|
||||
->get()->getResultArray();
|
||||
|
||||
foreach ($rowsHist as $r) {
|
||||
$sid = (int) ($r['student_id'] ?? 0);
|
||||
@@ -838,11 +1058,16 @@ class InventoryController extends BaseController
|
||||
|
||||
// 8) On-hand for the selected book (to show live available stock)
|
||||
$onHand = 0;
|
||||
$unitChargeCents = 0;
|
||||
$priceConfirmed = 0;
|
||||
if ($itemId) {
|
||||
$book = $this->itemModel->find($itemId);
|
||||
if ($book && ($book['type'] ?? '') === 'book') {
|
||||
$onHand = (int) ($book['quantity'] ?? 0);
|
||||
}
|
||||
$itemYear = $this->bookItemYear($itemId, false);
|
||||
$unitChargeCents = (int) ($itemYear['charge_price_cents'] ?? 0);
|
||||
$priceConfirmed = (int) ($itemYear['price_confirmed'] ?? 0);
|
||||
}
|
||||
|
||||
// 9) Render view
|
||||
@@ -858,6 +1083,8 @@ class InventoryController extends BaseController
|
||||
'students' => $students,
|
||||
'already' => $already,
|
||||
'onHand' => $onHand,
|
||||
'unitChargeCents' => $unitChargeCents,
|
||||
'priceConfirmed' => $priceConfirmed,
|
||||
|
||||
'schoolYear' => $ctx['schoolYear'],
|
||||
'semester' => $ctx['semester'],
|
||||
@@ -910,50 +1137,34 @@ class InventoryController extends BaseController
|
||||
$selectedIds = array_values(array_unique(array_map('intval', $selectedIds)));
|
||||
$selectedIds = array_values(array_filter($selectedIds, fn($sid) => isset($validIds[$sid])));
|
||||
|
||||
// Already distributed this year for this class/book
|
||||
$already = [];
|
||||
$rows = $this->movModel
|
||||
->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty')
|
||||
->where([
|
||||
'item_id' => $itemId,
|
||||
'movement_type' => 'distribution',
|
||||
'class_section_id' => $classSectionId,
|
||||
'school_year' => $ctx['schoolYear'],
|
||||
])->groupBy('student_id')->findAll();
|
||||
foreach ($rows as $r) {
|
||||
$sid = (int)($r['student_id'] ?? 0);
|
||||
if ($sid) $already[$sid] = (int)$r['qty'];
|
||||
try {
|
||||
$year = $this->currentSchoolYearRow();
|
||||
$result = (new \App\Services\StudentBookIssueService($this->db))->distributeBatch(
|
||||
$itemId,
|
||||
$selectedIds,
|
||||
$classSectionId,
|
||||
(int) ($year['id'] ?? 0),
|
||||
(string) $ctx['schoolYear'],
|
||||
$this->currentUserId(),
|
||||
$note,
|
||||
null,
|
||||
(string) $this->request->getPost('idempotency_key')
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()
|
||||
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
||||
->withInput()
|
||||
->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
// Only assign to students who don't already have it
|
||||
$toGive = [];
|
||||
foreach ($selectedIds as $sid) {
|
||||
if (($already[$sid] ?? 0) < 1) $toGive[] = $sid;
|
||||
}
|
||||
|
||||
// Stock check
|
||||
$needed = count($toGive);
|
||||
$onHand = (int)$book['quantity'];
|
||||
if ($needed > $onHand) {
|
||||
return redirect()->back()->withInput()->with('error', 'Not enough stock: need ' . $needed . ', on hand ' . $onHand . '.');
|
||||
}
|
||||
|
||||
// Record one movement per student
|
||||
$okCount = 0;
|
||||
foreach ($toGive as $sid) {
|
||||
$ok = $this->recordMovement($itemId, -1, 'distribution', 'Teacher distribution', $note, $classSectionId, $sid);
|
||||
if ($ok) $okCount++;
|
||||
}
|
||||
|
||||
if ($okCount === 0) {
|
||||
if ((int) ($result['issued'] ?? 0) === 0) {
|
||||
return redirect()
|
||||
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
||||
->with('warning', 'No changes (everyone selected already has this book).');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId))
|
||||
->with('success', 'Distributed to ' . $okCount . ' student(s).');
|
||||
->with('success', 'Distributed to ' . (int) $result['issued'] . ' student(s).');
|
||||
}
|
||||
|
||||
|
||||
@@ -1105,7 +1316,9 @@ class InventoryController extends BaseController
|
||||
|
||||
public function create(string $type = 'classroom')
|
||||
{
|
||||
$type = $this->normalizeType($type);
|
||||
$categories = $this->catModel->optionsForType($type);
|
||||
|
||||
return view("inventory/{$type}/form", [
|
||||
'type' => $type,
|
||||
'item' => [], // important for create
|
||||
@@ -1347,6 +1560,10 @@ class InventoryController extends BaseController
|
||||
$userId = (int) (session('user_id') ?? 0);
|
||||
$itemId = (int) $this->request->getPost('item_id');
|
||||
$movementType = (string) $this->request->getPost('movement_type');
|
||||
if ($movementType === 'distribution') {
|
||||
return redirect()->back()->withInput()->with('status', 'error')
|
||||
->with('message', 'Student book distributions must be created from the book distribution workflow.');
|
||||
}
|
||||
|
||||
$item = $this->itemModel->find($itemId);
|
||||
if (!$item) {
|
||||
@@ -1404,6 +1621,10 @@ class InventoryController extends BaseController
|
||||
return redirect()->to(site_url('inventory/movements'))
|
||||
->with('status', 'error')->with('message', 'Movement not found.');
|
||||
}
|
||||
if ($this->isProtectedMovement($movement)) {
|
||||
return redirect()->to(site_url('inventory/movements'))
|
||||
->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.');
|
||||
}
|
||||
|
||||
// enrich for display (names)
|
||||
$movement = $this->hydrateNames($movement);
|
||||
@@ -1449,8 +1670,16 @@ class InventoryController extends BaseController
|
||||
return redirect()->to(site_url('inventory/movements'))
|
||||
->with('status', 'error')->with('message', 'Movement not found.');
|
||||
}
|
||||
if ($this->isProtectedMovement($existing)) {
|
||||
return redirect()->to(site_url('inventory/movements'))
|
||||
->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.');
|
||||
}
|
||||
|
||||
$movementType = (string) $this->request->getPost('movement_type');
|
||||
if ($movementType === 'distribution') {
|
||||
return redirect()->back()->withInput()->with('status', 'error')
|
||||
->with('message', 'Student book distributions must be created from the book distribution workflow.');
|
||||
}
|
||||
$qtyChange = $this->normalizeMovementQty($movementType, (int) $this->request->getPost('qty_change'));
|
||||
|
||||
$itemId = (int) ($existing['item_id'] ?? 0);
|
||||
@@ -1504,10 +1733,13 @@ class InventoryController extends BaseController
|
||||
{
|
||||
// Optional: authorization checks here
|
||||
log_message('info', 'Inventory: delete one attempt', ['id' => $id, 'user' => (int)(session('user_id') ?? 0)]);
|
||||
$movement = $this->db->table('inventory_movements')->select('id, item_id')->where('id', $id)->get()->getRowArray();
|
||||
$movement = $this->db->table('inventory_movements')->select('*')->where('id', $id)->get()->getRowArray();
|
||||
if (!$movement) {
|
||||
return redirect()->back()->with('status', 'error')->with('message', 'Movement not found.');
|
||||
}
|
||||
if ($this->isProtectedMovement($movement)) {
|
||||
return redirect()->back()->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.');
|
||||
}
|
||||
|
||||
$ok = $this->db->table('inventory_movements')->where('id', $id)->delete();
|
||||
log_message('info', 'Inventory: delete one result', ['id' => $id, 'ok' => (bool)$ok, 'affected' => $this->db->affectedRows()]);
|
||||
@@ -1577,6 +1809,16 @@ class InventoryController extends BaseController
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isProtectedMovement(array $movement): bool
|
||||
{
|
||||
$type = (string) ($movement['movement_type'] ?? '');
|
||||
$sourceType = (string) ($movement['source_type'] ?? '');
|
||||
return $type === 'distribution'
|
||||
|| in_array($sourceType, ['student_book_issue', 'student_book_issue_reversal'], true)
|
||||
|| (int) ($movement['source_id'] ?? 0) > 0
|
||||
|| (int) ($movement['reversal_of_movement_id'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Hydrate display names for a row (used in edit) */
|
||||
private function hydrateNames(array $m): array
|
||||
{
|
||||
@@ -1682,13 +1924,18 @@ class InventoryController extends BaseController
|
||||
}
|
||||
|
||||
log_message('info', 'Inventory: bulk delete attempt', ['count' => count($ids), 'ids' => $ids, 'user' => (int)(session('user_id') ?? 0)]);
|
||||
$itemIds = $this->db->table('inventory_movements')
|
||||
->select('item_id')
|
||||
$movements = $this->db->table('inventory_movements')
|
||||
->select('*')
|
||||
->whereIn('id', $ids)
|
||||
->get()->getResultArray();
|
||||
foreach ($movements as $movement) {
|
||||
if ($this->isProtectedMovement($movement)) {
|
||||
return redirect()->back()->with('status','error')->with('message','Bulk delete contains issue-backed or distribution movement #' . (int) $movement['id'] . '. Use correction reversal instead.');
|
||||
}
|
||||
}
|
||||
$itemIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn($r) => (int) ($r['item_id'] ?? 0),
|
||||
$itemIds
|
||||
$movements
|
||||
))));
|
||||
|
||||
$ok = $this->db->table('inventory_movements')->whereIn('id', $ids)->delete();
|
||||
|
||||
@@ -18,8 +18,8 @@ use \App\Models\EnrollmentModel;
|
||||
use \App\Models\EventChargesModel;
|
||||
use \App\Models\EventModel;
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Services\SchoolIdService;
|
||||
use App\Services\FeeCalculationService;
|
||||
use App\Services\SchoolIdService;
|
||||
use App\Services\PhoneFormatterService;
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use App\Support\Enrollment\EnrollmentEligibility;
|
||||
@@ -441,8 +441,6 @@ class ParentController extends BaseController
|
||||
|
||||
public function enrollClassesHandler()
|
||||
{
|
||||
$refundService = new FeeCalculationService();
|
||||
|
||||
// Retrieve enrollment and withdrawal data from the POST request
|
||||
$enroll = $this->request->getPost('enroll'); // Selected students for enrollment
|
||||
$withdraw = $this->request->getPost('withdraw'); // Selected students for withdrawal
|
||||
@@ -697,97 +695,14 @@ class ParentController extends BaseController
|
||||
->getRowArray();
|
||||
|
||||
if ($enrollment !== null) {
|
||||
// Update enrollment as withdrawn
|
||||
$enrollmentStatusService = \Config\Services::enrollmentStatus(false);
|
||||
$enrollmentStatusService->upsertStatus([
|
||||
'id' => (int) $enrollment['id'],
|
||||
'student_id' => (int) $studentId,
|
||||
'parent_id' => (int) ($enrollment['parent_id'] ?? $parentId),
|
||||
'school_year' => (string) $this->schoolYear,
|
||||
'semester' => (string) ($enrollment['semester'] ?? $this->semester),
|
||||
'withdrawal_date' => local_date(utc_now(), 'Y-m-d'),
|
||||
'enrollment_status' => 'withdraw under review', // Withdrawal needs review
|
||||
'updated_at' => utc_now()
|
||||
], (int) $parentId, 'parent_withdrawal_requested');
|
||||
$withdrawalRequestDate = local_date(utc_now(), 'Y-m-d');
|
||||
service('withdrawalFinancial')->requestWithdrawal(
|
||||
(int) $enrollment['id'],
|
||||
$withdrawalRequestDate,
|
||||
(int) $parentId
|
||||
);
|
||||
log_message('info', "Student ID $studentId has been withdrawn from enrollment ID {$enrollment['id']}.");
|
||||
$withdrawalResultMessages[] = 'Student ID ' . $studentId . ': withdraw under review';
|
||||
|
||||
// === Trigger refund process ===
|
||||
// Find the related invoice (you may need to adjust this based on your DB structure)
|
||||
$invoice = $this->db->table('invoices')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($invoice !== null) {
|
||||
$invoiceId = $invoice['id'];
|
||||
$studentsForRefund = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
$refundAmount = $refundService->calculateRefund($studentsForRefund, (int) $parentId);
|
||||
$refundCents = max(0, (int) round($refundAmount * 100));
|
||||
|
||||
$refundTable = $this->db->table('refunds');
|
||||
|
||||
$existingRefund = $refundTable
|
||||
->where('parent_id', $parentId)
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->whereIn('status', ['Pending', 'Approved', 'Partial', 'pending', 'requested', 'approved', 'partial', 'partially_paid'])
|
||||
->get()
|
||||
->getRow();
|
||||
|
||||
if ($existingRefund) {
|
||||
// Only update the fields that should change
|
||||
$updateData = [
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => $refundCents,
|
||||
'currency' => 'USD',
|
||||
'reason' => 'Withdrawal under review for student ID ' . $studentId,
|
||||
'note' => null,
|
||||
'request' => 'tuition',
|
||||
'source_type' => 'tuition_withdrawal',
|
||||
'source_id' => (int) $invoiceId,
|
||||
'status' => 'Pending',
|
||||
'updated_by' => session()->get('user_id'), // optionally track updates
|
||||
// Add other fields if *and only if* they must be changed
|
||||
];
|
||||
|
||||
$refundTable
|
||||
->where('id', $existingRefund->id)
|
||||
->update($this->filterPayloadByTableColumns($updateData, 'refunds'));
|
||||
|
||||
log_message('info', "Refund record updated for invoice ID {$invoiceId}, student ID {$studentId}.");
|
||||
} else {
|
||||
// Only set these once, for new entries
|
||||
$insertData = [
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => $refundCents,
|
||||
'approved_amount_cents' => null,
|
||||
'currency' => 'USD',
|
||||
'requested_at' => utc_now(),
|
||||
'school_year' => $this->schoolYear,
|
||||
'status' => 'Pending',
|
||||
'reason' => 'Withdrawal under review for student ID ' . $studentId,
|
||||
'request' => 'tuition',
|
||||
'source_type' => 'tuition_withdrawal',
|
||||
'source_id' => (int) $invoiceId,
|
||||
'semester' => $this->semester,
|
||||
'refund_paid_amount' => 0.0,
|
||||
];
|
||||
|
||||
$refundTable->insert($this->filterPayloadByTableColumns($insertData, 'refunds'));
|
||||
|
||||
log_message('info', "Refund record created for invoice ID {$invoiceId}, student ID {$studentId}.");
|
||||
}
|
||||
} else {
|
||||
log_message('error', "No invoice found for parent ID {$parentId}, student ID {$studentId}.");
|
||||
}
|
||||
} else {
|
||||
log_message('error', "No active enrollment found for student ID $studentId.");
|
||||
$withdrawalErrors[] = 'Student ID ' . $studentId . ': no active enrollment was found.';
|
||||
|
||||
@@ -16,7 +16,7 @@ use App\Models\PaymentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Services\FeeCalculationService;
|
||||
use App\Services\EnrollmentStatusService;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
class RefundController extends BaseController
|
||||
@@ -32,7 +32,6 @@ class RefundController extends BaseController
|
||||
protected ParentLedgerService $parentLedgerService;
|
||||
protected RefundEligibilityService $refundEligibilityService;
|
||||
protected FinancialAttachmentService $financialAttachmentService;
|
||||
protected FeeCalculationService $feeCalculationService;
|
||||
protected $db;
|
||||
|
||||
// Allowed request types (mapped to your `refunds.request` column)
|
||||
@@ -54,7 +53,6 @@ class RefundController extends BaseController
|
||||
$this->parentLedgerService = new ParentLedgerService();
|
||||
$this->refundEligibilityService = new RefundEligibilityService();
|
||||
$this->financialAttachmentService = new FinancialAttachmentService();
|
||||
$this->feeCalculationService = new FeeCalculationService();
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
@@ -723,6 +721,10 @@ class RefundController extends BaseController
|
||||
throw new FinancialPersistenceException('REFUND_PROJECTION_UPDATE_FAILED', $this->refundModel->errors());
|
||||
}
|
||||
|
||||
if (!$isOnline && $newStatus === FinancialStatus::REFUND_PAID) {
|
||||
$this->markWithdrawalEnrollmentComplete($lockedRefund);
|
||||
}
|
||||
|
||||
if (!$isOnline && $affectedInvoiceId > 0) {
|
||||
$this->invoiceLedgerService->recalculateInvoice($affectedInvoiceId);
|
||||
}
|
||||
@@ -774,6 +776,49 @@ class RefundController extends BaseController
|
||||
return $this->response->setJSON($payload);
|
||||
}
|
||||
|
||||
private function markWithdrawalEnrollmentComplete(array $refund): void
|
||||
{
|
||||
if ((string)($refund['source_type'] ?? '') !== 'tuition_withdrawal') {
|
||||
return;
|
||||
}
|
||||
|
||||
$calculationId = (int)($refund['withdrawal_calculation_id'] ?? 0);
|
||||
if ($calculationId <= 0 || ! $this->db->tableExists('withdrawal_financial_calculations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$calculation = $this->db->query(
|
||||
'SELECT enrollment_id FROM withdrawal_financial_calculations WHERE id = ? FOR UPDATE',
|
||||
[$calculationId]
|
||||
)->getRowArray();
|
||||
$enrollmentId = (int)($calculation['enrollment_id'] ?? 0);
|
||||
if ($enrollmentId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$enrollment = $this->db->query(
|
||||
'SELECT * FROM enrollments WHERE id = ? FOR UPDATE',
|
||||
[$enrollmentId]
|
||||
)->getRowArray();
|
||||
if (! $enrollment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statusService = new EnrollmentStatusService($this->db);
|
||||
$statusService->upsertStatus([
|
||||
'id' => (int)$enrollment['id'],
|
||||
'student_id' => (int)$enrollment['student_id'],
|
||||
'parent_id' => (int)$enrollment['parent_id'],
|
||||
'school_year' => (string)$enrollment['school_year'],
|
||||
'semester' => (string)($enrollment['semester'] ?? ''),
|
||||
'enrollment_status' => 'withdrawn',
|
||||
'admission_status' => (string)($enrollment['admission_status'] ?? 'pending'),
|
||||
'is_withdrawn' => 1,
|
||||
'withdrawal_date' => $enrollment['withdrawal_date'] ?? null,
|
||||
'updated_at' => utc_now(),
|
||||
], (int)(session()->get('user_id') ?? 0) ?: null, 'withdrawal_refund_paid');
|
||||
}
|
||||
|
||||
public function reversePayout(?int $routePayoutId = null)
|
||||
{
|
||||
$payoutId = (int)($routePayoutId ?: $this->request->getPost('payout_id'));
|
||||
@@ -933,6 +978,7 @@ class RefundController extends BaseController
|
||||
{
|
||||
// Repair legacy withdrawal placeholders only; overpayment recalculation remains explicit.
|
||||
$this->repairPendingWithdrawalRefunds();
|
||||
$withdrawalReviews = $this->pendingWithdrawalReviews();
|
||||
|
||||
// 2) List refunds with joins
|
||||
$refunds = $this->refundModel
|
||||
@@ -987,10 +1033,41 @@ class RefundController extends BaseController
|
||||
|
||||
return view('refunds/list', [
|
||||
'refunds' => $refunds,
|
||||
'parentId' => $parentId
|
||||
'parentId' => $parentId,
|
||||
'withdrawalReviews' => $withdrawalReviews,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function pendingWithdrawalReviews(): array
|
||||
{
|
||||
try {
|
||||
if (! $this->db->tableExists('withdrawal_financial_calculations')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->db->table('withdrawal_financial_calculations wfc')
|
||||
->select('wfc.*,
|
||||
i.invoice_number,
|
||||
u.firstname AS parent_firstname,
|
||||
u.lastname AS parent_lastname,
|
||||
s.firstname AS student_firstname,
|
||||
s.lastname AS student_lastname')
|
||||
->join('invoices i', 'wfc.invoice_id = i.id', 'left')
|
||||
->join('users u', 'wfc.parent_id = u.id', 'left')
|
||||
->join('students s', 'wfc.student_id = s.id', 'left')
|
||||
->whereIn('wfc.status', ['preview', 'requires_review'])
|
||||
->where('wfc.posted_at', null)
|
||||
->orderBy('wfc.calculated_at', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Pending withdrawal review lookup failed: ' . $e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function repairPendingWithdrawalRefunds(): void
|
||||
{
|
||||
try {
|
||||
@@ -1021,24 +1098,48 @@ class RefundController extends BaseController
|
||||
continue;
|
||||
}
|
||||
|
||||
$students = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
$refundAmount = $this->feeCalculationService->calculateRefund($students, $parentId);
|
||||
$refundCents = max(0, (int)round($refundAmount * 100));
|
||||
|
||||
$this->refundModel->update((int)$refund['id'], [
|
||||
'invoice_id' => (int)$invoice['id'],
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => $refundCents,
|
||||
'currency' => 'USD',
|
||||
'request' => 'tuition',
|
||||
'source_type' => 'tuition_withdrawal',
|
||||
'source_id' => (int)$invoice['id'],
|
||||
'reconciliation_status' => 'requires_review',
|
||||
'reconciliation_reason' => 'Legacy pending withdrawal refund requires recalculation through the withdrawal financial review workflow.',
|
||||
'reconciliation_required_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('withdrawal_financial_calculations')) {
|
||||
$postedRows = $this->db->table('refunds r')
|
||||
->select('r.id, r.refund_amount, r.requested_amount_cents, wfc.posted_at, wfc.posted_by')
|
||||
->join('withdrawal_financial_calculations wfc', 'wfc.id = r.withdrawal_calculation_id', 'inner')
|
||||
->where('r.source_type', 'tuition_withdrawal')
|
||||
->whereIn('r.status', ['Pending', 'pending', 'requested'])
|
||||
->where('wfc.status', 'posted')
|
||||
->where('wfc.posted_at IS NOT NULL', null, false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($postedRows as $refund) {
|
||||
$amountCents = (int)($refund['requested_amount_cents'] ?? 0);
|
||||
if ($amountCents <= 0) {
|
||||
$amountCents = (int)round(((float)($refund['refund_amount'] ?? 0)) * 100);
|
||||
}
|
||||
if ($amountCents <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->refundModel->update((int)$refund['id'], [
|
||||
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_APPROVED),
|
||||
'approved_amount_cents' => $amountCents,
|
||||
'approved_at' => $refund['posted_at'] ?? utc_now(),
|
||||
'approved_by' => !empty($refund['posted_by']) ? (int)$refund['posted_by'] : null,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Pending withdrawal refund repair failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateWithdrawalRefundInventoryFoundation extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->addSchoolYearPolicyColumns();
|
||||
$this->addInventoryCatalogColumns();
|
||||
$this->createInventoryItemYears();
|
||||
$this->extendInventoryMovements();
|
||||
$this->protectInventoryHistoryForeignKey();
|
||||
$this->createBookClassAssignments();
|
||||
$this->createStudentBookIssues();
|
||||
$this->createWithdrawalCalculations();
|
||||
$this->extendRefunds();
|
||||
$this->seedActiveYearPolicy();
|
||||
$this->seedActiveBookYears();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('withdrawal_financial_calculations', true);
|
||||
$this->forge->dropTable('student_book_issues', true);
|
||||
$this->forge->dropTable('inventory_book_class_assignments', true);
|
||||
|
||||
if ($this->db->tableExists('inventory_movements')) {
|
||||
foreach ([
|
||||
'item_year_id', 'idempotency_key', 'reversal_of_movement_id', 'status',
|
||||
'reversed_at', 'reversed_by', 'source_type', 'source_id',
|
||||
] as $column) {
|
||||
if ($this->db->fieldExists($column, 'inventory_movements')) {
|
||||
$this->forge->dropColumn('inventory_movements', $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->forge->dropTable('inventory_item_years', true);
|
||||
$this->restoreInventoryHistoryForeignKey();
|
||||
|
||||
if ($this->db->tableExists('inventory_items')) {
|
||||
foreach (['author', 'is_active', 'retired_at', 'isbn_edition_key', 'sku_normalized'] as $column) {
|
||||
if ($this->db->fieldExists($column, 'inventory_items')) {
|
||||
$this->forge->dropColumn('inventory_items', $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('school_years')) {
|
||||
foreach (['total_instructional_weeks', 'annual_fee_includes_books', 'withdrawal_policy_version'] as $column) {
|
||||
if ($this->db->fieldExists($column, 'school_years')) {
|
||||
$this->forge->dropColumn('school_years', $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('refunds') && $this->db->fieldExists('withdrawal_calculation_id', 'refunds')) {
|
||||
$this->forge->dropColumn('refunds', 'withdrawal_calculation_id');
|
||||
}
|
||||
}
|
||||
|
||||
private function addSchoolYearPolicyColumns(): void
|
||||
{
|
||||
if (! $this->db->tableExists('school_years')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
if (! $this->db->fieldExists('total_instructional_weeks', 'school_years')) {
|
||||
$columns['total_instructional_weeks'] = [
|
||||
'type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => true,
|
||||
];
|
||||
}
|
||||
if (! $this->db->fieldExists('annual_fee_includes_books', 'school_years')) {
|
||||
$columns['annual_fee_includes_books'] = [
|
||||
'type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'null' => false,
|
||||
];
|
||||
}
|
||||
if (! $this->db->fieldExists('withdrawal_policy_version', 'school_years')) {
|
||||
$columns['withdrawal_policy_version'] = [
|
||||
'type' => 'VARCHAR', 'constraint' => 40, 'default' => 'studied_weeks_v1', 'null' => false,
|
||||
];
|
||||
}
|
||||
if ($columns !== []) {
|
||||
$this->forge->addColumn('school_years', $columns);
|
||||
}
|
||||
}
|
||||
|
||||
private function addInventoryCatalogColumns(): void
|
||||
{
|
||||
if (! $this->db->tableExists('inventory_items')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
if (! $this->db->fieldExists('author', 'inventory_items')) {
|
||||
$columns['author'] = ['type' => 'VARCHAR', 'constraint' => 190, 'null' => true];
|
||||
}
|
||||
if (! $this->db->fieldExists('is_active', 'inventory_items')) {
|
||||
$columns['is_active'] = ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'null' => false];
|
||||
}
|
||||
if (! $this->db->fieldExists('retired_at', 'inventory_items')) {
|
||||
$columns['retired_at'] = ['type' => 'DATETIME', 'null' => true];
|
||||
}
|
||||
if (! $this->db->fieldExists('isbn_edition_key', 'inventory_items')) {
|
||||
$columns['isbn_edition_key'] = ['type' => 'VARCHAR', 'constraint' => 190, 'null' => true];
|
||||
}
|
||||
if (! $this->db->fieldExists('sku_normalized', 'inventory_items')) {
|
||||
$columns['sku_normalized'] = ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true];
|
||||
}
|
||||
|
||||
if ($columns !== []) {
|
||||
$this->forge->addColumn('inventory_items', $columns);
|
||||
}
|
||||
// Existing legacy rows remain NULL until an admin reconciles/edits them,
|
||||
// so installing this migration cannot fail because of historical duplicates.
|
||||
$this->addIndexIfMissing('inventory_items', 'uq_inventory_book_isbn_edition', ['isbn_edition_key'], true);
|
||||
$this->addIndexIfMissing('inventory_items', 'uq_inventory_sku_normalized', ['sku_normalized'], true);
|
||||
}
|
||||
|
||||
private function createInventoryItemYears(): void
|
||||
{
|
||||
if ($this->db->tableExists('inventory_item_years')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'inventory_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false],
|
||||
'opening_quantity' => ['type' => 'INT', 'constraint' => 11, 'default' => 0, 'null' => false],
|
||||
'charge_price_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'currency' => ['type' => 'CHAR', 'constraint' => 3, 'default' => 'USD', 'null' => false],
|
||||
'price_confirmed' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'null' => false],
|
||||
'system_closing_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
|
||||
'counted_closing_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
|
||||
'variance_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
|
||||
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'open', 'null' => false],
|
||||
'source_item_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'closing_batch_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'updated_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey(['inventory_item_id', 'school_year'], 'uq_inventory_item_year');
|
||||
$this->forge->addKey(['school_year_id', 'status'], false, false, 'idx_inventory_item_year_status');
|
||||
$this->forge->addKey('closing_batch_id');
|
||||
$this->forge->createTable('inventory_item_years', true, ['ENGINE' => 'InnoDB']);
|
||||
}
|
||||
|
||||
private function extendInventoryMovements(): void
|
||||
{
|
||||
if (! $this->db->tableExists('inventory_movements')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$definitions = [
|
||||
'item_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true],
|
||||
'reversal_of_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'posted', 'null' => false],
|
||||
'reversed_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'reversed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'source_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
|
||||
'source_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
];
|
||||
|
||||
$columns = [];
|
||||
foreach ($definitions as $name => $definition) {
|
||||
if (! $this->db->fieldExists($name, 'inventory_movements')) {
|
||||
$columns[$name] = $definition;
|
||||
}
|
||||
}
|
||||
if ($columns !== []) {
|
||||
$this->forge->addColumn('inventory_movements', $columns);
|
||||
}
|
||||
$this->addIndexIfMissing('inventory_movements', 'uq_inventory_movement_idempotency', ['idempotency_key'], true);
|
||||
$this->addIndexIfMissing('inventory_movements', 'idx_inventory_movement_item_year_status', ['item_year_id', 'status']);
|
||||
$this->addIndexIfMissing('inventory_movements', 'idx_inventory_movement_source', ['source_type', 'source_id']);
|
||||
}
|
||||
|
||||
private function createBookClassAssignments(): void
|
||||
{
|
||||
if ($this->db->tableExists('inventory_book_class_assignments')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'inventory_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false],
|
||||
'class_number' => ['type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => false],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey(['inventory_item_id', 'school_year', 'class_number'], 'uq_book_year_class');
|
||||
$this->forge->addKey(['school_year', 'class_number'], false, false, 'idx_book_class_year');
|
||||
$this->forge->createTable('inventory_book_class_assignments', true, ['ENGINE' => 'InnoDB']);
|
||||
}
|
||||
|
||||
private function protectInventoryHistoryForeignKey(): void
|
||||
{
|
||||
if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('inventory_items')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$rows = $this->db->query(
|
||||
"SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'inventory_movements'
|
||||
AND COLUMN_NAME = 'item_id' AND REFERENCED_TABLE_NAME = 'inventory_items'"
|
||||
)->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
$name = str_replace('`', '', (string) ($row['CONSTRAINT_NAME'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$this->db->query('ALTER TABLE inventory_movements DROP FOREIGN KEY `' . $name . '`');
|
||||
}
|
||||
}
|
||||
$this->db->query(
|
||||
'ALTER TABLE inventory_movements ADD CONSTRAINT fk_inventory_movements_item_restrict '
|
||||
. 'FOREIGN KEY (item_id) REFERENCES inventory_items(id) ON DELETE RESTRICT ON UPDATE CASCADE'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to replace inventory movement cascade with RESTRICT: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function restoreInventoryHistoryForeignKey(): void
|
||||
{
|
||||
if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('inventory_items')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$rows = $this->db->query(
|
||||
"SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'inventory_movements'
|
||||
AND COLUMN_NAME = 'item_id' AND REFERENCED_TABLE_NAME = 'inventory_items'"
|
||||
)->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
$name = str_replace('`', '', (string) ($row['CONSTRAINT_NAME'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$this->db->query('ALTER TABLE inventory_movements DROP FOREIGN KEY `' . $name . '`');
|
||||
}
|
||||
}
|
||||
$this->db->query(
|
||||
'ALTER TABLE inventory_movements ADD CONSTRAINT fk_inventory_movements_item_cascade '
|
||||
. 'FOREIGN KEY (item_id) REFERENCES inventory_items(id) ON DELETE CASCADE ON UPDATE CASCADE'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to restore inventory movement cascade during rollback: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function createStudentBookIssues(): void
|
||||
{
|
||||
if ($this->db->tableExists('student_book_issues')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'enrollment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'inventory_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'inventory_item_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false],
|
||||
'class_section_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'quantity' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 1, 'null' => false],
|
||||
'unit_charge_price_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'total_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'distribution_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 160, 'null' => false],
|
||||
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'issued', 'null' => false],
|
||||
'issued_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
'issued_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'reversed_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'reversed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'reversal_reason' => ['type' => 'TEXT', 'null' => true],
|
||||
'reversal_quantity' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'reversal_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey('idempotency_key', 'uq_student_book_issue_key');
|
||||
$this->forge->addKey(['student_id', 'school_year', 'status'], false, false, 'idx_student_book_issue_student');
|
||||
$this->forge->addKey(['inventory_item_year_id', 'status'], false, false, 'idx_student_book_issue_item_year');
|
||||
$this->forge->addKey('distribution_movement_id');
|
||||
$this->forge->createTable('student_book_issues', true, ['ENGINE' => 'InnoDB']);
|
||||
}
|
||||
|
||||
private function createWithdrawalCalculations(): void
|
||||
{
|
||||
if ($this->db->tableExists('withdrawal_financial_calculations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'version' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 1, 'null' => false],
|
||||
'enrollment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false],
|
||||
'policy_version' => ['type' => 'VARCHAR', 'constraint' => 40, 'null' => false],
|
||||
'annual_fee_includes_books' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'null' => false],
|
||||
'school_year_start_date' => ['type' => 'DATE', 'null' => false],
|
||||
'enrollment_date' => ['type' => 'DATE', 'null' => false],
|
||||
'withdrawal_request_date' => ['type' => 'DATE', 'null' => false],
|
||||
'total_instructional_weeks' => ['type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => false],
|
||||
'total_chargeable_days' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'studied_calendar_days' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'studied_weeks' => ['type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => false],
|
||||
'annual_fee_allocation_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'issued_book_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'annual_instruction_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'earned_tuition_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'other_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'retained_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'original_invoice_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'invoice_adjustment_cents' => ['type' => 'INT', 'constraint' => 11, 'default' => 0, 'null' => false],
|
||||
'adjusted_invoice_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'valid_payment_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'completed_payout_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'open_reservation_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'refundable_credit_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'new_refund_request_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'balance_due_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false],
|
||||
'book_evidence_json' => ['type' => 'LONGTEXT', 'null' => true],
|
||||
'explanation_json' => ['type' => 'LONGTEXT', 'null' => false],
|
||||
'calculation_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => false],
|
||||
'active_posted_key' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
|
||||
'books_discount_eligible' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'null' => false],
|
||||
'status' => ['type' => 'VARCHAR', 'constraint' => 24, 'default' => 'preview', 'null' => false],
|
||||
'superseded_by_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'override_reason' => ['type' => 'TEXT', 'null' => true],
|
||||
'overridden_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'overridden_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'calculated_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'posted_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'calculated_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
'posted_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey(['enrollment_id', 'version'], 'uq_withdrawal_calc_version');
|
||||
$this->forge->addUniqueKey('active_posted_key', 'uq_withdrawal_calc_active_posted');
|
||||
$this->forge->addKey('calculation_hash');
|
||||
$this->forge->addKey(['enrollment_id', 'status'], false, false, 'idx_withdrawal_calc_status');
|
||||
$this->forge->addKey(['invoice_id', 'status'], false, false, 'idx_withdrawal_calc_invoice');
|
||||
$this->forge->createTable('withdrawal_financial_calculations', true, ['ENGINE' => 'InnoDB']);
|
||||
}
|
||||
|
||||
private function extendRefunds(): void
|
||||
{
|
||||
if (! $this->db->tableExists('refunds') || $this->db->fieldExists('withdrawal_calculation_id', 'refunds')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addColumn('refunds', [
|
||||
'withdrawal_calculation_id' => [
|
||||
'type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'invoice_id',
|
||||
],
|
||||
]);
|
||||
$this->addIndexIfMissing('refunds', 'idx_refunds_withdrawal_calculation', ['withdrawal_calculation_id']);
|
||||
}
|
||||
|
||||
private function seedActiveYearPolicy(): void
|
||||
{
|
||||
if (! $this->db->tableExists('school_years') || ! $this->db->tableExists('configuration')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $this->db->table('configuration')
|
||||
->select('config_value')
|
||||
->where('config_key', 'total_instructional_weeks')
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
$weeks = filter_var($row['config_value'] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($weeks === false || $weeks <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->table('school_years')
|
||||
->where('status', 'active')
|
||||
->where('total_instructional_weeks IS NULL', null, false)
|
||||
->update([
|
||||
'total_instructional_weeks' => $weeks,
|
||||
'annual_fee_includes_books' => 1,
|
||||
'withdrawal_policy_version' => 'studied_weeks_v1',
|
||||
]);
|
||||
}
|
||||
|
||||
private function seedActiveBookYears(): void
|
||||
{
|
||||
if (! $this->db->tableExists('inventory_item_years') || ! $this->db->tableExists('inventory_items')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$year = $this->db->table('school_years')
|
||||
->select('id, name')
|
||||
->where('status', 'active')
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if ($year === null || trim((string) ($year['name'] ?? '')) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$books = $this->db->table('inventory_items')
|
||||
->select('id, quantity')
|
||||
->where('type', 'book')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($books as $book) {
|
||||
$exists = $this->db->table('inventory_item_years')
|
||||
->where('inventory_item_id', (int) $book['id'])
|
||||
->where('school_year', (string) $year['name'])
|
||||
->countAllResults();
|
||||
if ($exists > 0) {
|
||||
continue;
|
||||
}
|
||||
$movementRow = $this->db->table('inventory_movements')
|
||||
->selectSum('qty_change', 'year_movement_total')
|
||||
->where('item_id', (int) $book['id'])
|
||||
->where('school_year', (string) $year['name'])
|
||||
->get()->getRowArray();
|
||||
// inventory_items.quantity is the legacy current physical balance.
|
||||
// Reconstruct the opening so linking existing movements to this
|
||||
// item-year does not count those movements a second time.
|
||||
$openingQuantity = (int) ($book['quantity'] ?? 0) - (int) ($movementRow['year_movement_total'] ?? 0);
|
||||
$this->db->table('inventory_item_years')->insert([
|
||||
'inventory_item_id' => (int) $book['id'],
|
||||
'school_year_id' => (int) $year['id'],
|
||||
'school_year' => (string) $year['name'],
|
||||
'opening_quantity' => $openingQuantity,
|
||||
'charge_price_cents' => 0,
|
||||
'price_confirmed' => 0,
|
||||
'status' => 'open',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$itemYearId = (int) $this->db->insertID();
|
||||
$this->db->table('inventory_movements')
|
||||
->where('item_id', (int) $book['id'])
|
||||
->where('school_year', (string) $year['name'])
|
||||
->where('item_year_id', null)
|
||||
->update(['item_year_id' => $itemYearId, 'updated_at' => $now]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $columns */
|
||||
private function addIndexIfMissing(string $table, string $index, array $columns, bool $unique = false): void
|
||||
{
|
||||
try {
|
||||
$exists = $this->db->query(
|
||||
'SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1',
|
||||
[$table, $index]
|
||||
)->getRowArray();
|
||||
if ($exists !== null) {
|
||||
return;
|
||||
}
|
||||
$columnSql = implode(', ', array_map(static fn (string $column): string => '`' . str_replace('`', '', $column) . '`', $columns));
|
||||
$this->db->query(sprintf(
|
||||
'CREATE %s INDEX `%s` ON `%s` (%s)',
|
||||
$unique ? 'UNIQUE' : '',
|
||||
str_replace('`', '', $index),
|
||||
str_replace('`', '', $table),
|
||||
$columnSql
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to create inventory index {index}: {message}', [
|
||||
'index' => $index,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ class InvoiceLedgerService
|
||||
}
|
||||
|
||||
$netChargeCents = $totalAmountCents - $discountCents;
|
||||
$rawBalanceCents = $netChargeCents - $paidCents - $refundPaidCents;
|
||||
$rawBalanceCents = $netChargeCents - $paidCents + $refundPaidCents;
|
||||
$balanceCents = max(0, $rawBalanceCents);
|
||||
$customerCreditCents = max(0, $paidCents - $refundPaidCents - $netChargeCents);
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class RefundEligibilityService
|
||||
$completedPayoutCents = $this->completedPayoutsAffectAvailability($sourceType)
|
||||
? $this->calculateCompletedPayoutCents($sourceType, $sourceId, $excludeRefundId)
|
||||
: 0;
|
||||
$reservedAmountCents = $this->calculateReservedAmountCents($sourceType, $sourceId, $excludeRefundId);
|
||||
$reservedAmountCents = $this->calculateReservedAmountCents($sourceType, $sourceId, $excludeRefundId, $invoiceId);
|
||||
$availableAmountCents = max(0, $sourceCreditCents - $completedPayoutCents - $reservedAmountCents);
|
||||
|
||||
$reasons = [];
|
||||
@@ -164,7 +164,7 @@ class RefundEligibilityService
|
||||
{
|
||||
return match ($sourceType) {
|
||||
'invoice_overpayment' => $this->invoiceCreditCents($parentId, $invoiceId ?: $sourceId),
|
||||
'tuition_withdrawal' => $this->invoicePaidCents($parentId, $invoiceId ?: $sourceId),
|
||||
'tuition_withdrawal' => $this->invoiceCreditCents($parentId, $invoiceId ?: $sourceId),
|
||||
'payment_duplicate', 'payment_correction' => $this->paymentCreditCents($parentId, $invoiceId, $sourceId),
|
||||
'credit_memo', 'administrative_credit' => 0,
|
||||
default => 0,
|
||||
@@ -173,7 +173,7 @@ class RefundEligibilityService
|
||||
|
||||
protected function completedPayoutsAffectAvailability(string $sourceType): bool
|
||||
{
|
||||
return $sourceType !== 'invoice_overpayment';
|
||||
return ! in_array($sourceType, ['invoice_overpayment', 'tuition_withdrawal'], true);
|
||||
}
|
||||
|
||||
protected function invoicePaidCents(int $parentId, int $invoiceId): int
|
||||
@@ -286,13 +286,21 @@ class RefundEligibilityService
|
||||
return (int)round(((float)($row['total_paid'] ?? 0)) * 100);
|
||||
}
|
||||
|
||||
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
|
||||
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId, ?int $invoiceId = null): int
|
||||
{
|
||||
$query = $this->refundModel
|
||||
->select('id, refund_amount, refund_paid_amount, approved_amount_cents')
|
||||
->where('source_type', $sourceType)
|
||||
->where('source_id', $sourceId)
|
||||
->whereIn('status', ['Approved', 'Partial', 'approved', 'partially_paid']);
|
||||
->select('id, refund_amount, requested_amount_cents, refund_paid_amount, approved_amount_cents');
|
||||
|
||||
if (in_array($sourceType, ['invoice_overpayment', 'tuition_withdrawal'], true)) {
|
||||
$invoiceSourceId = $invoiceId !== null && $invoiceId > 0 ? $invoiceId : $sourceId;
|
||||
$query->where('invoice_id', $invoiceSourceId)
|
||||
->whereIn('source_type', ['invoice_overpayment', 'tuition_withdrawal']);
|
||||
} else {
|
||||
$query->where('source_type', $sourceType)
|
||||
->where('source_id', $sourceId);
|
||||
}
|
||||
|
||||
$query->whereIn('status', ['Pending', 'requested', 'Approved', 'Partial', 'pending', 'approved', 'partially_paid']);
|
||||
|
||||
if ($excludeRefundId !== null && $excludeRefundId > 0) {
|
||||
$query->where('id !=', $excludeRefundId);
|
||||
@@ -302,7 +310,7 @@ class RefundEligibilityService
|
||||
foreach ($query->findAll() as $refund) {
|
||||
$approved = isset($refund['approved_amount_cents']) && $refund['approved_amount_cents'] !== null
|
||||
? (int)$refund['approved_amount_cents']
|
||||
: (int)round(((float)($refund['refund_amount'] ?? 0)) * 100);
|
||||
: ((int)($refund['requested_amount_cents'] ?? 0) ?: (int)round(((float)($refund['refund_amount'] ?? 0)) * 100));
|
||||
$paid = $this->getCompletedPayoutTotalCentsForRefund((int)($refund['id'] ?? 0));
|
||||
$reserved += max(0, $approved - $paid);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,12 @@ protected $table = 'inventory_items';
|
||||
'condition',
|
||||
'isbn',
|
||||
'edition',
|
||||
'author',
|
||||
'sku',
|
||||
'is_active',
|
||||
'retired_at',
|
||||
'isbn_edition_key',
|
||||
'sku_normalized',
|
||||
'notes',
|
||||
|
||||
// audit
|
||||
@@ -49,7 +54,6 @@ protected $table = 'inventory_items';
|
||||
'type' => 'required|in_list[classroom,book,office,kitchen]',
|
||||
'name' => 'required|min_length[2]',
|
||||
'quantity' => 'permit_empty|integer',
|
||||
'unit_price' => 'permit_empty|decimal',
|
||||
'school_year' => 'required|string|max_length[16]',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class InventoryItemYearModel extends Model
|
||||
{
|
||||
protected $table = 'inventory_item_years';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
|
||||
protected $allowedFields = [
|
||||
'inventory_item_id',
|
||||
'school_year_id',
|
||||
'school_year',
|
||||
'opening_quantity',
|
||||
'charge_price_cents',
|
||||
'currency',
|
||||
'price_confirmed',
|
||||
'system_closing_quantity',
|
||||
'counted_closing_quantity',
|
||||
'variance_quantity',
|
||||
'status',
|
||||
'source_item_year_id',
|
||||
'closing_batch_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $validationRules = [
|
||||
'inventory_item_id' => 'required|integer',
|
||||
'school_year' => 'required|string|max_length[16]',
|
||||
'opening_quantity' => 'required|integer',
|
||||
'charge_price_cents' => 'required|integer|greater_than_equal_to[0]',
|
||||
'currency' => 'required|alpha|max_length[3]',
|
||||
'price_confirmed' => 'required|in_list[0,1]',
|
||||
'status' => 'required|in_list[open,reconciled,carried,closed]',
|
||||
];
|
||||
}
|
||||
@@ -15,9 +15,11 @@ class InventoryMovementModel extends Model
|
||||
protected $useTimestamps = true;
|
||||
|
||||
protected $allowedFields = [
|
||||
'item_id','qty_change','movement_type','reason','note',
|
||||
'item_id','item_year_id','qty_change','movement_type','reason','note',
|
||||
'semester','school_year',
|
||||
'performed_by','teacher_id','student_id','class_section_id',
|
||||
'idempotency_key','reversal_of_movement_id','status','reversed_at',
|
||||
'reversed_by','source_type','source_id',
|
||||
];
|
||||
protected $validationRules = [
|
||||
'school_year' => 'required|string|max_length[9]',
|
||||
|
||||
@@ -35,6 +35,9 @@ class SchoolYearModel extends Model
|
||||
'registration_launch_approved_by',
|
||||
'registration_email_template_version',
|
||||
'fall_makeup_exam_on',
|
||||
'total_instructional_weeks',
|
||||
'annual_fee_includes_books',
|
||||
'withdrawal_policy_version',
|
||||
'previous_school_year_id',
|
||||
'next_school_year_id',
|
||||
'activated_at',
|
||||
@@ -65,6 +68,8 @@ class SchoolYearModel extends Model
|
||||
'registration_launch_approved_at' => 'permit_empty|valid_date[Y-m-d H:i:s]',
|
||||
'registration_launch_approved_by' => 'permit_empty|integer',
|
||||
'fall_makeup_exam_on' => 'permit_empty|valid_date[Y-m-d]',
|
||||
'total_instructional_weeks' => 'permit_empty|integer|greater_than[0]',
|
||||
'annual_fee_includes_books' => 'permit_empty|in_list[0,1]',
|
||||
];
|
||||
|
||||
public function active(): ?array
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class StudentBookIssueModel extends Model
|
||||
{
|
||||
protected $table = 'student_book_issues';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
|
||||
protected $allowedFields = [
|
||||
'student_id', 'enrollment_id', 'parent_id', 'inventory_item_id',
|
||||
'inventory_item_year_id', 'school_year', 'class_section_id', 'quantity',
|
||||
'unit_charge_price_cents', 'total_charge_cents', 'distribution_movement_id',
|
||||
'idempotency_key', 'status', 'issued_at', 'issued_by', 'reversed_at',
|
||||
'reversed_by', 'reversal_reason', 'reversal_quantity', 'reversal_movement_id',
|
||||
];
|
||||
|
||||
protected $validationRules = [
|
||||
'student_id' => 'required|integer',
|
||||
'enrollment_id' => 'required|integer',
|
||||
'parent_id' => 'required|integer',
|
||||
'inventory_item_id' => 'required|integer',
|
||||
'inventory_item_year_id' => 'required|integer',
|
||||
'school_year' => 'required|string|max_length[16]',
|
||||
'quantity' => 'required|integer|greater_than[0]',
|
||||
'unit_charge_price_cents' => 'required|integer|greater_than[0]',
|
||||
'total_charge_cents' => 'required|integer|greater_than[0]',
|
||||
'idempotency_key' => 'required|string|max_length[160]',
|
||||
'status' => 'required|in_list[issued,reversed]',
|
||||
'issued_at' => 'required|valid_date[Y-m-d H:i:s]',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class WithdrawalFinancialCalculationModel extends Model
|
||||
{
|
||||
protected $table = 'withdrawal_financial_calculations';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
|
||||
protected $allowedFields = [
|
||||
'version', 'enrollment_id', 'student_id', 'parent_id', 'invoice_id',
|
||||
'school_year', 'policy_version', 'annual_fee_includes_books',
|
||||
'school_year_start_date', 'enrollment_date', 'withdrawal_request_date',
|
||||
'total_instructional_weeks', 'total_chargeable_days',
|
||||
'studied_calendar_days', 'studied_weeks', 'annual_fee_allocation_cents',
|
||||
'issued_book_charge_cents', 'annual_instruction_cents',
|
||||
'earned_tuition_cents', 'other_charge_cents', 'retained_charge_cents',
|
||||
'original_invoice_charge_cents', 'invoice_adjustment_cents',
|
||||
'adjusted_invoice_charge_cents',
|
||||
'valid_payment_cents', 'completed_payout_cents', 'open_reservation_cents',
|
||||
'refundable_credit_cents', 'new_refund_request_cents', 'balance_due_cents',
|
||||
'book_evidence_json', 'explanation_json', 'calculation_hash',
|
||||
'active_posted_key', 'books_discount_eligible', 'status', 'superseded_by_id',
|
||||
'override_reason', 'overridden_at', 'overridden_by',
|
||||
'calculated_by', 'posted_by', 'calculated_at', 'posted_at',
|
||||
];
|
||||
|
||||
protected $validationRules = [
|
||||
'version' => 'required|integer|greater_than[0]',
|
||||
'enrollment_id' => 'required|integer',
|
||||
'student_id' => 'required|integer',
|
||||
'parent_id' => 'required|integer',
|
||||
'school_year' => 'required|string|max_length[16]',
|
||||
'annual_fee_includes_books' => 'required|in_list[1]',
|
||||
'total_instructional_weeks' => 'required|integer|greater_than[0]',
|
||||
'status' => 'required|in_list[preview,posted,superseded,requires_review]',
|
||||
];
|
||||
}
|
||||
@@ -14,11 +14,11 @@ final class EnrollmentStatusService
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'withdraw under review',
|
||||
'refund pending',
|
||||
];
|
||||
|
||||
public const INACTIVE_STATUSES = [
|
||||
'denied',
|
||||
'refund pending',
|
||||
'withdrawn',
|
||||
'waitlist',
|
||||
];
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Controllers\View\InvoiceController;
|
||||
use App\Libraries\RefundEligibilityService;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\InvoiceModel;
|
||||
@@ -194,7 +193,6 @@ public function newStudents(string $schoolYear): array
|
||||
//update enrollment status
|
||||
public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, string $semester, ?int $performedBy): array
|
||||
{
|
||||
$refundService = new FeeCalculationService();
|
||||
$enrollmentStatusService = \Config\Services::enrollmentStatus(false);
|
||||
$performedBy = $performedBy ?: ((int) (session()->get('user_id') ?? 0) ?: null);
|
||||
$this->schoolYear = $schoolYear;
|
||||
@@ -212,8 +210,8 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
|
||||
// For batching emails: parent -> status -> [students...]
|
||||
$groupsByParentStatus = []; // [parent_id][status][] = ['student_id'=>, 'student_name'=>]
|
||||
$parentInfo = []; // [parent_id] = ['user_id','email','firstname','lastname']
|
||||
$refundParents = []; // parent_id => true (for refund calc)
|
||||
$refundAmountByParent = []; // parent_id => amount
|
||||
$withdrawalPreviewEnrollmentIds = []; // enrollment_id => parent_id
|
||||
$refundAmountByParent = []; // parent_id => preview amount for notification context
|
||||
|
||||
$validStatuses = EnrollmentStatusService::VALID_STATUSES;
|
||||
|
||||
@@ -287,7 +285,7 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
|
||||
];
|
||||
|
||||
if ($newEnrollmentStatus === 'refund pending') {
|
||||
$refundParents[$parentId] = true;
|
||||
$withdrawalPreviewEnrollmentIds[(int) $result['id']] = $parentId;
|
||||
}
|
||||
|
||||
log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}.");
|
||||
@@ -369,98 +367,25 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
|
||||
|
||||
// Mark for refund calc
|
||||
if ($newEnrollmentStatus === 'refund pending') {
|
||||
$refundParents[$parentId] = true;
|
||||
$withdrawalPreviewEnrollmentIds[(int) $enrollmentRow['id']] = (int) $parentId;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute refunds ONCE per parent needing it
|
||||
foreach (array_keys($refundParents) as $pid) {
|
||||
$students = $this->enrollmentModel
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->findAll();
|
||||
|
||||
if (empty($students)) {
|
||||
// If a parent is marked for refund but has no enrollments, just log and continue.
|
||||
log_message('info', "No enrollments found for parent ID {$pid} (for refund calc); skipping refund.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoice = $this->invoiceModel->where('parent_id', $pid)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
|
||||
if (!$invoice) {
|
||||
$errors[] = "No invoice found for parent ID $pid (for refund calc).";
|
||||
continue;
|
||||
}
|
||||
|
||||
$refundAmount = $refundService->calculateRefund($students, $pid);
|
||||
$refundAmountByParent[$pid] = $refundAmount;
|
||||
|
||||
$existingRefund = $this->refundModel->where('invoice_id', $invoice['id'])->first();
|
||||
|
||||
if ($existingRefund) {
|
||||
$refundId = (int)$existingRefund['id'];
|
||||
$status = strtolower((string)($existingRefund['status'] ?? ''));
|
||||
$isApprovedState = in_array($status, ['approved', 'partial', 'paid', 'partially_paid'], true);
|
||||
$calculatedCents = max(0, (int)round($refundAmount * 100));
|
||||
$paidCents = (new RefundEligibilityService())->getCompletedPayoutTotalCentsForRefund($refundId);
|
||||
$targetCents = $isApprovedState ? max($calculatedCents, $paidCents) : $calculatedCents;
|
||||
$update = [
|
||||
'refund_amount' => $targetCents / 100,
|
||||
'updated_by' => session()->get('user_id') ?? null,
|
||||
];
|
||||
if ($isApprovedState) {
|
||||
$update['approved_amount_cents'] = $targetCents;
|
||||
} else {
|
||||
$update['status'] = 'Pending';
|
||||
$update['requested_amount_cents'] = $targetCents;
|
||||
}
|
||||
if ($isApprovedState && $paidCents > $calculatedCents) {
|
||||
$message = sprintf(
|
||||
'Completed payouts (%0.2f) exceed recalculated refundable credit (%0.2f).',
|
||||
$paidCents / 100,
|
||||
$calculatedCents / 100
|
||||
);
|
||||
$update['reconciliation_status'] = 'requires_review';
|
||||
$update['reconciliation_reason'] = $message;
|
||||
$update['reconciliation_required_at'] = utc_now();
|
||||
log_message('critical', 'Refund reconciliation required for refund #' . $refundId . ': ' . $message);
|
||||
} else {
|
||||
$update['reconciliation_status'] = null;
|
||||
$update['reconciliation_reason'] = null;
|
||||
$update['reconciliation_required_at'] = null;
|
||||
}
|
||||
$this->refundModel->update($refundId, $update);
|
||||
} else {
|
||||
$this->refundModel->insert([
|
||||
'parent_id' => $pid,
|
||||
'school_year' => $invoice['school_year'],
|
||||
'invoice_id' => $invoice['id'],
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => (int)round($refundAmount * 100),
|
||||
'approved_amount_cents' => null,
|
||||
'currency' => 'USD',
|
||||
'refund_paid_amount' => 0.0,
|
||||
'status' => 'Pending',
|
||||
'source_type' => 'tuition_withdrawal',
|
||||
'source_id' => (int)$invoice['id'],
|
||||
'requested_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id') ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
log_message('info', "Refund of $refundAmount created/updated for invoice ID {$invoice['id']} (parent {$pid}).");
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
return ['ok' => false, 'message' => 'A database error occurred. Changes were rolled back.'];
|
||||
}
|
||||
|
||||
foreach ($withdrawalPreviewEnrollmentIds as $enrollmentId => $pid) {
|
||||
try {
|
||||
$calculation = service('withdrawalFinancial')->preview((int) $enrollmentId, $performedBy);
|
||||
$refundAmountByParent[(int) $pid] = ((int) ($calculation['new_refund_request_cents'] ?? 0)) / 100;
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = 'Withdrawal calculation preview failed for enrollment #' . (int) $enrollmentId . ': ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// === AFTER COMMIT: fire specific events, batched per parent/status ===
|
||||
$eventMap = [
|
||||
'admission under review' => 'admissionUnderReview',
|
||||
@@ -524,7 +449,7 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
|
||||
|
||||
// === Server-side safety net: generate/update invoices for parents whose statuses require it ===
|
||||
try {
|
||||
$needsInvoiceFor = ['payment pending', 'enrolled', 'withdrawn', 'refund pending'];
|
||||
$needsInvoiceFor = ['payment pending', 'enrolled'];
|
||||
$invCtl = new InvoiceController();
|
||||
foreach ($groupsByParentStatus as $pid => $byStatus) {
|
||||
$statuses = array_keys($byStatus);
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
|
||||
class FeeCalculationService
|
||||
@@ -15,112 +13,44 @@ class FeeCalculationService
|
||||
|
||||
public function calculateRefund(array $students, int $parentId): float
|
||||
{
|
||||
$configModel = new ConfigurationModel();
|
||||
$paymentModel = new PaymentModel();
|
||||
$invoiceModel = new InvoiceModel();
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
$totalCents = 0;
|
||||
$seenEnrollmentIds = [];
|
||||
|
||||
$schoolYear = $configModel->getConfig('school_year');
|
||||
$refundDeadline = date('Y-m-d', strtotime($configModel->getConfig('refund_deadline')));
|
||||
$weekOfStudy = (float) ($configModel->getConfig('weeks_study') ?? 8);
|
||||
$schoolEndDate = date('Y-m-d', strtotime($configModel->getConfig('last_day_of_school')));
|
||||
$totalPaid = $paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
|
||||
|
||||
if ($totalPaid <= 0) {
|
||||
log_message('info', "No payments made. Refund = 0.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Classify and enrich student data
|
||||
$registeredStudents = [];
|
||||
$withdrawnStudents = [];
|
||||
|
||||
foreach ($students as &$student) {
|
||||
$gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']);
|
||||
$student['grade'] = strtoupper(trim($gradeName));
|
||||
|
||||
if (in_array($student['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
|
||||
$withdrawnStudents[] = $student;
|
||||
} elseif (
|
||||
in_array($student['enrollment_status'], ['enrolled', 'payment pending']) &&
|
||||
$student['admission_status'] === 'accepted'
|
||||
) {
|
||||
$registeredStudents[] = $student;
|
||||
}
|
||||
}
|
||||
unset($student);
|
||||
|
||||
if (empty($withdrawnStudents)) {
|
||||
log_message('info', "No withdrawn students found. Refund = 0.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
usort($withdrawnStudents, function ($a, $b) {
|
||||
$leftDate = strtotime((string)($a['withdrawal_date'] ?? '')) ?: PHP_INT_MAX;
|
||||
$rightDate = strtotime((string)($b['withdrawal_date'] ?? '')) ?: PHP_INT_MAX;
|
||||
|
||||
if ($leftDate !== $rightDate) {
|
||||
return $leftDate <=> $rightDate;
|
||||
}
|
||||
|
||||
return $this->compareGrades($a['grade'], $b['grade']);
|
||||
});
|
||||
|
||||
// Combine all students for proper fee tiering before withdrawal.
|
||||
$allStudents = array_merge($registeredStudents, $withdrawnStudents);
|
||||
|
||||
// Sort all students by grade for correct tiering
|
||||
usort($allStudents, function ($a, $b) {
|
||||
return $this->compareGrades($a['grade'], $b['grade']);
|
||||
});
|
||||
|
||||
// Retrieve fee configs
|
||||
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380);
|
||||
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280);
|
||||
|
||||
$refundFeeStack = $this->reverseTuitionRefundFeeStack(
|
||||
count($allStudents),
|
||||
count($registeredStudents),
|
||||
$firstStudentFee,
|
||||
$secondStudentFee
|
||||
);
|
||||
|
||||
// Calculate refund for withdrawn students
|
||||
$refundAmount = 0;
|
||||
$withdrawnRefundIndex = 0;
|
||||
|
||||
foreach ($withdrawnStudents as $student) {
|
||||
if (empty($student['withdrawal_date'])) {
|
||||
log_message('warning', "Missing withdraw date for student ID: {$student['student_id']}");
|
||||
foreach ($students as $student) {
|
||||
if ((int) ($student['parent_id'] ?? $parentId) !== $parentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$withdrawDate = date('Y-m-d', strtotime($student['withdrawal_date']));
|
||||
if (strtotime($withdrawDate) > strtotime($refundDeadline)) {
|
||||
log_message('info', "Withdraw date {$withdrawDate} is after refund deadline {$refundDeadline}. No refund for this student.");
|
||||
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
|
||||
if (! in_array($status, ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$withdrawDateObj = new \DateTime($withdrawDate);
|
||||
$schoolEndDateObj = new \DateTime($schoolEndDate);
|
||||
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
|
||||
$weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7)));
|
||||
$enrollmentId = (int) ($student['enrollment_id'] ?? $student['id'] ?? 0);
|
||||
if ($enrollmentId <= 0 || isset($seenEnrollmentIds[$enrollmentId])) {
|
||||
continue;
|
||||
}
|
||||
$seenEnrollmentIds[$enrollmentId] = true;
|
||||
|
||||
$studentFee = (float) ($refundFeeStack[$withdrawnRefundIndex] ?? 0);
|
||||
$withdrawnRefundIndex++;
|
||||
$proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining;
|
||||
$refundAmount += $proportionalRefund;
|
||||
$calculation = $this->latestWithdrawalCalculation($enrollmentId);
|
||||
if ($calculation === null || ($calculation['status'] ?? '') === 'superseded') {
|
||||
$calculation = $this->previewWithdrawalCalculation($enrollmentId);
|
||||
}
|
||||
|
||||
log_message('info', "Student ID {$student['student_id']} refund portion: {$proportionalRefund} of {$studentFee} for {$weeksRemaining} weeks.");
|
||||
$totalCents += max(0, (int) ($calculation['new_refund_request_cents'] ?? 0));
|
||||
}
|
||||
|
||||
if ($refundAmount > $totalPaid) {
|
||||
log_message('info', "Refund capped at total paid amount: {$totalPaid}");
|
||||
return $totalPaid;
|
||||
}
|
||||
return round($totalCents / 100, 2);
|
||||
}
|
||||
|
||||
log_message('info', "Final calculated refund: {$refundAmount}");
|
||||
return $refundAmount;
|
||||
protected function latestWithdrawalCalculation(int $enrollmentId): ?array
|
||||
{
|
||||
return service('withdrawalFinancial')->latestForEnrollment($enrollmentId);
|
||||
}
|
||||
|
||||
protected function previewWithdrawalCalculation(int $enrollmentId): array
|
||||
{
|
||||
return service('withdrawalFinancial')->preview($enrollmentId, (int) (session()->get('user_id') ?? 0) ?: null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,6 +79,11 @@ final class SchoolYearClosingService
|
||||
$findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.');
|
||||
}
|
||||
|
||||
$inventory = $this->inventoryClosingPreview($sourceName, $target !== null ? (string) ($target['name'] ?? '') : '');
|
||||
foreach ($inventory['findings'] as $finding) {
|
||||
$findings[] = $finding;
|
||||
}
|
||||
|
||||
$carryForward = $this->carryForwardFamilies($sourceName);
|
||||
$warnings = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'warning'));
|
||||
$blockers = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'blocking'));
|
||||
@@ -89,6 +94,7 @@ final class SchoolYearClosingService
|
||||
'overview' => $overview,
|
||||
'finance' => $finance,
|
||||
'promotion' => $promotion,
|
||||
'inventory' => $inventory,
|
||||
'findings' => $findings,
|
||||
'blockers' => $blockers,
|
||||
'warnings' => $warnings,
|
||||
@@ -198,6 +204,7 @@ final class SchoolYearClosingService
|
||||
'error_message' => null,
|
||||
]);
|
||||
}
|
||||
$this->executeInventoryCarryForward($source, $target, (int) $batch['id'], $userId);
|
||||
if (($batch['status'] ?? '') !== 'completed') {
|
||||
$this->batchModel->update((int) $batch['id'], ['status' => 'executed']);
|
||||
}
|
||||
@@ -239,6 +246,19 @@ final class SchoolYearClosingService
|
||||
|
||||
$this->db->transStart();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
if ($this->db->tableExists('inventory_item_years')) {
|
||||
$this->db->table('inventory_item_years')
|
||||
->where('school_year', (string) ($target['name'] ?? ''))
|
||||
->where('source_item_year_id IS NOT NULL', null, false)
|
||||
->where('closing_batch_id', (int) $batch['id'])
|
||||
->where('status', 'carried')
|
||||
->update(['status' => 'open', 'updated_at' => $now, 'updated_by' => $userId]);
|
||||
$this->db->table('inventory_item_years')
|
||||
->where('school_year', (string) ($this->requireYear($sourceYearId)['name'] ?? ''))
|
||||
->where('closing_batch_id', (int) $batch['id'])
|
||||
->where('status', 'carried')
|
||||
->update(['status' => 'closed', 'updated_at' => $now, 'updated_by' => $userId]);
|
||||
}
|
||||
$this->batchModel->update((int) $batch['id'], [
|
||||
'status' => 'completed',
|
||||
'completed_by' => $userId,
|
||||
@@ -459,6 +479,195 @@ final class SchoolYearClosingService
|
||||
]);
|
||||
}
|
||||
|
||||
private function inventoryClosingPreview(string $sourceYear, string $targetYear): array
|
||||
{
|
||||
$empty = ['rows' => [], 'summary' => ['books' => 0, 'target_opening_quantity' => 0], 'findings' => []];
|
||||
if (! $this->db->tableExists('inventory_item_years') || ! $this->db->tableExists('inventory_items')) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$itemYears = $this->db->table('inventory_item_years iy')
|
||||
->select('iy.*, i.name AS item_name, i.isbn, i.edition')
|
||||
->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner')
|
||||
->where('iy.school_year', $sourceYear)
|
||||
->where('i.type', 'book')
|
||||
->orderBy('i.name', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
if ($itemYears === []) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$ids = array_map(static fn (array $row): int => (int) $row['id'], $itemYears);
|
||||
$movementTotals = [];
|
||||
$issueCounts = [];
|
||||
if ($this->db->tableExists('inventory_movements')) {
|
||||
foreach ($this->db->table('inventory_movements')
|
||||
->select('item_year_id, COALESCE(SUM(qty_change), 0) AS movement_total')
|
||||
->whereIn('item_year_id', $ids)
|
||||
->whereIn('status', ['posted', 'reversed'])
|
||||
->groupBy('item_year_id')
|
||||
->get()->getResultArray() as $row) {
|
||||
$movementTotals[(int) $row['item_year_id']] = (int) ($row['movement_total'] ?? 0);
|
||||
}
|
||||
}
|
||||
if ($this->db->tableExists('student_book_issues')) {
|
||||
foreach ($this->db->table('student_book_issues')
|
||||
->select('inventory_item_year_id, COUNT(*) AS issue_count')
|
||||
->whereIn('inventory_item_year_id', $ids)
|
||||
->where('school_year', $sourceYear)
|
||||
->groupBy('inventory_item_year_id')
|
||||
->get()->getResultArray() as $row) {
|
||||
$issueCounts[(int) $row['inventory_item_year_id']] = (int) ($row['issue_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$findings = [];
|
||||
$rows = [];
|
||||
$targetOpeningTotal = 0;
|
||||
foreach ($itemYears as $row) {
|
||||
$opening = (int) ($row['opening_quantity'] ?? 0);
|
||||
$system = $opening + (int) ($movementTotals[(int) $row['id']] ?? 0);
|
||||
$hasIssueSnapshots = (int) ($issueCounts[(int) $row['id']] ?? 0) > 0;
|
||||
$counted = $row['counted_closing_quantity'];
|
||||
$countedInt = $counted === null ? ($hasIssueSnapshots ? null : $system) : (int) $counted;
|
||||
$variance = $countedInt === null ? null : $countedInt - $system;
|
||||
$price = (int) ($row['charge_price_cents'] ?? 0);
|
||||
if ($price <= 0 || (int) ($row['price_confirmed'] ?? 0) !== 1) {
|
||||
$findings[] = $this->finding(
|
||||
$hasIssueSnapshots ? 'blocking' : 'warning',
|
||||
'Book price missing',
|
||||
(string) $row['item_name'] . ' has no confirmed charge price for ' . $sourceYear
|
||||
. ($hasIssueSnapshots ? '.' : '; this bootstrap year has no issue price snapshots, so confirm the target-year price before future distribution.')
|
||||
);
|
||||
}
|
||||
if ($system < 0) {
|
||||
$findings[] = $this->finding('blocking', 'Negative book stock', (string) $row['item_name'] . ' calculates to negative stock.');
|
||||
}
|
||||
if ($counted === null && ! $hasIssueSnapshots) {
|
||||
$findings[] = $this->finding('warning', 'Physical book count defaulted', (string) $row['item_name'] . ' has no physical count in this bootstrap year; system closing quantity will be carried forward.');
|
||||
} elseif ($countedInt === null) {
|
||||
$findings[] = $this->finding('blocking', 'Physical book count missing', (string) $row['item_name'] . ' needs a counted closing quantity.');
|
||||
} elseif ($variance !== 0) {
|
||||
$findings[] = $this->finding('blocking', 'Unresolved book variance', (string) $row['item_name'] . ' has variance ' . $variance . '. Resolve with an audited adjustment before closing.');
|
||||
}
|
||||
$targetOpening = max(0, $countedInt ?? 0);
|
||||
$targetOpeningTotal += $targetOpening;
|
||||
$rows[] = [
|
||||
'item_year_id' => (int) $row['id'],
|
||||
'inventory_item_id' => (int) $row['inventory_item_id'],
|
||||
'item_name' => (string) $row['item_name'],
|
||||
'isbn' => (string) ($row['isbn'] ?? ''),
|
||||
'edition' => (string) ($row['edition'] ?? ''),
|
||||
'opening_quantity' => $opening,
|
||||
'movement_total' => (int) ($movementTotals[(int) $row['id']] ?? 0),
|
||||
'system_closing_quantity' => $system,
|
||||
'counted_closing_quantity' => $countedInt,
|
||||
'variance_quantity' => $variance,
|
||||
'charge_price_cents' => $price,
|
||||
'target_school_year' => $targetYear,
|
||||
'target_opening_quantity' => $targetOpening,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->inventoryEvidenceFindings($sourceYear) as $finding) {
|
||||
$findings[] = $finding;
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'summary' => ['books' => count($rows), 'target_opening_quantity' => $targetOpeningTotal],
|
||||
'findings' => $findings,
|
||||
];
|
||||
}
|
||||
|
||||
private function inventoryEvidenceFindings(string $sourceYear): array
|
||||
{
|
||||
if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('student_book_issues')) {
|
||||
return [];
|
||||
}
|
||||
$findings = [];
|
||||
$hasAnyIssueSnapshots = $this->db->table('student_book_issues')
|
||||
->where('school_year', $sourceYear)
|
||||
->countAllResults() > 0;
|
||||
$missingIssues = $this->db->table('inventory_movements m')
|
||||
->join('student_book_issues sbi', 'sbi.distribution_movement_id = m.id', 'left')
|
||||
->where('m.school_year', $sourceYear)
|
||||
->where('m.movement_type', 'distribution')
|
||||
->where('m.status', 'posted')
|
||||
->where('sbi.id IS NULL', null, false)
|
||||
->countAllResults();
|
||||
if ($missingIssues > 0) {
|
||||
$findings[] = $this->finding(
|
||||
$hasAnyIssueSnapshots ? 'blocking' : 'warning',
|
||||
'Distribution movement missing issue snapshot',
|
||||
$missingIssues . ' legacy book distribution movement(s) have no linked student book issue'
|
||||
. ($hasAnyIssueSnapshots ? '.' : '; this bootstrap year will not use those movements as refund price evidence.')
|
||||
);
|
||||
}
|
||||
|
||||
$missingMovements = $this->db->table('student_book_issues sbi')
|
||||
->join('inventory_movements m', 'm.id = sbi.distribution_movement_id', 'left')
|
||||
->where('sbi.school_year', $sourceYear)
|
||||
->where('sbi.status', 'issued')
|
||||
->where('m.id IS NULL', null, false)
|
||||
->countAllResults();
|
||||
if ($missingMovements > 0) {
|
||||
$findings[] = $this->finding('blocking', 'Student issue missing stock movement', $missingMovements . ' student book issue(s) have no linked stock movement.');
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
private function executeInventoryCarryForward(array $source, array $target, int $batchId, ?int $userId): void
|
||||
{
|
||||
if (! $this->db->tableExists('inventory_item_years')) {
|
||||
return;
|
||||
}
|
||||
$preview = $this->inventoryClosingPreview((string) $source['name'], (string) $target['name']);
|
||||
$blockers = array_values(array_filter($preview['findings'], static fn (array $finding): bool => ($finding['severity'] ?? '') === 'blocking'));
|
||||
if ($blockers !== []) {
|
||||
throw new InvalidArgumentException('Resolve inventory closing blockers before executing carry-forward.');
|
||||
}
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($preview['rows'] as $row) {
|
||||
$targetOpening = (int) ($row['target_opening_quantity'] ?? 0);
|
||||
if ($targetOpening <= 0) {
|
||||
continue;
|
||||
}
|
||||
$exists = $this->db->table('inventory_item_years')
|
||||
->where('inventory_item_id', (int) $row['inventory_item_id'])
|
||||
->where('school_year', (string) $target['name'])
|
||||
->get(1)->getRowArray();
|
||||
if ($exists === null) {
|
||||
$this->db->table('inventory_item_years')->insert([
|
||||
'inventory_item_id' => (int) $row['inventory_item_id'],
|
||||
'school_year_id' => (int) $target['id'],
|
||||
'school_year' => (string) $target['name'],
|
||||
'opening_quantity' => $targetOpening,
|
||||
'charge_price_cents' => (int) $row['charge_price_cents'],
|
||||
'currency' => 'USD',
|
||||
'price_confirmed' => 0,
|
||||
'status' => 'carried',
|
||||
'source_item_year_id' => (int) $row['item_year_id'],
|
||||
'closing_batch_id' => $batchId,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
$this->db->table('inventory_item_years')->where('id', (int) $row['item_year_id'])->update([
|
||||
'system_closing_quantity' => (int) $row['system_closing_quantity'],
|
||||
'variance_quantity' => 0,
|
||||
'status' => 'carried',
|
||||
'closing_batch_id' => $batchId,
|
||||
'updated_by' => $userId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function countExistingTargetInvoices(array $batch): int
|
||||
{
|
||||
$batchId = (int) ($batch['id'] ?? 0);
|
||||
@@ -1120,6 +1329,7 @@ final class SchoolYearClosingService
|
||||
'target_id' => $preview['target']['id'] ?? null,
|
||||
'finance' => $preview['finance'],
|
||||
'promotion' => $preview['promotion'],
|
||||
'inventory' => $preview['inventory'] ?? [],
|
||||
'carry_forward' => $preview['carry_forward'],
|
||||
'blockers' => $preview['blockers'],
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
|
||||
@@ -163,6 +163,12 @@ final class SchoolYearManagementService
|
||||
if (! SchoolYearStatus::canTransition($from, SchoolYearStatus::ACTIVE)) {
|
||||
throw new InvalidArgumentException('Only draft or approved reopened school years can be activated.');
|
||||
}
|
||||
if ($this->configurationTotalInstructionalWeeks() <= 0) {
|
||||
throw new InvalidArgumentException('Set total instructional weeks before activating this school year.');
|
||||
}
|
||||
if ((int) ($year['annual_fee_includes_books'] ?? 1) !== 1) {
|
||||
throw new InvalidArgumentException('The withdrawal refund policy requires annual tuition to include books.');
|
||||
}
|
||||
|
||||
$this->db->transStart();
|
||||
$activeYears = $this->schoolYearModel->where('status', SchoolYearStatus::ACTIVE)->findAll();
|
||||
@@ -348,6 +354,9 @@ final class SchoolYearManagementService
|
||||
'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null),
|
||||
'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null),
|
||||
'fall_makeup_exam_on' => $this->nullableDate($payload['fall_makeup_exam_on'] ?? null),
|
||||
'total_instructional_weeks' => $this->nullableInt($payload['total_instructional_weeks'] ?? null),
|
||||
'annual_fee_includes_books' => 1,
|
||||
'withdrawal_policy_version' => trim((string) ($payload['withdrawal_policy_version'] ?? 'studied_weeks_v1')) ?: 'studied_weeks_v1',
|
||||
'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null),
|
||||
];
|
||||
}
|
||||
@@ -377,6 +386,7 @@ final class SchoolYearManagementService
|
||||
|
||||
$configValues = [
|
||||
'school_year' => $name,
|
||||
'total_instructional_weeks' => (string) ($schoolYear['total_instructional_weeks'] ?? ''),
|
||||
'date_age_reference' => $ageReferenceDate,
|
||||
'refund_deadline' => $ageReferenceDate,
|
||||
'school_year_start_date' => $yearStart,
|
||||
@@ -409,6 +419,13 @@ final class SchoolYearManagementService
|
||||
}
|
||||
}
|
||||
|
||||
private function configurationTotalInstructionalWeeks(): int
|
||||
{
|
||||
$weeks = filter_var($this->configurationModel->getConfig('total_instructional_weeks'), FILTER_VALIDATE_INT);
|
||||
|
||||
return $weeks !== false && $weeks > 0 ? (int) $weeks : 0;
|
||||
}
|
||||
|
||||
private function withCalendarDefaults(array $payload, string $schoolYearName): array
|
||||
{
|
||||
$calendar = $this->calendarPayloadForSchoolYear($schoolYearName);
|
||||
|
||||
@@ -42,6 +42,14 @@ final class SchoolYearValidationService
|
||||
}
|
||||
|
||||
$this->dateOrNull($payload['fall_makeup_exam_on'] ?? null);
|
||||
|
||||
$weeks = $payload['total_instructional_weeks'] ?? null;
|
||||
if ($weeks !== null && trim((string) $weeks) !== '') {
|
||||
$parsed = filter_var($weeks, FILTER_VALIDATE_INT);
|
||||
if ($parsed === false || $parsed <= 0) {
|
||||
throw new InvalidArgumentException('Total instructional weeks must be a positive whole number.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function isValidYearName(string $value): bool
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class StudentBookIssueService
|
||||
{
|
||||
public function __construct(private readonly BaseConnection $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically distributes one copy of a book to each selected student.
|
||||
* Existing active issues are skipped; every new issue snapshots the price.
|
||||
*
|
||||
* @param list<int> $studentIds
|
||||
* @return array{issued:int,skipped:int,issue_ids:list<int>,unit_charge_price_cents:int,on_hand:int}
|
||||
*/
|
||||
public function distributeBatch(
|
||||
int $inventoryItemId,
|
||||
array $studentIds,
|
||||
int $classSectionId,
|
||||
int $schoolYearId,
|
||||
string $schoolYear,
|
||||
?int $actorId,
|
||||
?string $note = null,
|
||||
?string $issuedAt = null,
|
||||
?string $batchKey = null
|
||||
): array {
|
||||
$this->assertTables();
|
||||
$schoolYear = trim($schoolYear);
|
||||
if ($inventoryItemId <= 0 || $classSectionId <= 0 || $schoolYearId <= 0 || $schoolYear === '') {
|
||||
throw new InvalidArgumentException('Book, class section, and school year are required.');
|
||||
}
|
||||
|
||||
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0)));
|
||||
if ($studentIds === []) {
|
||||
return ['issued' => 0, 'skipped' => 0, 'issue_ids' => [], 'unit_charge_price_cents' => 0, 'on_hand' => $this->legacyOnHand($inventoryItemId)];
|
||||
}
|
||||
|
||||
$issuedAt = $issuedAt !== null ? trim($issuedAt) : date('Y-m-d H:i:s');
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $issuedAt)) {
|
||||
throw new InvalidArgumentException('Issue time must use Y-m-d H:i:s.');
|
||||
}
|
||||
$batchKey = trim((string) $batchKey);
|
||||
if ($batchKey === '') {
|
||||
$batchKey = hash('sha256', implode('|', [
|
||||
$schoolYear, $inventoryItemId, $classSectionId, $actorId ?? 0,
|
||||
$issuedAt, implode(',', $studentIds),
|
||||
]));
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$book = $this->db->query(
|
||||
'SELECT id, type, name, is_active FROM inventory_items WHERE id = ? FOR UPDATE',
|
||||
[$inventoryItemId]
|
||||
)->getRowArray();
|
||||
if ($book === null || ($book['type'] ?? '') !== 'book' || (int) ($book['is_active'] ?? 1) !== 1) {
|
||||
throw new InvalidArgumentException('The selected book is missing or inactive.');
|
||||
}
|
||||
|
||||
$itemYear = $this->db->query(
|
||||
'SELECT * FROM inventory_item_years WHERE inventory_item_id = ? AND school_year_id = ? AND school_year = ? FOR UPDATE',
|
||||
[$inventoryItemId, $schoolYearId, $schoolYear]
|
||||
)->getRowArray();
|
||||
if ($itemYear === null || ($itemYear['status'] ?? '') !== 'open') {
|
||||
throw new InvalidArgumentException('The selected book does not have an open inventory record for this school year.');
|
||||
}
|
||||
$priceCents = (int) ($itemYear['charge_price_cents'] ?? 0);
|
||||
if ($priceCents <= 0 || (int) ($itemYear['price_confirmed'] ?? 0) !== 1) {
|
||||
throw new InvalidArgumentException('Enter and confirm this book’s school-year charge price before distribution.');
|
||||
}
|
||||
|
||||
$enrollments = [];
|
||||
$toIssue = [];
|
||||
$skipped = 0;
|
||||
foreach ($studentIds as $studentId) {
|
||||
$existing = $this->db->table('student_book_issues')
|
||||
->select('id')
|
||||
->where('student_id', $studentId)
|
||||
->where('inventory_item_year_id', (int) $itemYear['id'])
|
||||
->where('status', 'issued')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if ($existing !== null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$enrollment = $this->db->table('enrollments')
|
||||
->select('id, parent_id, class_section_id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if ($enrollment === null || (int) ($enrollment['parent_id'] ?? 0) <= 0) {
|
||||
throw new InvalidArgumentException('Student #' . $studentId . ' has no valid enrollment for ' . $schoolYear . '.');
|
||||
}
|
||||
if ((int) ($enrollment['class_section_id'] ?? 0) > 0
|
||||
&& (int) $enrollment['class_section_id'] !== $classSectionId) {
|
||||
throw new InvalidArgumentException('Student #' . $studentId . ' is not enrolled in the selected class section.');
|
||||
}
|
||||
$enrollments[$studentId] = $enrollment;
|
||||
$toIssue[] = $studentId;
|
||||
}
|
||||
|
||||
$onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']);
|
||||
if (count($toIssue) > $onHand) {
|
||||
throw new InvalidArgumentException('Not enough stock: need ' . count($toIssue) . ', on hand ' . $onHand . '.');
|
||||
}
|
||||
|
||||
$issueIds = [];
|
||||
foreach ($toIssue as $studentId) {
|
||||
$enrollment = $enrollments[$studentId];
|
||||
$idempotencyKey = substr($batchKey . ':student:' . $studentId, 0, 160);
|
||||
$existingKey = $this->db->table('student_book_issues')
|
||||
->select('id')
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if ($existingKey !== null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$issue = [
|
||||
'student_id' => $studentId,
|
||||
'enrollment_id' => (int) $enrollment['id'],
|
||||
'parent_id' => (int) $enrollment['parent_id'],
|
||||
'inventory_item_id' => $inventoryItemId,
|
||||
'inventory_item_year_id' => (int) $itemYear['id'],
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_id' => $classSectionId,
|
||||
'quantity' => 1,
|
||||
'unit_charge_price_cents' => $priceCents,
|
||||
'total_charge_cents' => $priceCents,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'status' => 'issued',
|
||||
'issued_at' => $issuedAt,
|
||||
'issued_by' => $actorId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if (! $this->db->table('student_book_issues')->insert($issue)) {
|
||||
throw new RuntimeException('Unable to create a student book issue.');
|
||||
}
|
||||
$issueId = (int) $this->db->insertID();
|
||||
|
||||
$movement = [
|
||||
'item_id' => $inventoryItemId,
|
||||
'item_year_id' => (int) $itemYear['id'],
|
||||
'qty_change' => -1,
|
||||
'movement_type' => 'distribution',
|
||||
'reason' => 'Student book distribution',
|
||||
'note' => $note,
|
||||
'semester' => null,
|
||||
'school_year' => $schoolYear,
|
||||
'performed_by' => $actorId,
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'idempotency_key' => substr($idempotencyKey . ':movement', 0, 120),
|
||||
'status' => 'posted',
|
||||
'source_type' => 'student_book_issue',
|
||||
'source_id' => $issueId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if (! $this->db->table('inventory_movements')->insert($movement)) {
|
||||
throw new RuntimeException('Unable to create the issue stock movement.');
|
||||
}
|
||||
$movementId = (int) $this->db->insertID();
|
||||
if (! $this->db->table('student_book_issues')->where('id', $issueId)->update([
|
||||
'distribution_movement_id' => $movementId,
|
||||
'updated_at' => $now,
|
||||
])) {
|
||||
throw new RuntimeException('Unable to link the issue to its stock movement.');
|
||||
}
|
||||
$issueIds[] = $issueId;
|
||||
}
|
||||
|
||||
$onHand -= count($issueIds);
|
||||
$this->db->table('inventory_items')->where('id', $inventoryItemId)->update([
|
||||
'quantity' => max(0, $onHand),
|
||||
'updated_by' => $actorId,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if (! $this->db->transCommit()) {
|
||||
throw new RuntimeException('Unable to commit the book distribution.');
|
||||
}
|
||||
|
||||
return [
|
||||
'issued' => count($issueIds),
|
||||
'skipped' => $skipped,
|
||||
'issue_ids' => $issueIds,
|
||||
'unit_charge_price_cents' => $priceCents,
|
||||
'on_hand' => $onHand,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function reverseErroneousIssue(int $issueId, string $reason, ?int $actorId): void
|
||||
{
|
||||
$reason = trim($reason);
|
||||
if ($issueId <= 0 || $reason === '') {
|
||||
throw new InvalidArgumentException('Issue and correction reason are required.');
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$issue = $this->db->query('SELECT * FROM student_book_issues WHERE id = ? FOR UPDATE', [$issueId])->getRowArray();
|
||||
if ($issue === null || ($issue['status'] ?? '') !== 'issued') {
|
||||
throw new InvalidArgumentException('Only an active issue can be corrected.');
|
||||
}
|
||||
$itemYear = $this->db->query('SELECT * FROM inventory_item_years WHERE id = ? FOR UPDATE', [
|
||||
(int) $issue['inventory_item_year_id'],
|
||||
])->getRowArray();
|
||||
if ($itemYear === null || ($itemYear['status'] ?? '') !== 'open') {
|
||||
throw new InvalidArgumentException('Corrections are not allowed after the inventory year is closed.');
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$quantity = (int) $issue['quantity'];
|
||||
$movement = [
|
||||
'item_id' => (int) $issue['inventory_item_id'],
|
||||
'item_year_id' => (int) $issue['inventory_item_year_id'],
|
||||
'qty_change' => $quantity,
|
||||
'movement_type' => 'adjust',
|
||||
'reason' => 'Correction of erroneous book issue',
|
||||
'note' => $reason,
|
||||
'school_year' => (string) $issue['school_year'],
|
||||
'performed_by' => $actorId,
|
||||
'student_id' => (int) $issue['student_id'],
|
||||
'class_section_id' => (int) ($issue['class_section_id'] ?? 0) ?: null,
|
||||
'reversal_of_movement_id' => (int) ($issue['distribution_movement_id'] ?? 0) ?: null,
|
||||
'idempotency_key' => substr('student-book-issue-reversal:' . $issueId, 0, 120),
|
||||
'status' => 'posted',
|
||||
'source_type' => 'student_book_issue_reversal',
|
||||
'source_id' => $issueId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if (! $this->db->table('inventory_movements')->insert($movement)) {
|
||||
throw new RuntimeException('Unable to create the correction movement.');
|
||||
}
|
||||
$reversalMovementId = (int) $this->db->insertID();
|
||||
|
||||
$this->db->table('student_book_issues')->where('id', $issueId)->update([
|
||||
'status' => 'reversed',
|
||||
'reversed_at' => $now,
|
||||
'reversed_by' => $actorId,
|
||||
'reversal_reason' => $reason,
|
||||
'reversal_quantity' => $quantity,
|
||||
'reversal_movement_id' => $reversalMovementId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if ((int) ($issue['distribution_movement_id'] ?? 0) > 0) {
|
||||
$this->db->table('inventory_movements')
|
||||
->where('id', (int) $issue['distribution_movement_id'])
|
||||
->update(['status' => 'reversed', 'reversed_at' => $now, 'reversed_by' => $actorId]);
|
||||
}
|
||||
|
||||
$this->markPostedCalculationsForReview($issueId, (int) $issue['student_id'], (string) $issue['school_year'], $now);
|
||||
|
||||
$onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']);
|
||||
$this->db->table('inventory_items')->where('id', (int) $issue['inventory_item_id'])->update([
|
||||
'quantity' => max(0, $onHand),
|
||||
'updated_by' => $actorId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (! $this->db->transCommit()) {
|
||||
throw new RuntimeException('Unable to commit the issue correction.');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function activeIssuesAsOf(int $studentId, string $schoolYear, string $date): array
|
||||
{
|
||||
$dateSql = $this->db->escape($date);
|
||||
|
||||
return $this->db->table('student_book_issues sbi')
|
||||
->select('sbi.*, i.name AS book_name, i.isbn, i.edition')
|
||||
->join('inventory_items i', 'i.id = sbi.inventory_item_id', 'inner')
|
||||
->where('sbi.student_id', $studentId)
|
||||
->where('sbi.school_year', $schoolYear)
|
||||
->where('sbi.status', 'issued')
|
||||
->where('DATE(sbi.issued_at) <= ' . $dateSql, null, false)
|
||||
->orderBy('sbi.issued_at', 'ASC')
|
||||
->orderBy('sbi.id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
}
|
||||
|
||||
public function totalChargeCentsAsOf(int $studentId, string $schoolYear, string $date): int
|
||||
{
|
||||
$dateSql = $this->db->escape($date);
|
||||
|
||||
$row = $this->db->table('student_book_issues')
|
||||
->selectSum('total_charge_cents', 'total')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'issued')
|
||||
->where('DATE(issued_at) <= ' . $dateSql, null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function issueEvidenceAsOf(int $studentId, string $schoolYear, string $date): array
|
||||
{
|
||||
$dateSql = $this->db->escape($date);
|
||||
|
||||
return $this->db->table('student_book_issues sbi')
|
||||
->select('sbi.id, sbi.inventory_item_id, sbi.quantity, sbi.unit_charge_price_cents, sbi.total_charge_cents, sbi.status, sbi.issued_at, sbi.reversed_at, sbi.reversal_reason, sbi.reversal_quantity, sbi.reversal_movement_id, i.name AS book_name, i.isbn, i.edition')
|
||||
->join('inventory_items i', 'i.id = sbi.inventory_item_id', 'inner')
|
||||
->where('sbi.student_id', $studentId)
|
||||
->where('sbi.school_year', $schoolYear)
|
||||
->where('DATE(sbi.issued_at) <= ' . $dateSql, null, false)
|
||||
->orderBy('sbi.issued_at', 'ASC')
|
||||
->orderBy('sbi.id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
}
|
||||
|
||||
private function onHandForItemYear(int $itemYearId, int $openingQuantity): int
|
||||
{
|
||||
$row = $this->db->table('inventory_movements')
|
||||
->selectSum('qty_change', 'movement_total')
|
||||
->where('item_year_id', $itemYearId)
|
||||
->whereIn('status', ['posted', 'reversed'])
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return $openingQuantity + (int) ($row['movement_total'] ?? 0);
|
||||
}
|
||||
|
||||
private function legacyOnHand(int $itemId): int
|
||||
{
|
||||
$row = $this->db->table('inventory_items')->select('quantity')->where('id', $itemId)->get(1)->getRowArray();
|
||||
return (int) ($row['quantity'] ?? 0);
|
||||
}
|
||||
|
||||
private function markPostedCalculationsForReview(int $issueId, int $studentId, string $schoolYear, string $now): void
|
||||
{
|
||||
if (! $this->db->tableExists('withdrawal_financial_calculations')) {
|
||||
return;
|
||||
}
|
||||
$calculations = $this->db->table('withdrawal_financial_calculations')
|
||||
->select('id, book_evidence_json')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'posted')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($calculations as $calculation) {
|
||||
$evidence = json_decode((string) ($calculation['book_evidence_json'] ?? '[]'), true);
|
||||
$ids = array_map('intval', array_column(is_array($evidence) ? $evidence : [], 'id'));
|
||||
if (! in_array($issueId, $ids, true)) {
|
||||
continue;
|
||||
}
|
||||
$calculationId = (int) $calculation['id'];
|
||||
$this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([
|
||||
'status' => 'requires_review',
|
||||
'active_posted_key' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if ($this->db->fieldExists('withdrawal_calculation_id', 'refunds')) {
|
||||
$this->db->table('refunds')->where('withdrawal_calculation_id', $calculationId)->update([
|
||||
'reconciliation_status' => 'requires_review',
|
||||
'reconciliation_reason' => 'Book issue #' . $issueId . ' was corrected after posting.',
|
||||
'reconciliation_required_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTables(): void
|
||||
{
|
||||
foreach (['inventory_item_years', 'student_book_issues', 'inventory_movements', 'enrollments'] as $table) {
|
||||
if (! $this->db->tableExists($table)) {
|
||||
throw new RuntimeException('Required table is missing: ' . $table . '. Run migrations first.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Libraries\RefundEligibilityService;
|
||||
use App\Models\ConfigurationModel;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Authoritative withdrawal financial workflow.
|
||||
*
|
||||
* Preview writes immutable versioned evidence but no invoice/refund entitlement.
|
||||
* Post locks the source rows, appends idempotent invoice adjustments, refreshes
|
||||
* the ledger, and creates at most one invoice-backed withdrawal refund claim.
|
||||
*/
|
||||
final class WithdrawalFinancialService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BaseConnection $db,
|
||||
private readonly WithdrawalRefundCalculator $calculator,
|
||||
private readonly StudentBookIssueService $bookIssues,
|
||||
private readonly InvoiceLedgerService $invoiceLedger,
|
||||
private readonly RefundEligibilityService $refundEligibility
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function requestWithdrawal(int $enrollmentId, string $requestDate, ?int $actorId): array
|
||||
{
|
||||
$this->assertTables();
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$enrollment = $this->lockEnrollment($enrollmentId);
|
||||
$status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? '')));
|
||||
if (! in_array($status, ['enrolled', 'payment pending', 'withdraw under review'], true)) {
|
||||
throw new InvalidArgumentException('Only an active enrollment can request withdrawal.');
|
||||
}
|
||||
$requestDate = $this->validDate($requestDate, 'Withdrawal request date');
|
||||
$storedRequestDate = trim((string) ($enrollment['withdrawal_date'] ?? ''));
|
||||
if ($storedRequestDate !== '') {
|
||||
$requestDate = $this->validDate($storedRequestDate, 'Existing withdrawal request date');
|
||||
}
|
||||
$this->db->table('enrollments')->where('id', $enrollmentId)->update([
|
||||
'withdrawal_date' => $requestDate,
|
||||
'is_withdrawn' => 1,
|
||||
'enrollment_status' => 'withdraw under review',
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
|
||||
$result = $this->createPreviewLocked($enrollmentId, $actorId, []);
|
||||
$this->commitOrFail('Unable to save the withdrawal request.');
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{enrollment_date?:string,withdrawal_request_date?:string,override_reason?:string,legacy_invoice_confirmed?:bool} $overrides
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function preview(int $enrollmentId, ?int $actorId, array $overrides = []): array
|
||||
{
|
||||
$this->assertTables();
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$result = $this->createPreviewLocked($enrollmentId, $actorId, $overrides);
|
||||
$this->commitOrFail('Unable to save the withdrawal calculation preview.');
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function post(int $calculationId, ?int $actorId): array
|
||||
{
|
||||
$this->assertTables();
|
||||
$this->db->transBegin();
|
||||
$transactionClosed = false;
|
||||
try {
|
||||
$calculation = $this->db->query(
|
||||
'SELECT * FROM withdrawal_financial_calculations WHERE id = ? FOR UPDATE',
|
||||
[$calculationId]
|
||||
)->getRowArray();
|
||||
if ($calculation === null) {
|
||||
throw new InvalidArgumentException('Withdrawal calculation not found.');
|
||||
}
|
||||
if (! in_array((string) ($calculation['status'] ?? ''), ['preview', 'requires_review'], true)) {
|
||||
if (($calculation['status'] ?? '') === 'posted') {
|
||||
$this->db->transCommit();
|
||||
$transactionClosed = true;
|
||||
return $this->details($calculationId);
|
||||
}
|
||||
throw new InvalidArgumentException('Only the latest preview can be posted.');
|
||||
}
|
||||
|
||||
$enrollment = $this->lockEnrollment((int) $calculation['enrollment_id']);
|
||||
$invoiceId = (int) ($calculation['invoice_id'] ?? 0);
|
||||
if ($invoiceId <= 0) {
|
||||
throw new RuntimeException('Resolve the invoice blocker before confirming this withdrawal.');
|
||||
}
|
||||
$invoice = $this->lockInvoice($invoiceId);
|
||||
$year = $this->lockSchoolYear((string) $calculation['school_year']);
|
||||
|
||||
$fresh = $this->buildSnapshot($enrollment, $actorId, [
|
||||
'enrollment_date' => (string) $calculation['enrollment_date'],
|
||||
'withdrawal_request_date' => (string) $calculation['withdrawal_request_date'],
|
||||
'override_reason' => (string) ($calculation['override_reason'] ?? ''),
|
||||
'legacy_invoice_confirmed' => str_contains((string) ($calculation['override_reason'] ?? ''), '[legacy invoice confirmed]'),
|
||||
], $invoice);
|
||||
if ($fresh['blockers'] !== []) {
|
||||
$this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([
|
||||
'status' => 'requires_review',
|
||||
'explanation_json' => json_encode($fresh['explanation'], JSON_UNESCAPED_SLASHES),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
$this->commitOrFail('Unable to mark the calculation for review.');
|
||||
$transactionClosed = true;
|
||||
throw new RuntimeException('The calculation has blockers and was marked for review: ' . implode(' ', $fresh['blockers']));
|
||||
}
|
||||
$gateBlockers = $this->postingGateBlockersForYear($year);
|
||||
if ($gateBlockers !== []) {
|
||||
throw new RuntimeException(implode(' ', $gateBlockers));
|
||||
}
|
||||
if (! hash_equals((string) $calculation['calculation_hash'], (string) $fresh['row']['calculation_hash'])) {
|
||||
$this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([
|
||||
'status' => 'requires_review',
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
$this->commitOrFail('Unable to mark the stale calculation for review.');
|
||||
$transactionClosed = true;
|
||||
throw new RuntimeException('The source data changed after preview. Generate and review a new calculation.');
|
||||
}
|
||||
|
||||
$this->lockRefundRows($invoiceId);
|
||||
$previous = $this->db->query(
|
||||
"SELECT * FROM withdrawal_financial_calculations
|
||||
WHERE enrollment_id = ?
|
||||
AND (status = 'posted' OR (status = 'requires_review' AND posted_at IS NOT NULL))
|
||||
AND id != ? FOR UPDATE",
|
||||
[(int) $enrollment['id'], $calculationId]
|
||||
)->getResultArray();
|
||||
foreach ($previous as $old) {
|
||||
$this->supersedePostedCalculation((int) $old['id'], $calculationId);
|
||||
}
|
||||
|
||||
$this->appendInvoiceLines($invoice, $calculation, $fresh['books']);
|
||||
$ledger = $this->invoiceLedger->recalculateInvoice($invoiceId);
|
||||
$refund = $this->syncRefundRequest($calculation, $ledger, $actorId);
|
||||
$creditCents = (int) ($ledger['customerCreditCents'] ?? 0);
|
||||
$balanceCents = (int) ($ledger['balanceDueCents'] ?? 0);
|
||||
|
||||
$this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([
|
||||
'status' => 'posted',
|
||||
'active_posted_key' => 'withdrawal-enrollment:' . (int) $enrollment['id'],
|
||||
'adjusted_invoice_charge_cents' => (int) ($ledger['net_charge_cents'] ?? 0),
|
||||
'refundable_credit_cents' => $creditCents,
|
||||
'new_refund_request_cents' => (int) ($refund['requested_amount_cents'] ?? 0),
|
||||
'balance_due_cents' => $balanceCents,
|
||||
'posted_by' => $actorId,
|
||||
'posted_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
$this->db->table('enrollments')->where('id', (int) $enrollment['id'])->update([
|
||||
'enrollment_status' => $creditCents > 0 ? 'refund pending' : 'withdrawn',
|
||||
'is_withdrawn' => 1,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
$this->commitOrFail('Unable to post the withdrawal calculation.');
|
||||
$transactionClosed = true;
|
||||
|
||||
return $this->details($calculationId);
|
||||
} catch (Throwable $e) {
|
||||
if (! $transactionClosed) {
|
||||
$this->db->transRollback();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function postingGateBlockers(array $calculation): array
|
||||
{
|
||||
$yearName = trim((string) ($calculation['school_year'] ?? ''));
|
||||
if ($yearName === '') {
|
||||
return ['School year configuration was not found.'];
|
||||
}
|
||||
|
||||
$year = $this->db->table('school_years')->where('name', $yearName)->get(1)->getRowArray();
|
||||
if ($year === null) {
|
||||
return ['School year configuration was not found.'];
|
||||
}
|
||||
|
||||
return $this->postingGateBlockersForYear($year);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function latestForEnrollment(int $enrollmentId): ?array
|
||||
{
|
||||
$row = $this->db->table('withdrawal_financial_calculations')
|
||||
->where('enrollment_id', $enrollmentId)
|
||||
->orderBy('version', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
return $row === null ? null : $this->decodeCalculation($row);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function details(int $calculationId): array
|
||||
{
|
||||
$row = $this->db->table('withdrawal_financial_calculations wfc')
|
||||
->select('wfc.*, s.firstname AS student_firstname, s.lastname AS student_lastname, i.invoice_number')
|
||||
->join('students s', 's.id = wfc.student_id', 'left')
|
||||
->join('invoices i', 'i.id = wfc.invoice_id', 'left')
|
||||
->where('wfc.id', $calculationId)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if ($row === null) {
|
||||
throw new InvalidArgumentException('Withdrawal calculation not found.');
|
||||
}
|
||||
|
||||
return $this->decodeCalculation($row);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function postingGateBlockersForYear(array $year): array
|
||||
{
|
||||
$blockers = [];
|
||||
if ($this->totalInstructionalWeeks() <= 0) {
|
||||
$blockers[] = 'Set a positive total_instructional_weeks value in configuration.';
|
||||
}
|
||||
if ((int) ($year['annual_fee_includes_books'] ?? 0) !== 1) {
|
||||
$blockers[] = 'The annual fee must be marked as book-inclusive.';
|
||||
}
|
||||
$missingBookPrices = $this->missingBookPriceCount((string) ($year['name'] ?? ''));
|
||||
if ($missingBookPrices > 0) {
|
||||
$blockers[] = $missingBookPrices . ' book price(s) must be confirmed before withdrawal refunds can be posted.';
|
||||
}
|
||||
|
||||
return $blockers;
|
||||
}
|
||||
|
||||
private function missingBookPriceCount(string $schoolYear): int
|
||||
{
|
||||
if ($schoolYear === ''
|
||||
|| ! $this->db->tableExists('inventory_item_years')
|
||||
|| ! $this->db->tableExists('inventory_items')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->db->table('inventory_item_years iy')
|
||||
->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner')
|
||||
->where('iy.school_year', $schoolYear)
|
||||
->where('i.type', 'book')
|
||||
->groupStart()
|
||||
->where('iy.charge_price_cents <=', 0)
|
||||
->orWhere('iy.price_confirmed !=', 1)
|
||||
->groupEnd()
|
||||
->countAllResults();
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function calculationsForInvoice(int $invoiceId): array
|
||||
{
|
||||
$rows = $this->db->table('withdrawal_financial_calculations wfc')
|
||||
->select('wfc.*, s.firstname AS student_firstname, s.lastname AS student_lastname, i.invoice_number')
|
||||
->join('students s', 's.id = wfc.student_id', 'left')
|
||||
->join('invoices i', 'i.id = wfc.invoice_id', 'left')
|
||||
->where('wfc.invoice_id', $invoiceId)
|
||||
->whereIn('wfc.status', ['posted', 'requires_review'])
|
||||
->orderBy('wfc.withdrawal_request_date', 'ASC')
|
||||
->orderBy('wfc.student_id', 'ASC')
|
||||
->orderBy('wfc.version', 'DESC')
|
||||
->get()->getResultArray();
|
||||
$seen = [];
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$enrollmentId = (int) $row['enrollment_id'];
|
||||
if (isset($seen[$enrollmentId])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$enrollmentId] = true;
|
||||
$result[] = $this->decodeCalculation($row);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function markCalculationsForIssueCorrection(int $studentId, string $schoolYear, int $issueId): void
|
||||
{
|
||||
if (! $this->db->tableExists('withdrawal_financial_calculations')) {
|
||||
return;
|
||||
}
|
||||
$rows = $this->db->table('withdrawal_financial_calculations')
|
||||
->select('id, book_evidence_json, status')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'posted')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
$evidence = json_decode((string) ($row['book_evidence_json'] ?? '[]'), true);
|
||||
$issueIds = array_map('intval', array_column(is_array($evidence) ? $evidence : [], 'id'));
|
||||
if (in_array($issueId, $issueIds, true)) {
|
||||
$this->db->table('withdrawal_financial_calculations')->where('id', (int) $row['id'])->update([
|
||||
'status' => 'requires_review',
|
||||
'active_posted_key' => null,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
$this->db->table('refunds')->where('withdrawal_calculation_id', (int) $row['id'])->update([
|
||||
'reconciliation_status' => 'requires_review',
|
||||
'reconciliation_reason' => 'Book issue #' . $issueId . ' was corrected after the withdrawal calculation was posted.',
|
||||
'reconciliation_required_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function createPreviewLocked(int $enrollmentId, ?int $actorId, array $overrides): array
|
||||
{
|
||||
$enrollment = $this->lockEnrollment($enrollmentId);
|
||||
$invoiceResolution = $this->resolveInvoice((int) $enrollment['parent_id'], (string) $enrollment['school_year']);
|
||||
$invoice = $invoiceResolution['invoice'];
|
||||
$snapshot = $this->buildSnapshot($enrollment, $actorId, $overrides, $invoice);
|
||||
$snapshot['blockers'] = array_values(array_unique(array_merge($invoiceResolution['blockers'], $snapshot['blockers'])));
|
||||
$snapshot['explanation']['blockers'] = $snapshot['blockers'];
|
||||
$snapshot['row']['status'] = $snapshot['blockers'] === [] ? 'preview' : 'requires_review';
|
||||
$snapshot['row']['explanation_json'] = json_encode($snapshot['explanation'], JSON_UNESCAPED_SLASHES);
|
||||
$snapshot['row']['calculation_hash'] = $this->snapshotHash($snapshot['row'], $snapshot['books'], $snapshot['explanation']);
|
||||
|
||||
$latest = $this->db->query(
|
||||
'SELECT * FROM withdrawal_financial_calculations WHERE enrollment_id = ? ORDER BY version DESC LIMIT 1 FOR UPDATE',
|
||||
[$enrollmentId]
|
||||
)->getRowArray();
|
||||
if ($latest !== null
|
||||
&& in_array((string) ($latest['status'] ?? ''), ['preview', 'requires_review'], true)
|
||||
&& hash_equals((string) ($latest['calculation_hash'] ?? ''), (string) $snapshot['row']['calculation_hash'])) {
|
||||
return $this->details((int) $latest['id']);
|
||||
}
|
||||
|
||||
$snapshot['row']['version'] = ((int) ($latest['version'] ?? 0)) + 1;
|
||||
$this->db->table('withdrawal_financial_calculations')->insert($snapshot['row']);
|
||||
$id = (int) $this->db->insertID();
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('Unable to persist the withdrawal calculation.');
|
||||
}
|
||||
$this->db->table('withdrawal_financial_calculations')
|
||||
->where('enrollment_id', $enrollmentId)
|
||||
->where('id !=', $id)
|
||||
->whereIn('status', ['preview', 'requires_review'])
|
||||
->where('posted_at', null)
|
||||
->update(['status' => 'superseded', 'superseded_by_id' => $id, 'updated_at' => utc_now()]);
|
||||
|
||||
return $this->details($id);
|
||||
}
|
||||
|
||||
/** @return array{row:array<string,mixed>,books:list<array<string,mixed>>,blockers:list<string>,explanation:array<string,mixed>} */
|
||||
private function buildSnapshot(array $enrollment, ?int $actorId, array $overrides, ?array $invoice): array
|
||||
{
|
||||
$year = $this->lockSchoolYear((string) $enrollment['school_year']);
|
||||
$blockers = [];
|
||||
$weeks = $this->totalInstructionalWeeks();
|
||||
if ($weeks <= 0) {
|
||||
$blockers[] = 'Set a positive total_instructional_weeks value in configuration.';
|
||||
}
|
||||
if ((int) ($year['annual_fee_includes_books'] ?? 0) !== 1) {
|
||||
$blockers[] = 'The annual fee must be marked as book-inclusive.';
|
||||
}
|
||||
|
||||
$storedEnrollmentDate = $this->validDate((string) ($enrollment['enrollment_date'] ?? ''), 'Enrollment date');
|
||||
$storedWithdrawalDate = $this->validDate((string) ($enrollment['withdrawal_date'] ?? date('Y-m-d')), 'Withdrawal request date');
|
||||
$enrollmentDate = isset($overrides['enrollment_date']) && trim((string) $overrides['enrollment_date']) !== ''
|
||||
? $this->validDate((string) $overrides['enrollment_date'], 'Corrected enrollment date')
|
||||
: $storedEnrollmentDate;
|
||||
$withdrawalDate = isset($overrides['withdrawal_request_date']) && trim((string) $overrides['withdrawal_request_date']) !== ''
|
||||
? $this->validDate((string) $overrides['withdrawal_request_date'], 'Corrected withdrawal date')
|
||||
: $storedWithdrawalDate;
|
||||
$overrideReason = trim((string) ($overrides['override_reason'] ?? ''));
|
||||
$dateChanged = $enrollmentDate !== $storedEnrollmentDate || $withdrawalDate !== $storedWithdrawalDate;
|
||||
$legacyConfirmed = ! empty($overrides['legacy_invoice_confirmed']);
|
||||
if (($dateChanged || $legacyConfirmed) && $overrideReason === '') {
|
||||
throw new InvalidArgumentException('A reason is required for date corrections or legacy invoice confirmation.');
|
||||
}
|
||||
if ($legacyConfirmed && ! str_contains($overrideReason, '[legacy invoice confirmed]')) {
|
||||
$overrideReason .= ($overrideReason === '' ? '' : ' ') . '[legacy invoice confirmed]';
|
||||
}
|
||||
|
||||
$allocation = $this->annualAllocationForEnrollment($enrollment, $invoice);
|
||||
$books = $this->bookIssues->issueEvidenceAsOf((int) $enrollment['student_id'], (string) $enrollment['school_year'], $withdrawalDate);
|
||||
$activeBooks = array_values(array_filter($books, static fn (array $book): bool => ($book['status'] ?? '') === 'issued'));
|
||||
$bookCharge = array_sum(array_map(static fn (array $book): int => (int) ($book['total_charge_cents'] ?? 0), $activeBooks));
|
||||
if ($bookCharge > $allocation['annual_allocation_cents']) {
|
||||
$blockers[] = 'Issued-book charges exceed this student’s annual tuition allocation.';
|
||||
}
|
||||
|
||||
$ledger = null;
|
||||
$originalInvoiceCharge = 0;
|
||||
$validPayments = 0;
|
||||
$completedPayouts = 0;
|
||||
$otherCharges = 0;
|
||||
$baseGrossCharge = 0;
|
||||
$baseDiscountEligible = 0;
|
||||
$requestedDiscount = 0;
|
||||
if ($invoice !== null) {
|
||||
$this->lockInvoice((int) $invoice['id']);
|
||||
$ledger = $this->invoiceLedger->calculateInvoice((int) $invoice['id']);
|
||||
$existingTargetAdjustment = $this->db->table('invoice_lines il')
|
||||
->selectSum('il.line_amount_cents', 'total')
|
||||
->select("COALESCE(SUM(CASE WHEN il.discount_eligible = 1 THEN il.line_amount_cents ELSE 0 END), 0) AS eligible_total", false)
|
||||
->join('withdrawal_financial_calculations wfc', 'wfc.id = il.source_id AND il.source_type = \'withdrawal_calculation\'', 'inner')
|
||||
->where('il.invoice_id', (int) $invoice['id'])
|
||||
->where('wfc.enrollment_id', (int) $enrollment['id'])
|
||||
->where('il.voided_at', null)
|
||||
->get()->getRowArray();
|
||||
$baseGrossCharge = (int) ($ledger['gross_charge_cents'] ?? 0) - (int) ($existingTargetAdjustment['total'] ?? 0);
|
||||
$baseDiscountEligible = max(0, (int) ($ledger['discount_eligible_base_cents'] ?? 0) - (int) ($existingTargetAdjustment['eligible_total'] ?? 0));
|
||||
$requestedDiscount = max(0, (int) ($ledger['requested_discount_cents'] ?? 0));
|
||||
$originalInvoiceCharge = max(0, $baseGrossCharge - min($requestedDiscount, $baseDiscountEligible));
|
||||
$validPayments = (int) ($ledger['paidCents'] ?? 0);
|
||||
$completedPayouts = (int) ($ledger['completedRefundCents'] ?? 0);
|
||||
$otherCharges = max(0, (int) ($ledger['eventCents'] ?? 0) + (int) ($ledger['additionalCents'] ?? 0));
|
||||
foreach ($this->invoiceReconstructionBlockers((int) $invoice['id'], $allocation['original_family_tuition_cents'], $allocation['original_student_count'], $legacyConfirmed) as $blocker) {
|
||||
$blockers[] = $blocker;
|
||||
}
|
||||
}
|
||||
|
||||
$schoolStart = trim((string) ($year['starts_on'] ?? ''));
|
||||
if ($schoolStart === '') {
|
||||
$schoolStart = $enrollmentDate;
|
||||
$blockers[] = 'School-year start date is missing; the enrollment date was used only to display this blocked preview.';
|
||||
}
|
||||
$baseInput = [
|
||||
'annual_fee_allocation_cents' => $allocation['annual_allocation_cents'],
|
||||
'issued_book_charge_cents' => $bookCharge,
|
||||
'total_instructional_weeks' => max(1, $weeks),
|
||||
'school_year_start_date' => $schoolStart,
|
||||
'enrollment_date' => $enrollmentDate,
|
||||
'withdrawal_request_date' => $withdrawalDate,
|
||||
'annual_fee_includes_books' => true,
|
||||
];
|
||||
$studentCalculation = $this->calculator->calculate($baseInput);
|
||||
$adjustment = (int) $studentCalculation['retained_charge_cents'] - $allocation['annual_allocation_cents'];
|
||||
$newEligibleAdjustment = (int) $studentCalculation['earned_tuition_cents'] - $allocation['annual_allocation_cents'];
|
||||
$adjustedGrossCharge = $baseGrossCharge + $adjustment;
|
||||
$adjustedDiscountEligible = max(0, $baseDiscountEligible + $newEligibleAdjustment);
|
||||
$adjustedDiscount = min($requestedDiscount, $adjustedDiscountEligible);
|
||||
$adjustedInvoiceCharge = max(0, $adjustedGrossCharge - $adjustedDiscount);
|
||||
$netPayments = max(0, $validPayments - $completedPayouts);
|
||||
$refundableCredit = max(0, $netPayments - $adjustedInvoiceCharge);
|
||||
$balanceDue = max(0, $adjustedInvoiceCharge - $netPayments);
|
||||
// A withdrawal refund is one invoice-level family claim. Exclude that
|
||||
// claim when replacing it for a sibling, otherwise its old reservation
|
||||
// incorrectly suppresses the new family credit in the preview.
|
||||
$existingWithdrawalRefundId = $invoice === null ? null : $this->existingWithdrawalRefundId((int) $invoice['id']);
|
||||
$reservations = $invoice === null ? 0 : $this->openInvoiceReservations((int) $invoice['id'], $existingWithdrawalRefundId);
|
||||
$newRefund = max(0, $refundableCredit - $reservations);
|
||||
|
||||
$explanation = [
|
||||
'formula' => 'books + round((annual allocation - books) * studied weeks / total instructional weeks)',
|
||||
'no_school_days_subtracted' => false,
|
||||
'books_returnable' => false,
|
||||
'books_discount_eligible' => false,
|
||||
'allocation' => $allocation,
|
||||
'discount_projection' => [
|
||||
'requested_discount_cents' => $requestedDiscount,
|
||||
'adjusted_discount_eligible_cents' => $adjustedDiscountEligible,
|
||||
'adjusted_discount_cents' => $adjustedDiscount,
|
||||
'books_discount_eligible' => false,
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
$now = utc_now();
|
||||
$row = [
|
||||
'enrollment_id' => (int) $enrollment['id'],
|
||||
'student_id' => (int) $enrollment['student_id'],
|
||||
'parent_id' => (int) $enrollment['parent_id'],
|
||||
'invoice_id' => $invoice === null ? null : (int) $invoice['id'],
|
||||
'school_year' => (string) $enrollment['school_year'],
|
||||
'policy_version' => (string) ($year['withdrawal_policy_version'] ?? 'studied_weeks_v1'),
|
||||
'annual_fee_includes_books' => 1,
|
||||
'school_year_start_date' => $studentCalculation['school_year_start_date'],
|
||||
'enrollment_date' => $studentCalculation['enrollment_date'],
|
||||
'withdrawal_request_date' => $studentCalculation['withdrawal_request_date'],
|
||||
'total_instructional_weeks' => $weeks,
|
||||
'total_chargeable_days' => (int) $studentCalculation['total_chargeable_days'],
|
||||
'studied_calendar_days' => (int) $studentCalculation['studied_calendar_days'],
|
||||
'studied_weeks' => (int) $studentCalculation['studied_weeks'],
|
||||
'annual_fee_allocation_cents' => $allocation['annual_allocation_cents'],
|
||||
'issued_book_charge_cents' => $bookCharge,
|
||||
'annual_instruction_cents' => (int) $studentCalculation['annual_instruction_cents'],
|
||||
'earned_tuition_cents' => (int) $studentCalculation['earned_tuition_cents'],
|
||||
'other_charge_cents' => $otherCharges,
|
||||
'retained_charge_cents' => (int) $studentCalculation['retained_charge_cents'],
|
||||
'original_invoice_charge_cents' => $originalInvoiceCharge,
|
||||
'invoice_adjustment_cents' => $adjustment,
|
||||
'adjusted_invoice_charge_cents' => $adjustedInvoiceCharge,
|
||||
'valid_payment_cents' => $validPayments,
|
||||
'completed_payout_cents' => $completedPayouts,
|
||||
'open_reservation_cents' => $reservations,
|
||||
'refundable_credit_cents' => $refundableCredit,
|
||||
'new_refund_request_cents' => $newRefund,
|
||||
'balance_due_cents' => $balanceDue,
|
||||
'book_evidence_json' => json_encode($books, JSON_UNESCAPED_SLASHES),
|
||||
'explanation_json' => json_encode($explanation, JSON_UNESCAPED_SLASHES),
|
||||
'calculation_hash' => '',
|
||||
'active_posted_key' => null,
|
||||
'books_discount_eligible' => 0,
|
||||
'status' => $blockers === [] ? 'preview' : 'requires_review',
|
||||
'override_reason' => $overrideReason !== '' ? $overrideReason : null,
|
||||
'overridden_at' => $dateChanged || $legacyConfirmed ? $now : null,
|
||||
'overridden_by' => $dateChanged || $legacyConfirmed ? $actorId : null,
|
||||
'calculated_by' => $actorId,
|
||||
'calculated_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$row['calculation_hash'] = $this->snapshotHash($row, $books, $explanation);
|
||||
|
||||
return ['row' => $row, 'books' => $books, 'blockers' => $blockers, 'explanation' => $explanation];
|
||||
}
|
||||
|
||||
/** @return array<string,int> */
|
||||
private function annualAllocationForEnrollment(array $target, ?array $invoice = null): array
|
||||
{
|
||||
$rows = $this->db->table('enrollments')
|
||||
->select('id, student_id, enrollment_status, admission_status, withdrawal_date')
|
||||
->where('parent_id', (int) $target['parent_id'])
|
||||
->where('school_year', (string) $target['school_year'])
|
||||
->whereIn('enrollment_status', ['enrolled', 'payment pending', 'withdraw under review', 'refund pending', 'withdrawn'])
|
||||
->orderBy('id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$snapshotDetails = $invoice === null ? [] : $this->invoiceTuitionStudentDetails((int) $invoice['id']);
|
||||
$originalCount = $snapshotDetails !== [] && count($snapshotDetails) === count($rows)
|
||||
? count($snapshotDetails)
|
||||
: count($rows);
|
||||
$remainingCount = count(array_filter($rows, static fn (array $row): bool => in_array((string) $row['enrollment_status'], ['enrolled', 'payment pending'], true)));
|
||||
if ($originalCount <= 0) {
|
||||
throw new RuntimeException('Unable to reconstruct the family tuition stack.');
|
||||
}
|
||||
$config = new ConfigurationModel();
|
||||
$first = $this->moneyToCents($config->getConfig('first_student_fee') ?? 380);
|
||||
$additional = $this->moneyToCents($config->getConfig('second_student_fee') ?? 280);
|
||||
$withdrawals = array_values(array_filter($rows, static fn (array $row): bool => ! in_array((string) $row['enrollment_status'], ['enrolled', 'payment pending'], true)));
|
||||
usort($withdrawals, static fn (array $a, array $b): int => [strtotime((string) ($a['withdrawal_date'] ?? '')) ?: PHP_INT_MAX, (int) $a['student_id']] <=> [strtotime((string) ($b['withdrawal_date'] ?? '')) ?: PHP_INT_MAX, (int) $b['student_id']]);
|
||||
$targetIndex = null;
|
||||
foreach ($withdrawals as $index => $withdrawal) {
|
||||
if ((int) $withdrawal['id'] === (int) $target['id']) {
|
||||
$targetIndex = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($targetIndex === null) {
|
||||
throw new RuntimeException('The enrollment is not in a withdrawal state.');
|
||||
}
|
||||
$position = $originalCount - $targetIndex;
|
||||
if ($snapshotDetails !== [] && count($snapshotDetails) === count($rows)) {
|
||||
$allocation = (int) ($snapshotDetails[$position - 1]['annual_allocation_cents'] ?? 0);
|
||||
$familyTuition = array_sum(array_map(static fn (array $detail): int => (int) ($detail['annual_allocation_cents'] ?? 0), $snapshotDetails));
|
||||
if ($allocation <= 0 || $familyTuition <= 0) {
|
||||
throw new RuntimeException('The invoice student-level tuition snapshot is malformed.');
|
||||
}
|
||||
} else {
|
||||
$allocation = $position === 1 ? $first : $additional;
|
||||
$familyTuition = $originalCount <= 0 ? 0 : $first + max(0, $originalCount - 1) * $additional;
|
||||
}
|
||||
|
||||
return [
|
||||
'original_student_count' => $originalCount,
|
||||
'remaining_student_count' => $remainingCount,
|
||||
'withdrawal_stack_index' => $targetIndex,
|
||||
'annual_allocation_cents' => $allocation,
|
||||
'original_family_tuition_cents' => $familyTuition,
|
||||
];
|
||||
}
|
||||
|
||||
private function totalInstructionalWeeks(): int
|
||||
{
|
||||
$configWeeks = filter_var((new ConfigurationModel())->getConfig('total_instructional_weeks'), FILTER_VALIDATE_INT);
|
||||
|
||||
return $configWeeks !== false && $configWeeks > 0 ? (int) $configWeeks : 0;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function invoiceReconstructionBlockers(int $invoiceId, int $expectedFamilyTuition, int $expectedStudentCount, bool $legacyConfirmed): array
|
||||
{
|
||||
$lines = $this->db->table('invoice_lines')
|
||||
->select('line_type, source_type, line_amount_cents')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
$legacy = array_filter($lines, static fn (array $line): bool => ($line['source_type'] ?? '') === 'legacy_invoice');
|
||||
if ($legacy !== [] && ! $legacyConfirmed) {
|
||||
return ['This legacy aggregate invoice must be explicitly confirmed with an audit reason before withdrawal posting.'];
|
||||
}
|
||||
if ($legacy !== []) {
|
||||
return [];
|
||||
}
|
||||
$details = $this->invoiceTuitionStudentDetails($invoiceId);
|
||||
if ($details !== []) {
|
||||
if (count($details) !== $expectedStudentCount) {
|
||||
return ['The invoice student-level tuition snapshot does not match the family enrollment count.'];
|
||||
}
|
||||
$detailTotal = array_sum(array_map(static fn (array $detail): int => (int) ($detail['annual_allocation_cents'] ?? 0), $details));
|
||||
if ($detailTotal !== $expectedFamilyTuition) {
|
||||
return ['The invoice student-level tuition snapshot does not match the reconstructed family allocation.'];
|
||||
}
|
||||
}
|
||||
$baseTuition = 0;
|
||||
foreach ($lines as $line) {
|
||||
$type = (string) ($line['line_type'] ?? '');
|
||||
if (($line['source_type'] ?? '') === 'withdrawal_calculation' || str_contains($type, 'event') || str_contains($type, 'additional')) {
|
||||
continue;
|
||||
}
|
||||
$baseTuition += (int) ($line['line_amount_cents'] ?? 0);
|
||||
}
|
||||
return $baseTuition !== $expectedFamilyTuition
|
||||
? ['Frozen tuition lines do not match the reconstructed family tuition stack; reconcile the invoice before posting.']
|
||||
: [];
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function invoiceTuitionStudentDetails(int $invoiceId): array
|
||||
{
|
||||
$line = $this->db->table('invoice_lines')
|
||||
->select('metadata_json')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('line_type', 'tuition')
|
||||
->where('voided_at', null)
|
||||
->orderBy('id', 'ASC')
|
||||
->get(1)->getRowArray();
|
||||
$metadata = json_decode((string) ($line['metadata_json'] ?? ''), true);
|
||||
$details = is_array($metadata) ? ($metadata['tuition_student_details'] ?? []) : [];
|
||||
if (! is_array($details)) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_filter($details, static fn ($detail): bool => is_array($detail)
|
||||
&& (int) ($detail['student_id'] ?? 0) > 0
|
||||
&& (int) ($detail['annual_allocation_cents'] ?? 0) > 0));
|
||||
}
|
||||
|
||||
private function appendInvoiceLines(array $invoice, array $calculation, array $books): void
|
||||
{
|
||||
$invoiceId = (int) $invoice['id'];
|
||||
$calculationId = (int) $calculation['id'];
|
||||
$enrollmentId = (int) $calculation['enrollment_id'];
|
||||
$timestamp = utc_now();
|
||||
$base = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => (string) $calculation['school_year'],
|
||||
'source_type' => 'withdrawal_calculation',
|
||||
'source_id' => $calculationId,
|
||||
'quantity' => '1.00',
|
||||
'calculation_version' => (string) $calculation['policy_version'] . ':v' . (int) $calculation['version'],
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
'voided_at' => null,
|
||||
];
|
||||
$lines = [
|
||||
$base + [
|
||||
'line_type' => 'withdrawal_tuition_reversal',
|
||||
'active_source_key' => 'withdrawal:' . $enrollmentId . ':tuition-reversal',
|
||||
'description' => 'Withdrawal annual tuition allocation reversal',
|
||||
'unit_amount_cents' => -1 * (int) $calculation['annual_fee_allocation_cents'],
|
||||
'line_amount_cents' => -1 * (int) $calculation['annual_fee_allocation_cents'],
|
||||
'discount_eligible' => 1,
|
||||
'metadata_json' => json_encode(['calculation_id' => $calculationId, 'enrollment_id' => $enrollmentId], JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
$base + [
|
||||
'line_type' => 'withdrawal_earned_tuition',
|
||||
'active_source_key' => 'withdrawal:' . $enrollmentId . ':earned-tuition',
|
||||
'description' => 'Withdrawal earned tuition for ' . (int) $calculation['studied_weeks'] . ' studied week(s)',
|
||||
'unit_amount_cents' => (int) $calculation['earned_tuition_cents'],
|
||||
'line_amount_cents' => (int) $calculation['earned_tuition_cents'],
|
||||
'discount_eligible' => 1,
|
||||
'metadata_json' => json_encode(['calculation_id' => $calculationId, 'enrollment_id' => $enrollmentId, 'studied_weeks' => (int) $calculation['studied_weeks']], JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
];
|
||||
foreach ($books as $book) {
|
||||
if (($book['status'] ?? '') !== 'issued') {
|
||||
continue;
|
||||
}
|
||||
$issueId = (int) $book['id'];
|
||||
$amount = (int) $book['total_charge_cents'];
|
||||
$lines[] = $base + [
|
||||
'line_type' => 'withdrawal_retained_book',
|
||||
'active_source_key' => 'withdrawal:' . $enrollmentId . ':book-issue:' . $issueId,
|
||||
'description' => 'Retained issued book - ' . (string) ($book['book_name'] ?? ('Issue #' . $issueId)),
|
||||
'quantity' => number_format((int) ($book['quantity'] ?? 1), 2, '.', ''),
|
||||
'unit_amount_cents' => (int) $book['unit_charge_price_cents'],
|
||||
'line_amount_cents' => $amount,
|
||||
'discount_eligible' => 0,
|
||||
'metadata_json' => json_encode(['calculation_id' => $calculationId, 'enrollment_id' => $enrollmentId, 'book_issue_id' => $issueId, 'issued_at' => $book['issued_at'] ?? null], JSON_UNESCAPED_SLASHES),
|
||||
];
|
||||
}
|
||||
foreach ($lines as $line) {
|
||||
$existing = $this->db->table('invoice_lines')
|
||||
->select('id')
|
||||
->where('active_source_key', (string) $line['active_source_key'])
|
||||
->where('voided_at', null)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if ($existing !== null) {
|
||||
continue;
|
||||
}
|
||||
if (! $this->db->table('invoice_lines')->insert($line)) {
|
||||
throw new RuntimeException('Unable to append a withdrawal invoice line.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function syncRefundRequest(array $calculation, array $ledger, ?int $actorId): array
|
||||
{
|
||||
$invoiceId = (int) $calculation['invoice_id'];
|
||||
$existing = $this->db->table('refunds')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('source_type', 'tuition_withdrawal')
|
||||
->whereIn('status', ['Pending', 'pending', 'requested', 'Approved', 'approved', 'Partial', 'partial', 'partially_paid', 'Paid', 'paid'])
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
$existingId = (int) ($existing['id'] ?? 0);
|
||||
$reserved = $this->openInvoiceReservations($invoiceId, $existingId > 0 ? $existingId : null);
|
||||
$credit = max(0, (int) ($ledger['customerCreditCents'] ?? 0) - $reserved);
|
||||
$paid = $existingId > 0 ? $this->refundEligibility->getCompletedPayoutTotalCentsForRefund($existingId) : 0;
|
||||
$target = max($credit, $paid);
|
||||
if ($target <= 0 && $existingId <= 0) {
|
||||
return ['requested_amount_cents' => 0];
|
||||
}
|
||||
$payload = [
|
||||
'parent_id' => (int) $calculation['parent_id'],
|
||||
'school_year' => (string) $calculation['school_year'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'withdrawal_calculation_id' => (int) $calculation['id'],
|
||||
'refund_amount' => $target / 100,
|
||||
'requested_amount_cents' => $target,
|
||||
'currency' => 'USD',
|
||||
'refund_paid_amount' => $paid / 100,
|
||||
'request' => 'tuition',
|
||||
'source_type' => 'tuition_withdrawal',
|
||||
'source_id' => $invoiceId,
|
||||
'reason' => 'Posted withdrawal calculation #' . (int) $calculation['id'],
|
||||
'reconciliation_status' => null,
|
||||
'reconciliation_reason' => null,
|
||||
'reconciliation_required_at' => null,
|
||||
'updated_by' => $actorId,
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
if ($existingId > 0) {
|
||||
$status = FinancialStatus::normalizeRefundStatus($existing['status'] ?? null);
|
||||
if (in_array($status, [FinancialStatus::REFUND_APPROVED, FinancialStatus::REFUND_PARTIALLY_PAID, FinancialStatus::REFUND_PAID], true)) {
|
||||
$payload['approved_amount_cents'] = $target;
|
||||
if ($paid > $credit) {
|
||||
$payload['reconciliation_status'] = 'requires_review';
|
||||
$payload['reconciliation_reason'] = 'Completed payouts exceed the current adjusted invoice credit.';
|
||||
$payload['reconciliation_required_at'] = utc_now();
|
||||
}
|
||||
} else {
|
||||
$payload['status'] = 'Approved';
|
||||
$payload['approved_amount_cents'] = $target;
|
||||
$payload['approved_at'] = utc_now();
|
||||
$payload['approved_by'] = $actorId;
|
||||
}
|
||||
$this->db->table('refunds')->where('id', $existingId)->update($payload);
|
||||
return $payload + ['id' => $existingId];
|
||||
}
|
||||
$payload['status'] = 'Approved';
|
||||
$payload['requested_at'] = utc_now();
|
||||
$payload['approved_amount_cents'] = $target;
|
||||
$payload['approved_at'] = utc_now();
|
||||
$payload['approved_by'] = $actorId;
|
||||
$this->db->table('refunds')->insert($payload);
|
||||
return $payload + ['id' => (int) $this->db->insertID()];
|
||||
}
|
||||
|
||||
private function supersedePostedCalculation(int $oldId, int $newId): void
|
||||
{
|
||||
$now = utc_now();
|
||||
$this->db->table('invoice_lines')->where('source_type', 'withdrawal_calculation')->where('source_id', $oldId)->where('voided_at', null)->update([
|
||||
'active_source_key' => null,
|
||||
'voided_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->db->table('withdrawal_financial_calculations')->where('id', $oldId)->update([
|
||||
'status' => 'superseded',
|
||||
'active_posted_key' => null,
|
||||
'superseded_by_id' => $newId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array{invoice:?array,blockers:list<string>} */
|
||||
private function resolveInvoice(int $parentId, string $schoolYear): array
|
||||
{
|
||||
$rows = $this->db->table('invoices i')
|
||||
->select('i.*')
|
||||
->where('i.parent_id', $parentId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->where("LOWER(COALESCE(i.status,'')) NOT IN ('void','voided','cancelled','canceled')", null, false)
|
||||
->orderBy('i.id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
if (count($rows) === 1) {
|
||||
return ['invoice' => $rows[0], 'blockers' => []];
|
||||
}
|
||||
if ($rows === []) {
|
||||
return ['invoice' => null, 'blockers' => ['No active invoice exists for this parent and school year.']];
|
||||
}
|
||||
return ['invoice' => null, 'blockers' => ['Multiple active invoices exist. Reconcile duplicates before calculating a withdrawal; the system will never guess which invoice to use.']];
|
||||
}
|
||||
|
||||
private function openInvoiceReservations(int $invoiceId, ?int $excludeRefundId): int
|
||||
{
|
||||
$builder = $this->db->table('refunds')
|
||||
->select('id, refund_amount, requested_amount_cents, approved_amount_cents, refund_paid_amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Pending', 'pending', 'requested', 'Approved', 'approved', 'Partial', 'partial', 'partially_paid']);
|
||||
if ($excludeRefundId !== null) {
|
||||
$builder->where('id !=', $excludeRefundId);
|
||||
}
|
||||
$reserved = 0;
|
||||
foreach ($builder->get()->getResultArray() as $refund) {
|
||||
$amount = $refund['approved_amount_cents'] !== null
|
||||
? (int) $refund['approved_amount_cents']
|
||||
: ((int) ($refund['requested_amount_cents'] ?? 0) ?: $this->moneyToCents($refund['refund_amount'] ?? 0));
|
||||
$paid = $this->refundEligibility->getCompletedPayoutTotalCentsForRefund((int) $refund['id']);
|
||||
$reserved += max(0, $amount - $paid);
|
||||
}
|
||||
return $reserved;
|
||||
}
|
||||
|
||||
private function existingWithdrawalRefundId(int $invoiceId): ?int
|
||||
{
|
||||
$row = $this->db->table('refunds r')
|
||||
->select('r.id')
|
||||
->where('r.invoice_id', $invoiceId)
|
||||
->where('r.source_type', 'tuition_withdrawal')
|
||||
->orderBy('r.id', 'DESC')
|
||||
->get(1)->getRowArray();
|
||||
return $row === null ? null : (int) $row['id'];
|
||||
}
|
||||
|
||||
private function snapshotHash(array $row, array $books, array $explanation): string
|
||||
{
|
||||
foreach (['version', 'status', 'calculation_hash', 'active_posted_key', 'calculated_by', 'calculated_at', 'created_at', 'updated_at', 'posted_by', 'posted_at', 'overridden_at', 'overridden_by'] as $key) {
|
||||
unset($row[$key]);
|
||||
}
|
||||
return hash('sha256', json_encode([$row, $books, $explanation], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function decodeCalculation(array $row): array
|
||||
{
|
||||
$row['books'] = json_decode((string) ($row['book_evidence_json'] ?? '[]'), true) ?: [];
|
||||
$row['explanation'] = json_decode((string) ($row['explanation_json'] ?? '{}'), true) ?: [];
|
||||
$row['blockers'] = $row['explanation']['blockers'] ?? [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function lockEnrollment(int $id): array
|
||||
{
|
||||
$row = $this->db->query('SELECT * FROM enrollments WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||
if ($row === null) {
|
||||
throw new InvalidArgumentException('Enrollment not found.');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function lockInvoice(int $id): array
|
||||
{
|
||||
$row = $this->db->query('SELECT * FROM invoices WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||
if ($row === null) {
|
||||
throw new InvalidArgumentException('Invoice not found.');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function lockSchoolYear(string $name): array
|
||||
{
|
||||
$row = $this->db->query('SELECT * FROM school_years WHERE name = ? FOR UPDATE', [$name])->getRowArray();
|
||||
if ($row === null) {
|
||||
throw new RuntimeException('School-year policy record not found.');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function lockRefundRows(int $invoiceId): void
|
||||
{
|
||||
$this->db->query('SELECT id FROM refunds WHERE invoice_id = ? FOR UPDATE', [$invoiceId]);
|
||||
}
|
||||
|
||||
private function validDate(string $value, string $label): string
|
||||
{
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
|
||||
$errors = \DateTimeImmutable::getLastErrors();
|
||||
if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
|
||||
throw new InvalidArgumentException($label . ' must be a valid Y-m-d date.');
|
||||
}
|
||||
return $date->format('Y-m-d');
|
||||
}
|
||||
|
||||
private function moneyToCents(mixed $value): int
|
||||
{
|
||||
return (int) round(((float) $value) * 100);
|
||||
}
|
||||
|
||||
private function commitOrFail(string $message): void
|
||||
{
|
||||
if (! $this->db->transCommit()) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTables(): void
|
||||
{
|
||||
foreach (['withdrawal_financial_calculations', 'student_book_issues', 'invoice_lines', 'refunds', 'school_years'] as $table) {
|
||||
if (! $this->db->tableExists($table)) {
|
||||
throw new RuntimeException('Required table is missing: ' . $table . '. Run migrations first.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Pure withdrawal calculation. All monetary values are integer cents.
|
||||
*
|
||||
* This class deliberately has no database, session, calendar, attendance, or
|
||||
* active-school-year dependencies. Callers must pass snapshotted inputs.
|
||||
*/
|
||||
final class WithdrawalRefundCalculator
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* annual_fee_allocation_cents:int,
|
||||
* issued_book_charge_cents:int,
|
||||
* total_instructional_weeks:int,
|
||||
* school_year_start_date:string,
|
||||
* enrollment_date:string,
|
||||
* withdrawal_request_date:string,
|
||||
* valid_payment_cents?:int,
|
||||
* completed_payout_cents?:int,
|
||||
* open_reservation_cents?:int,
|
||||
* other_charge_cents?:int,
|
||||
* annual_fee_includes_books?:bool
|
||||
* } $input
|
||||
* @return array<string,int|bool|string>
|
||||
*/
|
||||
public function calculate(array $input): array
|
||||
{
|
||||
$annualFee = $this->nonNegativeInt($input, 'annual_fee_allocation_cents');
|
||||
$bookCharge = $this->nonNegativeInt($input, 'issued_book_charge_cents');
|
||||
$totalWeeks = $this->positiveInt($input, 'total_instructional_weeks');
|
||||
$validPayments = $this->optionalNonNegativeInt($input, 'valid_payment_cents');
|
||||
$completedPayouts = $this->optionalNonNegativeInt($input, 'completed_payout_cents');
|
||||
$openReservations = $this->optionalNonNegativeInt($input, 'open_reservation_cents');
|
||||
$otherCharges = $this->optionalNonNegativeInt($input, 'other_charge_cents');
|
||||
$includesBooks = (bool) ($input['annual_fee_includes_books'] ?? true);
|
||||
|
||||
if (! $includesBooks) {
|
||||
throw new InvalidArgumentException('The active withdrawal policy requires annual tuition to include books.');
|
||||
}
|
||||
if ($bookCharge > $annualFee) {
|
||||
throw new InvalidArgumentException('Issued-book charges exceed the student annual tuition allocation.');
|
||||
}
|
||||
|
||||
$schoolStart = $this->date($input, 'school_year_start_date');
|
||||
$enrollmentDate = $this->date($input, 'enrollment_date');
|
||||
$withdrawalDate = $this->date($input, 'withdrawal_request_date');
|
||||
$chargeStart = $enrollmentDate > $schoolStart ? $enrollmentDate : $schoolStart;
|
||||
|
||||
$totalChargeableDays = $totalWeeks * 7;
|
||||
$studiedDays = 0;
|
||||
if ($withdrawalDate >= $chargeStart) {
|
||||
$studiedDays = ((int) $chargeStart->diff($withdrawalDate)->format('%a')) + 1;
|
||||
}
|
||||
$studiedDays = min($totalChargeableDays, max(0, $studiedDays));
|
||||
$studiedWeeks = $studiedDays === 0
|
||||
? 0
|
||||
: min($totalWeeks, intdiv($studiedDays + 6, 7));
|
||||
|
||||
$annualInstruction = $annualFee - $bookCharge;
|
||||
$earnedTuition = $this->roundRatio($annualInstruction * $studiedWeeks, $totalWeeks);
|
||||
$retainedCharge = $bookCharge + $earnedTuition + $otherCharges;
|
||||
|
||||
$netPayments = max(0, $validPayments - $completedPayouts);
|
||||
$refundableCredit = max(0, $netPayments - $retainedCharge);
|
||||
$balanceDue = max(0, $retainedCharge - $netPayments);
|
||||
$newRefundRequest = max(0, $refundableCredit - $openReservations);
|
||||
|
||||
return [
|
||||
'annual_fee_includes_books' => true,
|
||||
'school_year_start_date' => $schoolStart->format('Y-m-d'),
|
||||
'enrollment_date' => $enrollmentDate->format('Y-m-d'),
|
||||
'withdrawal_request_date' => $withdrawalDate->format('Y-m-d'),
|
||||
'charge_start_date' => $chargeStart->format('Y-m-d'),
|
||||
'total_instructional_weeks' => $totalWeeks,
|
||||
'total_chargeable_days' => $totalChargeableDays,
|
||||
'studied_calendar_days' => $studiedDays,
|
||||
'studied_weeks' => $studiedWeeks,
|
||||
'annual_fee_allocation_cents' => $annualFee,
|
||||
'issued_book_charge_cents' => $bookCharge,
|
||||
'annual_instruction_cents' => $annualInstruction,
|
||||
'earned_tuition_cents' => $earnedTuition,
|
||||
'other_charge_cents' => $otherCharges,
|
||||
'retained_charge_cents' => $retainedCharge,
|
||||
'valid_payment_cents' => $validPayments,
|
||||
'completed_payout_cents' => $completedPayouts,
|
||||
'net_payment_cents' => $netPayments,
|
||||
'open_reservation_cents' => $openReservations,
|
||||
'refundable_credit_cents' => $refundableCredit,
|
||||
'new_refund_request_cents' => $newRefundRequest,
|
||||
'balance_due_cents' => $balanceDue,
|
||||
];
|
||||
}
|
||||
|
||||
private function date(array $input, string $key): DateTimeImmutable
|
||||
{
|
||||
$raw = trim((string) ($input[$key] ?? ''));
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw);
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
|
||||
throw new InvalidArgumentException($key . ' must be a valid Y-m-d date.');
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
private function positiveInt(array $input, string $key): int
|
||||
{
|
||||
$value = filter_var($input[$key] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($value === false || $value <= 0) {
|
||||
throw new InvalidArgumentException($key . ' must be a positive integer.');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function nonNegativeInt(array $input, string $key): int
|
||||
{
|
||||
$value = filter_var($input[$key] ?? null, FILTER_VALIDATE_INT);
|
||||
if ($value === false || $value < 0) {
|
||||
throw new InvalidArgumentException($key . ' must be a non-negative integer.');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function optionalNonNegativeInt(array $input, string $key): int
|
||||
{
|
||||
if (! array_key_exists($key, $input) || $input[$key] === null || $input[$key] === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->nonNegativeInt($input, $key);
|
||||
}
|
||||
|
||||
private function roundRatio(int $numerator, int $denominator): int
|
||||
{
|
||||
if ($denominator <= 0) {
|
||||
throw new InvalidArgumentException('The calculation denominator must be positive.');
|
||||
}
|
||||
|
||||
return intdiv($numerator + intdiv($denominator, 2), $denominator);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -474,7 +474,7 @@
|
||||
<img src="images/carousel-0.png" class="d-block w-100" alt="Faith and Knowledge Hand in Hand">
|
||||
<div class="carousel-caption">
|
||||
<h1>Faith and Knowledge Hand in Hand</h1>
|
||||
<p>Our Sunday School program (Grades 1-9, Youth) is evenly divided between Islamic Studies in English—exploring topics about Allah, Islamic values, Muslim life and lessons from the Prophets—and Quran/Arabic studies focused on recitation, memorization and language. This balanced approach nurtures both understanding and practice of Islam.</p>
|
||||
<p>Our Sunday School program (Grades 1-10, Youth) is evenly divided between Islamic Studies in English—exploring topics about Allah, Islamic values, Muslim life and lessons from the Prophets—and Quran/Arabic studies focused on recitation, memorization and language. This balanced approach nurtures both understanding and practice of Islam.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -662,8 +662,8 @@
|
||||
<div class="h-100 d-flex flex-column justify-content-center p-5">
|
||||
<h1 class="mb-4">Our Curriculum and Grade Structure</h1>
|
||||
<p>Our program spans nine structured grade levels, each building upon the previous year's knowledge to ensure a solid foundation in faith, character, and Islamic learning.</p>
|
||||
<p>Upon completing the 9th grade, students transition into our three-year Youth Program, which emphasizes deeper community engagement, personal development and practical application of Islamic principles. Participation in the Youth Program requires students to be at least 15 years old, ensuring they are mature enough to benefit from its advanced content.</p>
|
||||
<p>Starting this academic year, children must be at least 6 years old by 09-01-2025 to enroll in Grade 1. For younger learners, we are pleased to offer our newly established Kindergarten class, welcoming children who are at least 5 years old by 12-31-2025. This early education program is designed to introduce young minds to the basics of Islamic teachings in a warm, age-appropriate environment.</p>
|
||||
<p>Upon completing the 10th grade, students transition into our two-year Youth Program, which emphasizes deeper community engagement, personal development and practical application of Islamic principles. Participation in the Youth Program requires students to be at least 16 years old, ensuring they are mature enough to benefit from its advanced content.</p>
|
||||
<p>Starting this academic year, children must be at least 6 years old by 09-01-2026 to enroll in Grade 1. For younger learners, we are pleased to offer our newly established Kindergarten class, welcoming children who are at least 5 years old by 12-31-2026. This early education program is designed to introduce young minds to the basics of Islamic teachings in a warm, age-appropriate environment.</p>
|
||||
<a class="btn btn-success py-3 px-5 mt-3" href="/register">Get Started Now<i class="fa fa-arrow-right ms-2"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,6 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses));
|
||||
<label class="form-label">Unit</label>
|
||||
<input class="form-control" name="unit" placeholder="pcs, set" value="<?= esc($item['unit'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">SKU</label>
|
||||
<input class="form-control" name="sku" value="<?= esc($item['sku'] ?? '') ?>">
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 px-3">
|
||||
<h3 class="mb-0">Books</h3>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-outline-primary" href="<?= site_url('inventory/book-prices') ?>">Book Prices</a>
|
||||
<a class="btn btn-primary" href="<?= site_url('inventory/create/book') ?>">Add Book</a>
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#addCategoryModal">Add Category</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= view('partials/flash_messages') ?>
|
||||
<div class="px-3 mb-2"><?= $this->include('partials/academic_filter') ?></div>
|
||||
|
||||
<?php
|
||||
// Helper to render a compact grade label from a category row
|
||||
@@ -50,7 +50,7 @@
|
||||
<th>Grade Range</th> <!-- NEW -->
|
||||
<th>Qty</th><th>Unit</th>
|
||||
<th>Updated By</th><th>Updated Date</th>
|
||||
<th>SKU</th><th style="width:110px">Actions</th>
|
||||
<th>SKU</th><th style="width:150px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -72,7 +72,7 @@
|
||||
<td><?= esc($i['updated_at'] ? local_datetime($i['updated_at'], 'm-d-Y H:i') : '—') ?></td>
|
||||
<td><?= esc($i['sku']) ?></td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-primary" href="<?= site_url('inventory/edit/'.$i['id']) ?>">Edit</a>
|
||||
<a class="btn btn-sm btn-outline-primary" href="<?= site_url('inventory/edit/'.$i['id']) ?>">Edit Book</a>
|
||||
<form action="<?= site_url('inventory/delete/'.$i['id']) ?>" method="post" class="d-inline">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('Delete this item?')">Del</button>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
$categoryById = [];
|
||||
foreach (($categories ?? []) as $category) {
|
||||
$categoryById[(int) ($category['id'] ?? 0)] = $category;
|
||||
}
|
||||
|
||||
$gradeLabel = static function (?array $category): string {
|
||||
if (! $category) {
|
||||
return '-';
|
||||
}
|
||||
$gmin = $category['grade_min'] ?? null;
|
||||
$gmax = $category['grade_max'] ?? null;
|
||||
if ($gmin === null && $gmax === null) {
|
||||
return '-';
|
||||
}
|
||||
if ($gmin !== null && $gmax !== null) {
|
||||
return 'G' . (int) $gmin . '-G' . (int) $gmax;
|
||||
}
|
||||
if ($gmin !== null) {
|
||||
return 'G' . (int) $gmin . '+';
|
||||
}
|
||||
return '<=G' . (int) $gmax;
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="container-fluid px-0 mt-3">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3 px-3">
|
||||
<div>
|
||||
<h3 class="mb-0">Book Prices</h3>
|
||||
<div class="text-muted small">School year: <?= esc($schoolYear ?? '') ?></div>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="<?= site_url('inventory/book') ?>">Back to Books</a>
|
||||
</div>
|
||||
|
||||
<?= view('partials/flash_messages') ?>
|
||||
|
||||
<form method="post" action="<?= site_url('inventory/book-prices') ?>">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<input class="form-control form-control-sm" id="bookPriceSearch" type="search" placeholder="Search title, ISBN, SKU" style="width: 260px;">
|
||||
<select class="form-select form-select-sm" id="bookPriceCategoryFilter" style="width: 240px;">
|
||||
<option value="">All Categories</option>
|
||||
<?php foreach (($categories ?? []) as $category): ?>
|
||||
<option value="<?= (int) ($category['id'] ?? 0) ?>"><?= esc($category['name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm">Save Prices</button>
|
||||
</div>
|
||||
<div class="card-body table-responsive">
|
||||
<table class="table table-striped align-middle mb-0 no-mgmt-sticky no-dt-fixedheader" data-no-mgmt-sticky data-no-dt-fixedheader id="bookPriceTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Book</th>
|
||||
<th>ISBN / Edition / SKU</th>
|
||||
<th>Category</th>
|
||||
<th>Grade Range</th>
|
||||
<th class="text-end">Qty</th>
|
||||
<th style="width: 170px;">Price</th>
|
||||
<th style="width: 130px;">Confirmed</th>
|
||||
<th style="width: 120px;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach (($books ?? []) as $book): ?>
|
||||
<?php
|
||||
$bookId = (int) ($book['id'] ?? 0);
|
||||
$category = $categoryById[(int) ($book['category_id'] ?? 0)] ?? null;
|
||||
$priceCents = (int) ($book['charge_price_cents'] ?? 0);
|
||||
$isConfirmed = $priceCents > 0 && (int) ($book['price_confirmed'] ?? 0) === 1;
|
||||
$search = strtolower(trim(implode(' ', [
|
||||
$book['name'] ?? '',
|
||||
$book['isbn'] ?? '',
|
||||
$book['edition'] ?? '',
|
||||
$book['sku'] ?? '',
|
||||
$category['name'] ?? '',
|
||||
])));
|
||||
?>
|
||||
<tr data-category="<?= esc($book['category_id'] ?? '') ?>" data-search="<?= esc($search) ?>">
|
||||
<td>
|
||||
<div class="fw-semibold"><?= esc($book['name'] ?? '') ?></div>
|
||||
<div class="small text-muted">ID #<?= $bookId ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div><?= esc(($book['isbn'] ?? '') ?: '-') ?></div>
|
||||
<div class="small text-muted">
|
||||
<?= esc(($book['edition'] ?? '') ?: '-') ?> / <?= esc(($book['sku'] ?? '') ?: '-') ?>
|
||||
</div>
|
||||
</td>
|
||||
<td><?= esc($category['name'] ?? 'Uncategorized') ?></td>
|
||||
<td><?= esc($gradeLabel($category)) ?></td>
|
||||
<td class="text-end"><?= (int) ($book['quantity'] ?? 0) ?></td>
|
||||
<td>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">$</span>
|
||||
<input class="form-control" name="prices[<?= $bookId ?>]" inputmode="decimal" pattern="^\d+(\.\d{1,2})?$" value="<?= esc(number_format($priceCents / 100, 2, '.', '')) ?>">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="confirmed[<?= $bookId ?>]" value="1" id="confirmed<?= $bookId ?>" <?= $isConfirmed ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="confirmed<?= $bookId ?>">Confirm</label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($isConfirmed): ?>
|
||||
<span class="badge bg-success">Ready</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-warning text-dark">Needs price</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<button class="btn btn-primary">Save Prices</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const search = document.getElementById('bookPriceSearch');
|
||||
const category = document.getElementById('bookPriceCategoryFilter');
|
||||
const rows = Array.from(document.querySelectorAll('#bookPriceTable tbody tr'));
|
||||
|
||||
function applyFilters() {
|
||||
const term = (search?.value || '').trim().toLowerCase();
|
||||
const categoryId = category?.value || '';
|
||||
rows.forEach(row => {
|
||||
const matchesText = !term || (row.getAttribute('data-search') || '').includes(term);
|
||||
const matchesCategory = !categoryId || row.getAttribute('data-category') === categoryId;
|
||||
row.style.display = matchesText && matchesCategory ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
search?.addEventListener('input', applyFilters);
|
||||
category?.addEventListener('change', applyFilters);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="mb-1">Book Issue History</h2><div class="text-muted"><?= esc(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')) ?> · <?= esc($student['school_id'] ?? '') ?></div></div><a class="btn btn-outline-secondary" href="<?= previous_url() ?>">Back</a></div>
|
||||
<div class="alert alert-info">Issued books are not returnable. A reversed row means an administrator corrected an issue that was entered in error; it is not a parent return.</div>
|
||||
<div class="table-responsive"><table class="table table-striped align-middle"><thead><tr><th>Year</th><th>Book</th><th>Issued</th><th>Qty</th><th>Status</th><th>Issued by</th><th>Correction audit</th></tr></thead><tbody>
|
||||
<?php foreach ($issues as $issue): ?><tr><td><?= esc($issue['school_year'] ?? '') ?></td><td><?= esc($issue['book_name'] ?? '') ?><div class="small text-muted"><?= esc($issue['isbn'] ?? '') ?> <?= esc($issue['edition'] ?? '') ?></div></td><td><?= esc($issue['issued_at'] ?? '') ?></td><td><?= (int) ($issue['quantity'] ?? 0) ?></td><td><span class="badge bg-<?= ($issue['status'] ?? '') === 'issued' ? 'success' : 'secondary' ?>"><?= esc($issue['status'] ?? '') ?></span></td><td><?= esc(trim(($issue['issued_by_firstname'] ?? '') . ' ' . ($issue['issued_by_lastname'] ?? ''))) ?></td><td><?php if (($issue['status'] ?? '') === 'reversed'): ?><?= esc($issue['reversal_reason'] ?? '') ?><div class="small text-muted"><?= esc($issue['reversed_at'] ?? '') ?> by <?= esc(trim(($issue['reversed_by_firstname'] ?? '') . ' ' . ($issue['reversed_by_lastname'] ?? ''))) ?></div><?php else: ?>—<?php endif; ?></td></tr><?php endforeach; ?>
|
||||
<?php if (empty($issues)): ?><tr><td colspan="7" class="text-muted">No book issues found.</td></tr><?php endif; ?>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -47,8 +47,13 @@
|
||||
<strong>School Year:</strong> <?= esc($schoolYear) ?>,
|
||||
<strong>Semester:</strong> <?= esc($semester) ?>
|
||||
</div>
|
||||
<div><strong>On Hand:</strong> <?= (int)$onHand ?></div>
|
||||
<div>
|
||||
<strong>On Hand:</strong> <?= (int)$onHand ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ((int)($priceConfirmed ?? 0) !== 1 || (int)($unitChargeCents ?? 0) <= 0): ?>
|
||||
<div class="mt-2 text-danger">This book cannot be distributed until its active-year charge price is confirmed.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Submission form (POST) -->
|
||||
@@ -57,6 +62,7 @@
|
||||
<!-- Keep class section hidden -->
|
||||
<input type="hidden" name="class_section_id" value="<?= esc($class_section_id) ?>">
|
||||
<input type="hidden" name="item_id" value="<?= esc($item_id) ?>">
|
||||
<input type="hidden" name="idempotency_key" value="<?= esc(hash('sha256', (string) microtime(true) . ':' . (string) random_int(1, PHP_INT_MAX))) ?>">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
@@ -96,7 +102,7 @@
|
||||
class="form-check-input student-box"
|
||||
name="student_ids[]"
|
||||
value="<?= esc($sid) ?>"
|
||||
<?= $given ? 'checked' : '' ?><?= $disabledAttr ?>>
|
||||
<?= $given ? 'checked disabled' : $disabledAttr ?>>
|
||||
</td>
|
||||
<td><?= esc($student['school_id'] ?? '-') ?></td>
|
||||
<td><?= esc($student['firstname'] ?? '') ?></td>
|
||||
@@ -123,7 +129,7 @@
|
||||
<textarea class="form-control" rows="2" name="note" placeholder="e.g., Distributed in class today"<?= $disabledAttr ?>></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-success"<?= $disabledAttr ?>>Deduct & Save</button>
|
||||
<button type="submit" class="btn btn-success"<?= $disabledAttr ?><?= ((int)($priceConfirmed ?? 0) !== 1 || (int)($unitChargeCents ?? 0) <= 0) ? ' disabled' : '' ?>>Deduct & Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="clearSelectionBtn"<?= $disabledAttr ?>>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,34 +154,18 @@
|
||||
|
||||
document.getElementById('checkAllBtn')?.addEventListener('click', () => {
|
||||
if (distributeReadonly) return;
|
||||
document.querySelectorAll('.student-box').forEach(cb => cb.checked = true);
|
||||
});
|
||||
document.getElementById('uncheckAllBtn')?.addEventListener('click', () => {
|
||||
if (distributeReadonly) return;
|
||||
document.querySelectorAll('.student-box').forEach(cb => cb.checked = false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Auto-submit filter when book changes
|
||||
document.getElementById('bookSelect')?.addEventListener('change', () => {
|
||||
document.getElementById('filterForm').submit();
|
||||
});
|
||||
|
||||
document.getElementById('checkAllBtn')?.addEventListener('click', () => {
|
||||
if (distributeReadonly) return;
|
||||
document.querySelectorAll('.student-box').forEach(cb => cb.checked = true);
|
||||
document.querySelectorAll('.student-box:not(:disabled)').forEach(cb => cb.checked = true);
|
||||
});
|
||||
|
||||
document.getElementById('uncheckAllBtn')?.addEventListener('click', () => {
|
||||
if (distributeReadonly) return;
|
||||
document.querySelectorAll('.student-box').forEach(cb => cb.checked = false);
|
||||
document.querySelectorAll('.student-box:not(:disabled)').forEach(cb => cb.checked = false);
|
||||
});
|
||||
|
||||
// Cancel button clears all checkboxes
|
||||
document.getElementById('clearSelectionBtn')?.addEventListener('click', () => {
|
||||
if (distributeReadonly) return;
|
||||
document.querySelectorAll('.student-box').forEach(cb => cb.checked = false);
|
||||
document.querySelectorAll('.student-box:not(:disabled)').forEach(cb => cb.checked = false);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+130
-45
@@ -3,6 +3,41 @@
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<h2 class="text-center mt-4 mb-3">Refunds</h2>
|
||||
<?php
|
||||
$withdrawalReviews = $withdrawalReviews ?? [];
|
||||
$refunds = $refunds ?? [];
|
||||
$moneyFromCents = static fn($cents): string => '$' . number_format(((int) $cents) / 100, 2);
|
||||
$dateOnly = static function($dt) {
|
||||
if (empty($dt) || $dt === '0000-00-00 00:00:00') return '-';
|
||||
return local_date($dt, 'm-d-Y');
|
||||
};
|
||||
$normalizeRefundStatus = static function($status): string {
|
||||
$key = strtolower(str_replace(' ', '_', trim((string) $status)));
|
||||
if ($key === 'requested') return 'pending';
|
||||
if ($key === 'partially_paid') return 'partial';
|
||||
return $key !== '' ? $key : 'pending';
|
||||
};
|
||||
$summary = [
|
||||
'reviews' => count($withdrawalReviews),
|
||||
'approval' => 0,
|
||||
'payment' => 0,
|
||||
'complete' => 0,
|
||||
];
|
||||
foreach ($refunds as $refund) {
|
||||
$key = $normalizeRefundStatus($refund['status'] ?? '');
|
||||
$isWithdrawalRefund = (string)($refund['source_type'] ?? '') === 'tuition_withdrawal';
|
||||
$amount = (float) ($refund['refund_amount'] ?? 0);
|
||||
$paid = (float) ($refund['refund_paid_amount'] ?? 0);
|
||||
$remaining = max(0, $amount - $paid);
|
||||
if ($key === 'pending' && !$isWithdrawalRefund) {
|
||||
$summary['approval']++;
|
||||
} elseif (in_array($key, ['approved', 'partial'], true) && $remaining > 0) {
|
||||
$summary['payment']++;
|
||||
} elseif (in_array($key, ['paid', 'rejected'], true)) {
|
||||
$summary['complete']++;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Flash messages -->
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
@@ -15,34 +50,78 @@
|
||||
<div class="alert alert-info mb-3 d-inline-block"><?= session()->getFlashdata('info') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- (Optional) Bulk calculation form kept for future use -->
|
||||
<form method="post" action="/refunds/processRefunds">
|
||||
<?= csrf_field() ?>
|
||||
<?php
|
||||
$submitted = [];
|
||||
foreach ($refunds as $refund):
|
||||
if (!empty($refund['request']) && !in_array($refund['parent_id'], $submitted, true)):
|
||||
$submitted[] = $refund['parent_id'];
|
||||
?>
|
||||
<input type="hidden" name="parent_ids[]" value="<?= esc($refund['parent_id']) ?>">
|
||||
<?php
|
||||
endif;
|
||||
endforeach;
|
||||
?>
|
||||
<?php if (empty($submitted)): ?>
|
||||
<div class="alert alert-info mb-3 d-inline-block">No eligible parents for refund calculation.</div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Withdrawal reviews</div><div class="fs-4 fw-semibold"><?= (int) $summary['reviews'] ?></div></div></div></div>
|
||||
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Need approval</div><div class="fs-4 fw-semibold"><?= (int) $summary['approval'] ?></div></div></div></div>
|
||||
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Ready to pay</div><div class="fs-4 fw-semibold"><?= (int) $summary['payment'] ?></div></div></div></div>
|
||||
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Closed</div><div class="fs-4 fw-semibold"><?= (int) $summary['complete'] ?></div></div></div></div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($withdrawalReviews)): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Withdrawal Review Queue</strong>
|
||||
<span class="badge bg-warning text-dark"><?= count($withdrawalReviews) ?> pending</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-striped align-middle w-100 mb-0 no-mgmt-sticky no-dt-fixedheader" data-no-mgmt-sticky data-no-dt-fixedheader>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parent</th>
|
||||
<th>Student</th>
|
||||
<th>Invoice #</th>
|
||||
<th>Requested</th>
|
||||
<th>Expected Refund</th>
|
||||
<th>Balance Due</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($withdrawalReviews as $review): ?>
|
||||
<?php
|
||||
$reviewStatus = (string) ($review['status'] ?? '');
|
||||
$reviewStatusClass = $reviewStatus === 'requires_review' ? 'danger' : 'warning text-dark';
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc(trim(($review['parent_firstname'] ?? '') . ' ' . ($review['parent_lastname'] ?? '')) ?: '-') ?></td>
|
||||
<td><?= esc(trim(($review['student_firstname'] ?? '') . ' ' . ($review['student_lastname'] ?? '')) ?: '-') ?></td>
|
||||
<td><?= esc($review['invoice_number'] ?? '-') ?></td>
|
||||
<td>
|
||||
<div><?= esc($dateOnly($review['withdrawal_request_date'] ?? null)) ?></div>
|
||||
<div class="text-muted small">Calculated <?= esc($dateOnly($review['calculated_at'] ?? null)) ?></div>
|
||||
</td>
|
||||
<td class="fw-semibold"><?= esc($moneyFromCents($review['new_refund_request_cents'] ?? 0)) ?></td>
|
||||
<td><?= esc($moneyFromCents($review['balance_due_cents'] ?? 0)) ?></td>
|
||||
<td><span class="badge bg-<?= esc($reviewStatusClass) ?>"><?= esc($reviewStatus ?: 'preview') ?></span></td>
|
||||
<td>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a class="btn btn-primary btn-sm" href="<?= site_url('administrator/withdrawals/' . (int) $review['enrollment_id'] . '/review') ?>">Open Review</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4 class="mb-0">Refund Queue</h4>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="refundsTable" class="table table-bordered table-striped align-middle w-100">
|
||||
<table id="refundsTable" class="table table-bordered table-striped align-middle w-100 no-mgmt-sticky no-dt-fixedheader" data-no-mgmt-sticky data-no-dt-fixedheader>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parent</th>
|
||||
<th>Invoice #</th>
|
||||
<th>Requested</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Refund Details</th>
|
||||
<th>Payment</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -56,12 +135,7 @@
|
||||
return local_date($dt, 'm-d-Y');
|
||||
};
|
||||
$statusRaw = (string)($r['status'] ?? '');
|
||||
$statusKey = strtolower(str_replace(' ', '_', trim($statusRaw)));
|
||||
if ($statusKey === 'requested') {
|
||||
$statusKey = 'pending';
|
||||
} elseif ($statusKey === 'partially_paid') {
|
||||
$statusKey = 'partial';
|
||||
}
|
||||
$statusKey = $normalizeRefundStatus($statusRaw);
|
||||
$statusLabel = [
|
||||
'pending' => 'Pending',
|
||||
'approved' => 'Approved',
|
||||
@@ -80,9 +154,20 @@
|
||||
$paidAmount = (float)($r['refund_paid_amount'] ?? 0);
|
||||
$remainingAmount = max(0, $refundAmount - $paidAmount);
|
||||
$hasSource = !empty($r['source_type']) && !empty($r['source_id']);
|
||||
$canApprove = $statusKey === 'pending' && $refundAmount > 0 && $hasSource;
|
||||
$canReject = $statusKey === 'pending';
|
||||
$isWithdrawalRefund = (string)($r['source_type'] ?? '') === 'tuition_withdrawal';
|
||||
$canApprove = !$isWithdrawalRefund && $statusKey === 'pending' && $refundAmount > 0 && $hasSource;
|
||||
$canReject = !$isWithdrawalRefund && $statusKey === 'pending';
|
||||
$canRecord = in_array($statusKey, ['approved', 'partial'], true) && $remainingAmount > 0;
|
||||
$nextStep = 'Closed';
|
||||
if ($statusKey === 'pending') {
|
||||
$nextStep = $isWithdrawalRefund ? 'Approved by withdrawal review' : ($canApprove ? 'Needs approval' : 'Needs review');
|
||||
} elseif ($canRecord) {
|
||||
$nextStep = 'Ready to pay';
|
||||
} elseif ($statusKey === 'paid') {
|
||||
$nextStep = 'Paid';
|
||||
} elseif ($statusKey === 'rejected') {
|
||||
$nextStep = 'Rejected';
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc(($r['firstname'] ?? '').' '.($r['lastname'] ?? '')) ?></td>
|
||||
@@ -93,6 +178,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<div><span class="badge bg-<?= esc($statusClass) ?>"><?= esc($statusLabel) ?></span></div>
|
||||
<div class="text-muted small mt-1"><?= esc($nextStep) ?></div>
|
||||
<?php if (!empty($r['approved_at']) && $r['approved_at'] !== '0000-00-00 00:00:00'): ?>
|
||||
<div class="text-muted small mt-1">
|
||||
<?= esc($fmt($r['approved_at'])) ?>
|
||||
@@ -103,6 +189,8 @@
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div><span class="text-muted small">Paid:</span> $<?= esc(number_format($paidAmount, 2)) ?></div>
|
||||
<div><span class="text-muted small">Remaining:</span> $<?= esc(number_format($remainingAmount, 2)) ?></div>
|
||||
<div><span class="text-muted small">Check #:</span> <?= esc($r['check_nbr'] ?? '-') ?></div>
|
||||
<div>
|
||||
<span class="text-muted small">Check File:</span>
|
||||
@@ -112,25 +200,21 @@
|
||||
-
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div><span class="text-muted small">Paid:</span> $<?= esc(number_format($paidAmount, 2)) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-success btn-sm"
|
||||
<?= $canRecord ? '' : 'disabled' ?>
|
||||
onclick="handleRecordRefundClick(<?= (int)$r['id'] ?>, '<?= esc($statusLabel) ?>', <?= $remainingAmount ?>)">
|
||||
Record
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
<?= $canApprove ? '' : 'disabled' ?>
|
||||
onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Approved', <?= $refundAmount ?>)">
|
||||
Approve
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm"
|
||||
<?= $canReject ? '' : 'disabled' ?>
|
||||
onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Rejected', <?= max($refundAmount, 0.01) ?>)">
|
||||
Reject
|
||||
</button>
|
||||
<?php if ($canRecord): ?>
|
||||
<button class="btn btn-success btn-sm" onclick="handleRecordRefundClick(<?= (int)$r['id'] ?>, '<?= esc($statusLabel) ?>', <?= $remainingAmount ?>)">Pay</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($canApprove): ?>
|
||||
<button class="btn btn-primary btn-sm" onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Approved', <?= $refundAmount ?>)">Approve</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($canReject): ?>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Rejected', <?= max($refundAmount, 0.01) ?>)">Reject</button>
|
||||
<?php endif; ?>
|
||||
<?php if (!$canRecord && !$canApprove && !$canReject): ?>
|
||||
<span class="text-muted small">No action</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -226,6 +310,7 @@ $(function () {
|
||||
order: [[2, 'desc']], // order by Requested desc
|
||||
scrollX: true,
|
||||
autoWidth: false,
|
||||
fixedHeader: false,
|
||||
});
|
||||
|
||||
// Status form (approve/reject)
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
$blockers = $preview['blockers'] ?? [];
|
||||
$warnings = $preview['warnings'] ?? [];
|
||||
$carryForward = $preview['carry_forward'] ?? [];
|
||||
$inventory = $preview['inventory'] ?? ['rows' => [], 'summary' => []];
|
||||
$inventoryRows = $inventory['rows'] ?? [];
|
||||
$carryForwardTotal = array_reduce(
|
||||
$carryForward,
|
||||
static fn (float $total, array $row): float => $total + (float) ($row['carry_forward_amount'] ?? 0),
|
||||
@@ -344,6 +346,44 @@
|
||||
</div>
|
||||
|
||||
<div class="border rounded bg-white p-3 mb-4" id="carry-forward-table">
|
||||
<h5>Book Inventory Reconciliation</h5>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm align-middle no-mgmt-sticky" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Book</th>
|
||||
<th class="text-end">Opening</th>
|
||||
<th class="text-end">Net Movements</th>
|
||||
<th class="text-end">System Closing</th>
|
||||
<th class="text-end">Physical Count</th>
|
||||
<th class="text-end">Variance</th>
|
||||
<th class="text-end">Charge Price</th>
|
||||
<th class="text-end">Target Opening</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($inventoryRows as $row): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div><?= esc($row['item_name'] ?? '') ?></div>
|
||||
<div class="text-muted small"><?= esc(trim((string)($row['isbn'] ?? '') . ' ' . (string)($row['edition'] ?? ''))) ?></div>
|
||||
</td>
|
||||
<td class="text-end"><?= (int) ($row['opening_quantity'] ?? 0) ?></td>
|
||||
<td class="text-end"><?= (int) ($row['movement_total'] ?? 0) ?></td>
|
||||
<td class="text-end"><?= (int) ($row['system_closing_quantity'] ?? 0) ?></td>
|
||||
<td class="text-end"><?= ($row['counted_closing_quantity'] ?? null) === null ? 'Missing' : (int) $row['counted_closing_quantity'] ?></td>
|
||||
<td class="text-end"><?= ($row['variance_quantity'] ?? null) === null ? '-' : (int) $row['variance_quantity'] ?></td>
|
||||
<td class="text-end">$<?= number_format(((int) ($row['charge_price_cents'] ?? 0)) / 100, 2) ?></td>
|
||||
<td class="text-end"><?= (int) ($row['target_opening_quantity'] ?? 0) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($inventoryRows === []): ?>
|
||||
<tr><td colspan="8" class="text-muted">No book inventory item-year rows found for this source year.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h5>Carry-Forward Families</h5>
|
||||
<div class="table-responsive">
|
||||
<table id="carryForwardFamiliesTable" class="table table-sm align-middle no-mgmt-sticky" data-no-mgmt-sticky>
|
||||
|
||||
@@ -93,6 +93,12 @@
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="new_total_instructional_weeks">Instructional Weeks</label>
|
||||
<input class="form-control" id="new_total_instructional_weeks" name="total_instructional_weeks" type="number" min="1" step="1" required>
|
||||
<input type="hidden" name="annual_fee_includes_books" value="1">
|
||||
<input type="hidden" name="withdrawal_policy_version" value="studied_weeks_v1">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex gap-2">
|
||||
<button class="btn btn-primary" type="submit">Save Draft</button>
|
||||
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Cancel</button>
|
||||
@@ -320,6 +326,12 @@
|
||||
<label class="form-label" for="fall_makeup_exam_on_<?= $id ?>">Fall Makeup Exam</label>
|
||||
<input class="form-control" id="fall_makeup_exam_on_<?= $id ?>" name="fall_makeup_exam_on" type="date" value="<?= esc($year['fall_makeup_exam_on'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="total_instructional_weeks_<?= $id ?>">Instructional Weeks</label>
|
||||
<input class="form-control" id="total_instructional_weeks_<?= $id ?>" name="total_instructional_weeks" type="number" min="1" step="1" value="<?= esc($totalInstructionalWeeks ?? '') ?>" required>
|
||||
<input type="hidden" name="annual_fee_includes_books" value="1">
|
||||
<input type="hidden" name="withdrawal_policy_version" value="<?= esc($year['withdrawal_policy_version'] ?? 'studied_weeks_v1') ?>">
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-label" for="description_<?= $id ?>">Description</label>
|
||||
<textarea class="form-control" id="description_<?= $id ?>" name="description" rows="3"><?= esc($year['description'] ?? '') ?></textarea>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<?php $c = $calculation ?? []; $money = static fn ($v): string => '$' . number_format(((int) $v) / 100, 2); ?>
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="mb-1">Withdrawal Calculation #<?= (int) ($c['id'] ?? 0) ?></h2><div class="text-muted">Version <?= (int) ($c['version'] ?? 0) ?> · <?= esc($c['status'] ?? '') ?> · <?= esc($c['student_firstname'] ?? '') ?> <?= esc($c['student_lastname'] ?? '') ?></div></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/withdrawals/' . (int) ($c['enrollment_id'] ?? 0) . '/review') ?>">Review</a></div>
|
||||
<?php if (session()->getFlashdata('success')): ?><div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div><?php endif; ?>
|
||||
<?php if (!empty($c['blockers'])): ?><div class="alert alert-danger"><ul class="mb-0"><?php foreach ($c['blockers'] as $b): ?><li><?= esc($b) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
|
||||
<div class="card mb-3"><div class="card-body"><div class="row g-3">
|
||||
<?php foreach ([
|
||||
'Annual allocation'=>'annual_fee_allocation_cents','Issued books'=>'issued_book_charge_cents','Instruction component'=>'annual_instruction_cents','Earned tuition'=>'earned_tuition_cents','Retained charge'=>'retained_charge_cents','Original invoice charge'=>'original_invoice_charge_cents','Invoice adjustment'=>'invoice_adjustment_cents','Adjusted invoice charge'=>'adjusted_invoice_charge_cents','Valid payments'=>'valid_payment_cents','Completed payouts'=>'completed_payout_cents','Open reservations'=>'open_reservation_cents','Refundable credit'=>'refundable_credit_cents','New refund request'=>'new_refund_request_cents','Balance due'=>'balance_due_cents'
|
||||
] as $label=>$field): ?><div class="col-md-3"><div class="text-muted small"><?= esc($label) ?></div><div class="fw-semibold"><?= $money($c[$field] ?? 0) ?></div></div><?php endforeach; ?>
|
||||
</div></div></div>
|
||||
<div class="card mb-3"><div class="card-header fw-semibold">Policy snapshot</div><div class="card-body"><dl class="row mb-0"><dt class="col-sm-4">Dates</dt><dd class="col-sm-8"><?= esc($c['school_year_start_date'] ?? '') ?> / <?= esc($c['enrollment_date'] ?? '') ?> / <?= esc($c['withdrawal_request_date'] ?? '') ?></dd><dt class="col-sm-4">Studied</dt><dd class="col-sm-8"><?= (int) ($c['studied_calendar_days'] ?? 0) ?> calendar day(s), rounded to <?= (int) ($c['studied_weeks'] ?? 0) ?> week(s), of <?= (int) ($c['total_instructional_weeks'] ?? 0) ?></dd><dt class="col-sm-4">Policy</dt><dd class="col-sm-8"><?= esc($c['policy_version'] ?? '') ?>; annual tuition includes books; no no-school subtraction; no book returns</dd><dt class="col-sm-4">Override reason</dt><dd class="col-sm-8"><?= esc($c['override_reason'] ?? '—') ?></dd></dl></div></div>
|
||||
<div class="card"><div class="card-header fw-semibold">Book price snapshots</div><div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Issue</th><th>Book</th><th>Date</th><th>Status</th><th>Price</th><th>Total</th></tr></thead><tbody><?php foreach ((array) ($c['books'] ?? []) as $book): ?><tr><td>#<?= (int) ($book['id'] ?? 0) ?></td><td><?= esc($book['book_name'] ?? '') ?></td><td><?= esc($book['issued_at'] ?? '') ?></td><td><?= esc($book['status'] ?? '') ?></td><td><?= $money($book['unit_charge_price_cents'] ?? 0) ?></td><td><?= $money($book['total_charge_cents'] ?? 0) ?></td></tr><?php endforeach; ?></tbody></table></div></div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<?php $money = static fn ($v): string => '$' . number_format(((int) $v) / 100, 2); ?>
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3"><h2>Invoice #<?= (int) $invoiceId ?> Withdrawal Breakdown</h2><a class="btn btn-outline-secondary" href="<?= site_url('refunds/list') ?>">Back to refunds</a></div>
|
||||
<?php foreach ($calculations as $c): ?>
|
||||
<div class="card mb-3"><div class="card-header d-flex justify-content-between"><strong><?= esc(trim(($c['student_firstname'] ?? '') . ' ' . ($c['student_lastname'] ?? ''))) ?></strong><span class="badge bg-<?= ($c['status'] ?? '') === 'posted' ? 'success' : 'danger' ?>"><?= esc($c['status'] ?? '') ?></span></div><div class="card-body"><div class="row g-2"><div class="col-md-3">Studied: <strong><?= (int) ($c['studied_weeks'] ?? 0) ?> week(s)</strong></div><div class="col-md-3">Books: <strong><?= $money($c['issued_book_charge_cents'] ?? 0) ?></strong></div><div class="col-md-3">Earned tuition: <strong><?= $money($c['earned_tuition_cents'] ?? 0) ?></strong></div><div class="col-md-3">Retained: <strong><?= $money($c['retained_charge_cents'] ?? 0) ?></strong></div></div><div class="mt-2"><a href="<?= site_url('administrator/withdrawal-calculations/' . (int) $c['id']) ?>">Calculation #<?= (int) $c['id'] ?> version <?= (int) $c['version'] ?></a></div></div></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($calculations)): ?><div class="alert alert-warning">No posted withdrawal calculations were found for this invoice.</div><?php endif; ?>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,82 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<?php
|
||||
$c = $calculation ?? [];
|
||||
$money = static fn ($cents): string => '$' . number_format(((int) $cents) / 100, 2);
|
||||
$blockers = (array) ($c['blockers'] ?? []);
|
||||
$discountProjection = (array) (($c['explanation']['discount_projection'] ?? []));
|
||||
?>
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Withdrawal Refund Review</h2>
|
||||
<div class="text-muted">
|
||||
<?= esc(trim((string) ($c['student_firstname'] ?? '') . ' ' . (string) ($c['student_lastname'] ?? ''))) ?>
|
||||
· <?= esc($c['school_year'] ?? '') ?> · calculation v<?= (int) ($c['version'] ?? 0) ?>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="<?= site_url('refunds/list') ?>">Back</a>
|
||||
</div>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?><div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div><?php endif; ?>
|
||||
<?php if (session()->getFlashdata('error')): ?><div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div><?php endif; ?>
|
||||
<?php if ($blockers !== []): ?>
|
||||
<div class="alert alert-danger">
|
||||
<strong>Cannot post this refund yet.</strong>
|
||||
<ul class="mb-0 mt-2"><?php foreach ($blockers as $blocker): ?><li><?= esc($blocker) ?></li><?php endforeach; ?></ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Studied period</div><div class="fs-5 fw-semibold"><?= (int) ($c['studied_calendar_days'] ?? 0) ?> day(s) / <?= (int) ($c['studied_weeks'] ?? 0) ?> week(s)</div><div class="small">of <?= (int) ($c['total_instructional_weeks'] ?? 0) ?> instructional weeks</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Annual allocation</div><div class="fs-5 fw-semibold"><?= $money($c['annual_fee_allocation_cents'] ?? 0) ?></div><div class="small">Includes books</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Retained charge</div><div class="fs-5 fw-semibold"><?= $money($c['retained_charge_cents'] ?? 0) ?></div><div class="small">Books + earned tuition</div></div></div></div>
|
||||
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small"><?= ((int) ($c['new_refund_request_cents'] ?? 0)) > 0 ? 'Expected refund' : 'Expected balance' ?></div><div class="fs-5 fw-semibold"><?= $money(((int) ($c['new_refund_request_cents'] ?? 0)) > 0 ? ($c['new_refund_request_cents'] ?? 0) : ($c['balance_due_cents'] ?? 0)) ?></div><div class="small">Payments: <?= $money($c['valid_payment_cents'] ?? 0) ?></div></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header fw-semibold">Books Kept by Student</div>
|
||||
<div class="table-responsive"><table class="table table-sm mb-0 no-mgmt-sticky no-dt-fixedheader" data-no-mgmt-sticky data-no-dt-fixedheader><thead><tr><th>Book</th><th>Issued</th><th>Status</th><th>Qty</th><th>Snapshot price</th><th>Total</th><th>Correction</th></tr></thead><tbody>
|
||||
<?php foreach ((array) ($c['books'] ?? []) as $book): ?>
|
||||
<tr><td><?= esc($book['book_name'] ?? ('Issue #' . ($book['id'] ?? ''))) ?></td><td><?= esc($book['issued_at'] ?? '') ?></td><td><?= esc($book['status'] ?? '') ?></td><td><?= (int) ($book['quantity'] ?? 0) ?></td><td><?= $money($book['unit_charge_price_cents'] ?? 0) ?></td><td><?= $money($book['total_charge_cents'] ?? 0) ?></td><td><?= esc($book['reversal_reason'] ?? '—') ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($c['books'])): ?><tr><td colspan="7" class="text-muted">No book issues were recorded by the withdrawal date.</td></tr><?php endif; ?>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header fw-semibold">Refund Calculation</div>
|
||||
<div class="card-body row g-2">
|
||||
<div class="col-md-4">Issued books: <strong><?= $money($c['issued_book_charge_cents'] ?? 0) ?></strong></div>
|
||||
<div class="col-md-4">Instruction component: <strong><?= $money($c['annual_instruction_cents'] ?? 0) ?></strong></div>
|
||||
<div class="col-md-4">Earned tuition: <strong><?= $money($c['earned_tuition_cents'] ?? 0) ?></strong></div>
|
||||
<div class="col-md-4">Invoice adjustment: <strong><?= $money($c['invoice_adjustment_cents'] ?? 0) ?></strong></div>
|
||||
<div class="col-md-4">Adjusted invoice charge: <strong><?= $money($c['adjusted_invoice_charge_cents'] ?? 0) ?></strong></div>
|
||||
<div class="col-md-4">Open reservations: <strong><?= $money($c['open_reservation_cents'] ?? 0) ?></strong></div>
|
||||
<div class="col-md-4">Completed prior payouts: <strong><?= $money($c['completed_payout_cents'] ?? 0) ?></strong></div>
|
||||
<div class="col-md-4">Projected applied discount: <strong><?= $money($discountProjection['adjusted_discount_cents'] ?? 0) ?></strong></div>
|
||||
<div class="col-md-4">Refundable invoice credit: <strong><?= $money($c['refundable_credit_cents'] ?? 0) ?></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= site_url('administrator/withdrawals/' . (int) $c['enrollment_id'] . '/recalculate') ?>" class="card mb-3">
|
||||
<?= csrf_field() ?>
|
||||
<div class="card-header fw-semibold">Review Inputs</div>
|
||||
<div class="card-body row g-3">
|
||||
<div class="col-md-3"><label class="form-label">Enrollment date</label><input class="form-control" type="date" name="enrollment_date" value="<?= esc($c['enrollment_date'] ?? '') ?>" required></div>
|
||||
<div class="col-md-3"><label class="form-label">Withdrawal request date</label><input class="form-control" type="date" name="withdrawal_request_date" value="<?= esc($c['withdrawal_request_date'] ?? '') ?>" required></div>
|
||||
<div class="col-md-3"><label class="form-label">Legacy invoice</label><div class="form-check mt-2"><input class="form-check-input" type="checkbox" value="1" name="legacy_invoice_confirmed" id="legacyConfirmed"><label class="form-check-label" for="legacyConfirmed">I reconciled the aggregate invoice</label></div></div>
|
||||
<div class="col-md-12"><label class="form-label">Required audit reason when changing dates or confirming legacy data</label><textarea class="form-control" name="override_reason" rows="2"><?= esc($c['override_reason'] ?? '') ?></textarea></div>
|
||||
</div>
|
||||
<div class="card-footer"><button class="btn btn-outline-primary" type="submit">Update Preview</button></div>
|
||||
</form>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary" href="<?= site_url('administrator/withdrawal-calculations/' . (int) $c['id']) ?>">Detailed Breakdown</a>
|
||||
<form method="post" action="<?= site_url('administrator/withdrawal-calculations/' . (int) $c['id'] . '/confirm') ?>" onsubmit="return confirm('Post these invoice adjustments and finalize the withdrawal calculation?');">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-success" type="submit" <?= $blockers !== [] || ($c['status'] ?? '') === 'posted' ? 'disabled' : '' ?>>Confirm and Post</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
Reference in New Issue
Block a user