937 lines
46 KiB
PHP
937 lines
46 KiB
PHP
<?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.');
|
||
}
|
||
}
|
||
}
|
||
}
|