0ac3a8375e
- load global semester helpers consistently and use date-based semester defaults - fix grading and daily attendance duplicate student/section rows - keep attendance violations scoped to the current semester by default - update invoice, refund, discount, payment, and financial aid flows - add configuration cleanup migrations for duplicate calendar/semester keys - refresh parent registration/report-card and print request handling - update related models, services, views, cron notes, and test coverage
339 lines
12 KiB
PHP
339 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Libraries;
|
|
|
|
use App\Models\InvoiceModel;
|
|
use App\Models\PaymentModel;
|
|
use App\Models\PaymentCorrectionModel;
|
|
use App\Models\RefundModel;
|
|
use App\Models\RefundPayoutModel;
|
|
|
|
class RefundEligibilityService
|
|
{
|
|
protected InvoiceLedgerService $invoiceLedgerService;
|
|
protected RefundModel $refundModel;
|
|
protected RefundPayoutModel $refundPayoutModel;
|
|
protected PaymentModel $paymentModel;
|
|
protected PaymentCorrectionModel $paymentCorrectionModel;
|
|
protected InvoiceModel $invoiceModel;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->invoiceLedgerService = new InvoiceLedgerService();
|
|
$this->refundModel = new RefundModel();
|
|
$this->refundPayoutModel = new RefundPayoutModel();
|
|
$this->paymentModel = new PaymentModel();
|
|
$this->paymentCorrectionModel = new PaymentCorrectionModel();
|
|
$this->invoiceModel = new InvoiceModel();
|
|
}
|
|
|
|
public function calculateAvailableCredit(
|
|
int $parentId,
|
|
?int $invoiceId,
|
|
string $sourceType,
|
|
int $sourceId,
|
|
?int $excludeRefundId = null
|
|
): RefundEligibilityResult {
|
|
$sourceCreditCents = $this->calculateSourceCreditCents($parentId, $invoiceId, $sourceType, $sourceId);
|
|
$completedPayoutCents = $this->completedPayoutsAffectAvailability($sourceType)
|
|
? $this->calculateCompletedPayoutCents($sourceType, $sourceId, $excludeRefundId)
|
|
: 0;
|
|
$reservedAmountCents = $this->calculateReservedAmountCents($sourceType, $sourceId, $excludeRefundId);
|
|
$availableAmountCents = max(0, $sourceCreditCents - $completedPayoutCents - $reservedAmountCents);
|
|
|
|
$reasons = [];
|
|
if ($sourceCreditCents <= 0) {
|
|
$reasons[] = 'NO_SOURCE_CREDIT';
|
|
}
|
|
if ($completedPayoutCents > 0) {
|
|
$reasons[] = 'HAS_COMPLETED_PAYOUTS';
|
|
}
|
|
if ($reservedAmountCents > 0) {
|
|
$reasons[] = 'HAS_APPROVED_RESERVATIONS';
|
|
}
|
|
if ($availableAmountCents <= 0) {
|
|
$reasons[] = 'NO_AVAILABLE_CREDIT';
|
|
}
|
|
|
|
return new RefundEligibilityResult(
|
|
$sourceCreditCents,
|
|
$completedPayoutCents,
|
|
$reservedAmountCents,
|
|
$availableAmountCents,
|
|
array_values(array_unique($reasons))
|
|
);
|
|
}
|
|
|
|
public function validateRequestedAmount(RefundEligibilityResult $eligibility, int $requestedAmountCents): void
|
|
{
|
|
if ($requestedAmountCents <= 0) {
|
|
throw new \RuntimeException('Refund amount must be greater than zero.');
|
|
}
|
|
|
|
if ($requestedAmountCents > $eligibility->availableAmountCents) {
|
|
throw new \RuntimeException('Refund amount exceeds available source credit.');
|
|
}
|
|
}
|
|
|
|
public function getCompletedPayoutTotalCentsForRefund(int $refundId): int
|
|
{
|
|
return $this->getNetCompletedCashOutCentsForRefund($refundId);
|
|
}
|
|
|
|
public function getNetCompletedCashOutCentsForRefund(int $refundId): int
|
|
{
|
|
if ($refundId <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
if ($this->refundPayoutsAvailable()) {
|
|
$row = $this->refundPayoutModel->db->query(
|
|
"SELECT
|
|
COALESCE(SUM(CASE
|
|
WHEN payout_type = 'cash_out' AND status = 'completed' THEN amount_cents
|
|
WHEN payout_type = 'reversal' AND status = 'completed' THEN -amount_cents
|
|
ELSE 0
|
|
END), 0) AS total_cents
|
|
FROM refund_payouts
|
|
WHERE refund_id = ?",
|
|
[$refundId]
|
|
)->getRowArray();
|
|
|
|
return max(0, (int)($row['total_cents'] ?? 0));
|
|
}
|
|
|
|
$refund = $this->refundModel->find($refundId);
|
|
if (!$refund) {
|
|
return 0;
|
|
}
|
|
|
|
return (int)round(((float)($refund['refund_paid_amount'] ?? 0)) * 100);
|
|
}
|
|
|
|
public function getUnreversedPayoutAmountCents(int $payoutId): int
|
|
{
|
|
if ($payoutId <= 0 || !$this->refundPayoutsAvailable()) {
|
|
return 0;
|
|
}
|
|
|
|
$row = $this->refundPayoutModel->db->query(
|
|
"SELECT p.amount_cents
|
|
- COALESCE(SUM(CASE
|
|
WHEN r.payout_type = 'reversal' AND r.status = 'completed' THEN r.amount_cents
|
|
ELSE 0
|
|
END), 0) AS unreversed_cents
|
|
FROM refund_payouts p
|
|
LEFT JOIN refund_payouts r ON r.reversed_payout_id = p.id
|
|
WHERE p.id = ?
|
|
GROUP BY p.id, p.amount_cents",
|
|
[$payoutId]
|
|
)->getRowArray();
|
|
|
|
return max(0, (int)($row['unreversed_cents'] ?? 0));
|
|
}
|
|
|
|
public function findPayoutsExceedingApprovedAmounts(): array
|
|
{
|
|
if ($this->refundPayoutsAvailable()) {
|
|
return $this->refundModel->db->query(
|
|
"SELECT r.id AS refund_id,
|
|
COALESCE(r.approved_amount_cents, ROUND(COALESCE(r.refund_amount, 0) * 100)) AS approved_amount_cents,
|
|
COALESCE(SUM(CASE
|
|
WHEN p.payout_type = 'cash_out' AND p.status = 'completed' THEN p.amount_cents
|
|
WHEN p.payout_type = 'reversal' AND p.status = 'completed' THEN -p.amount_cents
|
|
ELSE 0
|
|
END), 0) AS completed_payout_cents
|
|
FROM refunds r
|
|
LEFT JOIN refund_payouts p ON p.refund_id = r.id
|
|
GROUP BY r.id, r.approved_amount_cents, r.refund_amount
|
|
HAVING completed_payout_cents > approved_amount_cents"
|
|
)->getResultArray();
|
|
}
|
|
|
|
return $this->refundModel->db->query(
|
|
"SELECT id AS refund_id,
|
|
COALESCE(approved_amount_cents, ROUND(COALESCE(refund_amount, 0) * 100)) AS approved_amount_cents,
|
|
ROUND(COALESCE(refund_paid_amount, 0) * 100) AS completed_payout_cents
|
|
FROM refunds
|
|
WHERE ROUND(COALESCE(refund_paid_amount, 0) * 100)
|
|
> COALESCE(approved_amount_cents, ROUND(COALESCE(refund_amount, 0) * 100))"
|
|
)->getResultArray();
|
|
}
|
|
|
|
protected function calculateSourceCreditCents(int $parentId, ?int $invoiceId, string $sourceType, int $sourceId): int
|
|
{
|
|
return match ($sourceType) {
|
|
'invoice_overpayment' => $this->invoiceCreditCents($parentId, $invoiceId ?: $sourceId),
|
|
'tuition_withdrawal' => $this->invoicePaidCents($parentId, $invoiceId ?: $sourceId),
|
|
'payment_duplicate', 'payment_correction' => $this->paymentCreditCents($parentId, $invoiceId, $sourceId),
|
|
'credit_memo', 'administrative_credit' => 0,
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
protected function completedPayoutsAffectAvailability(string $sourceType): bool
|
|
{
|
|
return $sourceType !== 'invoice_overpayment';
|
|
}
|
|
|
|
protected function invoicePaidCents(int $parentId, int $invoiceId): int
|
|
{
|
|
if ($invoiceId <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$invoice = $this->invoiceModel->find($invoiceId);
|
|
if (!$invoice || (int)($invoice['parent_id'] ?? 0) !== $parentId) {
|
|
return 0;
|
|
}
|
|
|
|
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
|
|
|
return max(0, (int)($ledger['paidCents'] ?? 0));
|
|
}
|
|
|
|
protected function invoiceCreditCents(int $parentId, int $invoiceId): int
|
|
{
|
|
if ($invoiceId <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$invoice = $this->invoiceModel->find($invoiceId);
|
|
if (!$invoice || (int)($invoice['parent_id'] ?? 0) !== $parentId) {
|
|
return 0;
|
|
}
|
|
|
|
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
|
|
|
return (int)($ledger['customerCreditCents'] ?? 0);
|
|
}
|
|
|
|
protected function paymentCreditCents(int $parentId, ?int $invoiceId, int $paymentId): int
|
|
{
|
|
if ($paymentId <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
$payment = $this->paymentModel->find($paymentId);
|
|
if (!$payment || (int)($payment['parent_id'] ?? 0) !== $parentId) {
|
|
return 0;
|
|
}
|
|
|
|
if ($invoiceId !== null && $invoiceId > 0 && (int)($payment['invoice_id'] ?? 0) !== $invoiceId) {
|
|
return 0;
|
|
}
|
|
|
|
$status = FinancialStatus::normalizePaymentStatus($payment['status'] ?? null);
|
|
if (!in_array($status, [FinancialStatus::PAYMENT_RECORDED], true)) {
|
|
return 0;
|
|
}
|
|
|
|
if (!$this->paymentCorrectionsAvailable()) {
|
|
return 0;
|
|
}
|
|
|
|
$correction = $this->paymentCorrectionModel
|
|
->where('payment_id', $paymentId)
|
|
->where('invoice_id', (int)($payment['invoice_id'] ?? 0))
|
|
->where('parent_id', $parentId)
|
|
->where('status', 'approved')
|
|
->orderBy('approved_at', 'DESC')
|
|
->orderBy('id', 'DESC')
|
|
->first();
|
|
if (!$correction) {
|
|
return 0;
|
|
}
|
|
|
|
return max(0, (int)($correction['approved_refundable_cents'] ?? 0));
|
|
}
|
|
|
|
protected function paymentCorrectionsAvailable(): bool
|
|
{
|
|
try {
|
|
return $this->paymentCorrectionModel->db->tableExists('payment_corrections');
|
|
} catch (\Throwable $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected function calculateCompletedPayoutCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
|
|
{
|
|
$refundIds = $this->matchingRefundIds($sourceType, $sourceId, $excludeRefundId);
|
|
if ($refundIds === []) {
|
|
return 0;
|
|
}
|
|
|
|
if ($this->refundPayoutsAvailable()) {
|
|
$row = $this->refundPayoutModel->db->table('refund_payouts')
|
|
->select("COALESCE(SUM(CASE
|
|
WHEN payout_type = 'cash_out' AND status = 'completed' THEN amount_cents
|
|
WHEN payout_type = 'reversal' AND status = 'completed' THEN -amount_cents
|
|
ELSE 0
|
|
END),0) AS total_cents", false)
|
|
->whereIn('refund_id', $refundIds)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
return max(0, (int)($row['total_cents'] ?? 0));
|
|
}
|
|
|
|
$row = $this->refundModel
|
|
->select('COALESCE(SUM(refund_paid_amount),0) AS total_paid')
|
|
->whereIn('id', $refundIds)
|
|
->whereIn('status', ['Partial', 'Paid', 'partial', 'paid', 'partially_paid'])
|
|
->first();
|
|
|
|
return (int)round(((float)($row['total_paid'] ?? 0)) * 100);
|
|
}
|
|
|
|
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): 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']);
|
|
|
|
if ($excludeRefundId !== null && $excludeRefundId > 0) {
|
|
$query->where('id !=', $excludeRefundId);
|
|
}
|
|
|
|
$reserved = 0;
|
|
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);
|
|
$paid = $this->getCompletedPayoutTotalCentsForRefund((int)($refund['id'] ?? 0));
|
|
$reserved += max(0, $approved - $paid);
|
|
}
|
|
|
|
return $reserved;
|
|
}
|
|
|
|
protected function matchingRefundIds(string $sourceType, int $sourceId, ?int $excludeRefundId): array
|
|
{
|
|
$query = $this->refundModel
|
|
->select('id')
|
|
->where('source_type', $sourceType)
|
|
->where('source_id', $sourceId);
|
|
|
|
if ($excludeRefundId !== null && $excludeRefundId > 0) {
|
|
$query->where('id !=', $excludeRefundId);
|
|
}
|
|
|
|
return array_values(array_filter(array_map(
|
|
static fn(array $row): int => (int)($row['id'] ?? 0),
|
|
$query->findAll()
|
|
)));
|
|
}
|
|
|
|
protected function refundPayoutsAvailable(): bool
|
|
{
|
|
try {
|
|
return $this->refundPayoutModel->db->tableExists('refund_payouts');
|
|
} catch (\Throwable $e) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|