@@ -21,6 +21,18 @@ final class FinancialAttachmentService
|
||||
];
|
||||
|
||||
public function saveUploadedFile($file, string $subdir): ?string
|
||||
{
|
||||
$staged = $this->stageUploadedFile($file, $subdir);
|
||||
if ($staged === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->finalizeStagedFile($staged);
|
||||
|
||||
return $staged['final_name'];
|
||||
}
|
||||
|
||||
public function stageUploadedFile($file, string $subdir): ?array
|
||||
{
|
||||
if (!$file instanceof UploadedFile) {
|
||||
return null;
|
||||
@@ -46,11 +58,50 @@ final class FinancialAttachmentService
|
||||
throw new \RuntimeException('File too large. Maximum size is 5 MB.');
|
||||
}
|
||||
|
||||
$dir = $this->ensureSubdir($subdir);
|
||||
$name = $file->getRandomName();
|
||||
$file->move($dir, $name);
|
||||
$tmpSubdir = '_tmp' . DIRECTORY_SEPARATOR . trim($subdir, '/');
|
||||
$tmpDir = $this->ensureSubdir($tmpSubdir);
|
||||
$tmpName = 'pending-' . bin2hex(random_bytes(8)) . '-' . $name;
|
||||
$file->move($tmpDir, $tmpName);
|
||||
|
||||
return $name;
|
||||
return [
|
||||
'subdir' => trim($subdir, '/'),
|
||||
'final_name' => $name,
|
||||
'temporary_subdir' => $tmpSubdir,
|
||||
'temporary_name' => $tmpName,
|
||||
'temporary_path' => $tmpDir . DIRECTORY_SEPARATOR . $tmpName,
|
||||
];
|
||||
}
|
||||
|
||||
public function finalizeStagedFile(array $staged): string
|
||||
{
|
||||
$tmpPath = (string)($staged['temporary_path'] ?? '');
|
||||
$finalName = basename((string)($staged['final_name'] ?? ''));
|
||||
$subdir = (string)($staged['subdir'] ?? '');
|
||||
|
||||
if ($tmpPath === '' || $finalName === '' || $subdir === '' || !is_file($tmpPath)) {
|
||||
throw new \RuntimeException('Temporary upload is missing.');
|
||||
}
|
||||
|
||||
$finalDir = $this->ensureSubdir($subdir);
|
||||
$finalPath = $finalDir . DIRECTORY_SEPARATOR . $finalName;
|
||||
if (!@rename($tmpPath, $finalPath)) {
|
||||
throw new \RuntimeException('Unable to finalize uploaded evidence.');
|
||||
}
|
||||
|
||||
return $finalName;
|
||||
}
|
||||
|
||||
public function discardStagedFile(?array $staged): void
|
||||
{
|
||||
if ($staged === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmpPath = (string)($staged['temporary_path'] ?? '');
|
||||
if ($tmpPath !== '' && is_file($tmpPath)) {
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
}
|
||||
|
||||
public function resolvePath(string $subdir, string $filename): ?string
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class FinancialCorrectionReportService
|
||||
{
|
||||
private InvoiceModel $invoiceModel;
|
||||
private FinancialReportProjectionService $projectionService;
|
||||
|
||||
public function __construct(?FinancialReportProjectionService $projectionService = null)
|
||||
{
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->projectionService = $projectionService ?? new FinancialReportProjectionService();
|
||||
}
|
||||
|
||||
public function invoiceReport(?string $schoolYear = null): array
|
||||
{
|
||||
$query = $this->invoiceModel->orderBy('id', 'ASC');
|
||||
if ($schoolYear !== null && $schoolYear !== '') {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($query->findAll() as $invoice) {
|
||||
$projection = $this->projectionService->invoiceProjection((int) $invoice['id']);
|
||||
$storedTotalCents = $this->toCents((float) ($invoice['total_amount'] ?? 0));
|
||||
$storedBalanceCents = $this->toCents((float) ($invoice['balance'] ?? 0));
|
||||
$flags = [];
|
||||
|
||||
if ($storedTotalCents !== $projection['invoice_gross_charges_cents']) {
|
||||
$flags[] = 'stored_total_mismatch';
|
||||
}
|
||||
if ($storedBalanceCents !== $projection['amount_due_cents']) {
|
||||
$flags[] = 'stored_balance_mismatch';
|
||||
}
|
||||
if ($projection['completed_cash_refunds_cents'] < 0) {
|
||||
$flags[] = 'wrong_refund_sign';
|
||||
}
|
||||
if ($projection['applied_discounts_cents'] > $projection['invoice_gross_charges_cents']) {
|
||||
$flags[] = 'discount_above_eligible_base';
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'invoice_id' => (int) $invoice['id'],
|
||||
'invoice_number' => $invoice['invoice_number'] ?? null,
|
||||
'stored_total_cents' => $storedTotalCents,
|
||||
'canonical_frozen_charge_total_cents' => $projection['invoice_gross_charges_cents'],
|
||||
'valid_payment_total_cents' => $projection['valid_payments_cents'],
|
||||
'completed_refund_payout_total_cents' => $projection['completed_cash_refunds_cents'],
|
||||
'applied_discount_total_cents' => $projection['applied_discounts_cents'],
|
||||
'expected_balance_due_cents' => $projection['amount_due_cents'],
|
||||
'expected_customer_credit_cents' => $projection['customer_credit_cents'],
|
||||
'flags' => $flags,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function toCents(float $amount): int
|
||||
{
|
||||
return (int) round($amount * 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
class FinancialPersistenceException extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $codeName,
|
||||
public readonly array $details = []
|
||||
) {
|
||||
parent::__construct($codeName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class FinancialReportProjectionService
|
||||
{
|
||||
private InvoiceLedgerService $invoiceLedgerService;
|
||||
private InvoiceModel $invoiceModel;
|
||||
|
||||
public function __construct(?InvoiceLedgerService $invoiceLedgerService = null)
|
||||
{
|
||||
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
}
|
||||
|
||||
public function invoiceProjection(int $invoiceId): array
|
||||
{
|
||||
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
||||
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_gross_charges_cents' => (int) ($ledger['totalAmountCents'] ?? 0),
|
||||
'applied_discounts_cents' => (int) ($ledger['discountCents'] ?? 0),
|
||||
'net_charges_cents' => max(0, (int) ($ledger['totalAmountCents'] ?? 0) - (int) ($ledger['discountCents'] ?? 0)),
|
||||
'valid_payments_cents' => (int) ($ledger['paidCents'] ?? 0),
|
||||
'completed_cash_refunds_cents' => (int) ($ledger['completedRefundCents'] ?? 0),
|
||||
'amount_due_cents' => (int) ($ledger['balanceDueCents'] ?? 0),
|
||||
'customer_credit_cents' => (int) ($ledger['customerCreditCents'] ?? 0),
|
||||
'approved_refund_reservations_cents' => (int) ($ledger['approvedRefundReservationCents'] ?? 0),
|
||||
'available_refundable_credit_cents' => (int) ($ledger['availableRefundableCreditCents'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function allInvoiceProjections(?string $schoolYear = null): array
|
||||
{
|
||||
$query = $this->invoiceModel->select('id')->orderBy('id', 'ASC');
|
||||
if ($schoolYear !== null && $schoolYear !== '') {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$rows = $query->findAll();
|
||||
return array_map(fn (array $row): array => $this->invoiceProjection((int) $row['id']), $rows);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,13 @@ namespace App\Libraries;
|
||||
|
||||
final class FinancialStatus
|
||||
{
|
||||
public const INVOICE_DRAFT = 'draft';
|
||||
public const INVOICE_ISSUED = 'issued';
|
||||
public const INVOICE_UNPAID = 'unpaid';
|
||||
public const INVOICE_PARTIALLY_PAID = 'partially_paid';
|
||||
public const INVOICE_PAID = 'paid';
|
||||
public const INVOICE_CREDITED = 'credited';
|
||||
public const INVOICE_VOIDED = 'voided';
|
||||
public const INVOICE_OVERPAID = 'overpaid';
|
||||
public const INVOICE_CANCELLED = 'cancelled';
|
||||
|
||||
@@ -29,24 +33,59 @@ final class FinancialStatus
|
||||
self::PAYMENT_CHARGEBACK,
|
||||
];
|
||||
|
||||
public const VALID_PAYMENT_STATUSES = [
|
||||
self::PAYMENT_RECORDED,
|
||||
'successful',
|
||||
'completed',
|
||||
'paid',
|
||||
'Successful',
|
||||
'Completed',
|
||||
'Paid',
|
||||
];
|
||||
|
||||
public const INVOICE_STATUSES = [
|
||||
self::INVOICE_UNPAID,
|
||||
self::INVOICE_DRAFT,
|
||||
self::INVOICE_ISSUED,
|
||||
self::INVOICE_PARTIALLY_PAID,
|
||||
self::INVOICE_PAID,
|
||||
self::INVOICE_CREDITED,
|
||||
self::INVOICE_VOIDED,
|
||||
self::INVOICE_OVERPAID,
|
||||
self::INVOICE_CANCELLED,
|
||||
];
|
||||
|
||||
public const REFUND_REQUESTED = 'requested';
|
||||
public const REFUND_PENDING = 'pending';
|
||||
public const REFUND_APPROVED = 'approved';
|
||||
public const REFUND_REJECTED = 'rejected';
|
||||
public const REFUND_PARTIALLY_PAID = 'partially_paid';
|
||||
public const REFUND_PAID = 'paid';
|
||||
public const REFUND_CANCELLED = 'cancelled';
|
||||
public const REFUND_REVERSED = 'reversed';
|
||||
public const REFUND_EXCEPTION = 'exception';
|
||||
public const REFUND_VOIDED = 'voided';
|
||||
|
||||
public const REIMBURSEMENT_PENDING = 'pending';
|
||||
public const REIMBURSEMENT_APPROVED = 'approved';
|
||||
public const REIMBURSEMENT_PAID = 'paid';
|
||||
public const REIMBURSEMENT_REJECTED = 'rejected';
|
||||
public const REIMBURSEMENT_REVERSED = 'reversed';
|
||||
|
||||
public const REIMBURSEMENT_STATUSES = [
|
||||
self::REIMBURSEMENT_PENDING,
|
||||
self::REIMBURSEMENT_APPROVED,
|
||||
self::REIMBURSEMENT_PAID,
|
||||
self::REIMBURSEMENT_REJECTED,
|
||||
self::REIMBURSEMENT_REVERSED,
|
||||
];
|
||||
|
||||
public const ADDITIONAL_CHARGE_PENDING = 'pending';
|
||||
public const ADDITIONAL_CHARGE_APPROVED = 'approved';
|
||||
public const ADDITIONAL_CHARGE_APPLIED = 'applied';
|
||||
public const ADDITIONAL_CHARGE_REJECTED = 'rejected';
|
||||
public const ADDITIONAL_CHARGE_VOIDED = 'voided';
|
||||
public const ADDITIONAL_CHARGE_REVERSED = 'reversed';
|
||||
|
||||
public const EXCLUDED_PAYMENT_STATUSES = [
|
||||
self::PAYMENT_VOIDED,
|
||||
@@ -74,6 +113,10 @@ final class FinancialStatus
|
||||
return match (self::normalize($status)) {
|
||||
'paid', 'full' => self::INVOICE_PAID,
|
||||
'partially paid', 'partially_paid', 'partial' => self::INVOICE_PARTIALLY_PAID,
|
||||
'draft' => self::INVOICE_DRAFT,
|
||||
'issued' => self::INVOICE_ISSUED,
|
||||
'credited' => self::INVOICE_CREDITED,
|
||||
'void', 'voided' => self::INVOICE_VOIDED,
|
||||
'overpaid' => self::INVOICE_OVERPAID,
|
||||
'cancelled', 'canceled', 'cancelled invoice', 'canceled invoice' => self::INVOICE_CANCELLED,
|
||||
default => self::INVOICE_UNPAID,
|
||||
@@ -103,8 +146,23 @@ final class FinancialStatus
|
||||
'rejected' => self::REFUND_REJECTED,
|
||||
'partial', 'partially paid', 'partially_paid' => self::REFUND_PARTIALLY_PAID,
|
||||
'paid', 'full' => self::REFUND_PAID,
|
||||
'requested', 'pending' => self::REFUND_REQUESTED,
|
||||
'cancelled', 'canceled' => self::REFUND_CANCELLED,
|
||||
'reversed' => self::REFUND_REVERSED,
|
||||
'exception' => self::REFUND_EXCEPTION,
|
||||
'void', 'voided' => self::REFUND_VOIDED,
|
||||
default => self::REFUND_PENDING,
|
||||
default => self::REFUND_REQUESTED,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeReimbursementStatus(?string $status): string
|
||||
{
|
||||
return match (self::normalize($status)) {
|
||||
'approved' => self::REIMBURSEMENT_APPROVED,
|
||||
'paid' => self::REIMBURSEMENT_PAID,
|
||||
'rejected', 'denied' => self::REIMBURSEMENT_REJECTED,
|
||||
'reversed' => self::REIMBURSEMENT_REVERSED,
|
||||
default => self::REIMBURSEMENT_PENDING,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use App\Models\InvoiceLineModel;
|
||||
|
||||
class InvoiceAdjustmentService
|
||||
{
|
||||
private \CodeIgniter\Database\BaseConnection $db;
|
||||
private AdditionalChargeModel $additionalChargeModel;
|
||||
private InvoiceLineModel $invoiceLineModel;
|
||||
private InvoiceLedgerService $invoiceLedgerService;
|
||||
|
||||
public function __construct(
|
||||
?\CodeIgniter\Database\BaseConnection $db = null,
|
||||
?AdditionalChargeModel $additionalChargeModel = null,
|
||||
?InvoiceLineModel $invoiceLineModel = null,
|
||||
?InvoiceLedgerService $invoiceLedgerService = null
|
||||
) {
|
||||
$this->db = $db ?? db_connect();
|
||||
$this->additionalChargeModel = $additionalChargeModel ?? new AdditionalChargeModel();
|
||||
$this->invoiceLineModel = $invoiceLineModel ?? new InvoiceLineModel();
|
||||
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
public function applyAdditionalCharge(int $chargeId, int $invoiceId, int $actorId): InvoiceLedgerResult
|
||||
{
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$charge = $this->lockCharge($chargeId);
|
||||
$invoice = $this->lockInvoice($invoiceId);
|
||||
|
||||
$this->assertInvoiceAcceptsAdjustment($invoice);
|
||||
$this->assertChargeMatchesInvoice($charge, $invoice);
|
||||
if ((string)($charge['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPROVED) {
|
||||
throw new \RuntimeException('Only approved charges can be applied.');
|
||||
}
|
||||
|
||||
$amountCents = $this->signedChargeAmountCents($charge);
|
||||
if ($amountCents === 0) {
|
||||
throw new \RuntimeException('Zero amount additional charges cannot be applied.');
|
||||
}
|
||||
|
||||
$activeSourceKey = $this->activeSourceKey($chargeId);
|
||||
$existing = $this->db->table('invoice_lines')
|
||||
->where('active_source_key', $activeSourceKey)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($existing) {
|
||||
throw new \RuntimeException('Additional charge already has an active invoice line.');
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$lineId = $this->invoiceLineModel->insert([
|
||||
'invoice_id' => $invoiceId,
|
||||
'line_type' => $amountCents > 0 ? 'additional_charge' : 'additional_deduction',
|
||||
'source_type' => 'additional_charge',
|
||||
'source_id' => $chargeId,
|
||||
'active_source_key' => $activeSourceKey,
|
||||
'description' => $this->chargeDescription($charge),
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'discount_eligible' => 0,
|
||||
'calculation_version' => 'invoice_adjustment_v1',
|
||||
'metadata_json' => json_encode([
|
||||
'charge_type' => (string)($charge['charge_type'] ?? ''),
|
||||
'applied_by' => $actorId,
|
||||
], JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'voided_at' => null,
|
||||
]);
|
||||
$this->requireWrite($lineId, 'INVOICE_ADJUSTMENT_LINE_INSERT_FAILED', $this->invoiceLineModel);
|
||||
|
||||
$this->requireWrite($this->additionalChargeModel->update($chargeId, [
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int)$invoice['parent_id'],
|
||||
'school_year' => (string)$invoice['school_year'],
|
||||
'semester' => (string)($invoice['semester'] ?? ''),
|
||||
'applied_invoice_line_id' => (int)$lineId,
|
||||
'applied_by' => $actorId > 0 ? $actorId : null,
|
||||
'applied_at' => $now,
|
||||
]), 'ADDITIONAL_CHARGE_APPLY_UPDATE_FAILED', $this->additionalChargeModel);
|
||||
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
$this->requireTransactionStatus();
|
||||
$this->db->transCommit();
|
||||
|
||||
return new InvoiceLedgerResult($invoiceId, (int)$lineId, $ledger);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function reverseAdditionalCharge(int $chargeId, string $reason, int $actorId): InvoiceLedgerResult
|
||||
{
|
||||
$reason = trim($reason);
|
||||
if ($reason === '') {
|
||||
throw new \RuntimeException('Reversal reason is required.');
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$charge = $this->lockCharge($chargeId);
|
||||
if ((string)($charge['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||
throw new \RuntimeException('Only applied charges can be reversed.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($charge['invoice_id'] ?? 0);
|
||||
$invoice = $this->lockInvoice($invoiceId);
|
||||
$this->assertInvoiceAcceptsAdjustment($invoice);
|
||||
|
||||
$originalLineId = (int)($charge['applied_invoice_line_id'] ?? 0);
|
||||
$originalLine = $originalLineId > 0
|
||||
? $this->db->query('SELECT * FROM invoice_lines WHERE id = ? FOR UPDATE', [$originalLineId])->getRowArray()
|
||||
: null;
|
||||
if (!$originalLine) {
|
||||
$originalLine = $this->db->query(
|
||||
'SELECT * FROM invoice_lines WHERE source_type = ? AND source_id = ? AND voided_at IS NULL ORDER BY id ASC LIMIT 1 FOR UPDATE',
|
||||
['additional_charge', $chargeId]
|
||||
)->getRowArray();
|
||||
}
|
||||
if (!$originalLine) {
|
||||
throw new \RuntimeException('Original invoice line not found.');
|
||||
}
|
||||
|
||||
$reverseAmountCents = -1 * (int)($originalLine['line_amount_cents'] ?? 0);
|
||||
if ($reverseAmountCents === 0) {
|
||||
throw new \RuntimeException('Zero amount additional charges cannot be reversed.');
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$lineId = $this->invoiceLineModel->insert([
|
||||
'invoice_id' => $invoiceId,
|
||||
'line_type' => $reverseAmountCents > 0 ? 'additional_charge_reversal' : 'additional_deduction_reversal',
|
||||
'source_type' => 'additional_charge_reversal',
|
||||
'source_id' => $chargeId,
|
||||
'active_source_key' => null,
|
||||
'description' => 'Reversal: ' . $this->chargeDescription($charge),
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $reverseAmountCents,
|
||||
'line_amount_cents' => $reverseAmountCents,
|
||||
'discount_eligible' => 0,
|
||||
'calculation_version' => 'invoice_adjustment_v1',
|
||||
'metadata_json' => json_encode([
|
||||
'original_invoice_line_id' => (int)($originalLine['id'] ?? 0),
|
||||
'reason' => $reason,
|
||||
'reversed_by' => $actorId,
|
||||
], JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'voided_at' => null,
|
||||
]);
|
||||
$this->requireWrite($lineId, 'INVOICE_ADJUSTMENT_REVERSAL_LINE_INSERT_FAILED', $this->invoiceLineModel);
|
||||
|
||||
$this->requireWrite($this->additionalChargeModel->update($chargeId, [
|
||||
'status' => 'reversed',
|
||||
'voided_by' => $actorId > 0 ? $actorId : null,
|
||||
'voided_at' => $now,
|
||||
'void_reason' => $reason,
|
||||
]), 'ADDITIONAL_CHARGE_REVERSE_UPDATE_FAILED', $this->additionalChargeModel);
|
||||
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
$this->requireTransactionStatus();
|
||||
$this->db->transCommit();
|
||||
|
||||
return new InvoiceLedgerResult($invoiceId, (int)$lineId, $ledger);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function lockCharge(int $chargeId): array
|
||||
{
|
||||
$charge = $this->db->query('SELECT * FROM additional_charges WHERE id = ? FOR UPDATE', [$chargeId])->getRowArray();
|
||||
if (!$charge) {
|
||||
throw new \RuntimeException('Additional charge not found.');
|
||||
}
|
||||
|
||||
return $charge;
|
||||
}
|
||||
|
||||
private function lockInvoice(int $invoiceId): array
|
||||
{
|
||||
$invoice = $this->db->query('SELECT * FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId])->getRowArray();
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
private function assertInvoiceAcceptsAdjustment(array $invoice): void
|
||||
{
|
||||
if (FinancialStatus::normalizeInvoiceStatus($invoice['status'] ?? null) === FinancialStatus::INVOICE_VOIDED) {
|
||||
throw new \RuntimeException('Voided invoices cannot be adjusted.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertChargeMatchesInvoice(array $charge, array $invoice): void
|
||||
{
|
||||
if ((int)($charge['parent_id'] ?? 0) !== (int)($invoice['parent_id'] ?? 0)) {
|
||||
throw new \RuntimeException('Charge parent does not match invoice parent.');
|
||||
}
|
||||
if ((string)($charge['school_year'] ?? '') !== (string)($invoice['school_year'] ?? '')) {
|
||||
throw new \RuntimeException('Charge school year does not match invoice school year.');
|
||||
}
|
||||
if ((string)($charge['semester'] ?? '') !== (string)($invoice['semester'] ?? '')) {
|
||||
throw new \RuntimeException('Charge semester does not match invoice semester.');
|
||||
}
|
||||
}
|
||||
|
||||
private function signedChargeAmountCents(array $charge): int
|
||||
{
|
||||
$amount = (int)round(abs((float)($charge['amount'] ?? 0)) * 100);
|
||||
return (string)($charge['charge_type'] ?? 'add') === 'deduct' ? -1 * $amount : $amount;
|
||||
}
|
||||
|
||||
private function activeSourceKey(int $chargeId): string
|
||||
{
|
||||
return 'additional_charge:' . $chargeId;
|
||||
}
|
||||
|
||||
private function chargeDescription(array $charge): string
|
||||
{
|
||||
$title = trim((string)($charge['title'] ?? 'Additional charge'));
|
||||
return $title !== '' ? $title : 'Additional charge';
|
||||
}
|
||||
|
||||
private function requireWrite($result, string $code, $model = null): void
|
||||
{
|
||||
if ($result === false || $result === null || $result === 0) {
|
||||
$details = is_object($model) && method_exists($model, 'errors') ? (array)$model->errors() : [];
|
||||
throw new FinancialPersistenceException($code, $details);
|
||||
}
|
||||
}
|
||||
|
||||
private function requireTransactionStatus(string $code = 'TRANSACTION_FAILED'): void
|
||||
{
|
||||
if (method_exists($this->db, 'transStatus') && $this->db->transStatus() === false) {
|
||||
throw new FinancialPersistenceException($code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceLineModel;
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class InvoiceIssuanceService
|
||||
{
|
||||
private \CodeIgniter\Database\BaseConnection $db;
|
||||
private InvoiceModel $invoiceModel;
|
||||
private InvoiceLineModel $invoiceLineModel;
|
||||
private InvoiceLedgerService $invoiceLedgerService;
|
||||
|
||||
public function __construct(
|
||||
?\CodeIgniter\Database\BaseConnection $db = null,
|
||||
?InvoiceModel $invoiceModel = null,
|
||||
?InvoiceLineModel $invoiceLineModel = null,
|
||||
?InvoiceLedgerService $invoiceLedgerService = null
|
||||
) {
|
||||
$this->db = $db ?? db_connect();
|
||||
$this->invoiceModel = $invoiceModel ?? new InvoiceModel();
|
||||
$this->invoiceLineModel = $invoiceLineModel ?? new InvoiceLineModel();
|
||||
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
public function issueInvoice(IssueInvoiceCommand $command): InvoiceLedgerResult
|
||||
{
|
||||
$invoiceData = $command->invoiceData;
|
||||
$invoiceData['status'] = FinancialStatus::INVOICE_DRAFT;
|
||||
$invoiceData['total_amount'] = $invoiceData['total_amount'] ?? number_format($command->tuitionAmount + $command->eventAmount, 2, '.', '');
|
||||
$invoiceData['balance'] = $invoiceData['balance'] ?? $invoiceData['total_amount'];
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$invoiceId = $this->invoiceModel->insert($invoiceData);
|
||||
$this->requireWrite($invoiceId, 'INVOICE_ISSUE_INSERT_FAILED', $this->invoiceModel);
|
||||
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int)$invoiceId]);
|
||||
|
||||
$inserted = $this->invoiceLedgerService->issueInitialInvoiceLines(
|
||||
(int)$invoiceId,
|
||||
$command->tuitionAmount,
|
||||
$command->eventAmount,
|
||||
$command->metadata
|
||||
);
|
||||
if ($inserted <= 0) {
|
||||
throw new FinancialPersistenceException('INVOICE_ISSUE_NO_LINES');
|
||||
}
|
||||
|
||||
$lineTotals = $this->db->table('invoice_lines')
|
||||
->select('COUNT(*) AS line_count, COALESCE(SUM(line_amount_cents),0) AS total_cents')
|
||||
->where('invoice_id', (int)$invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ((int)($lineTotals['line_count'] ?? 0) !== $inserted) {
|
||||
throw new FinancialPersistenceException('INVOICE_ISSUE_LINE_COUNT_MISMATCH');
|
||||
}
|
||||
if ((int)($lineTotals['total_cents'] ?? 0) === 0) {
|
||||
throw new FinancialPersistenceException('INVOICE_ISSUE_ZERO_LINE_TOTAL');
|
||||
}
|
||||
|
||||
$this->requireWrite($this->invoiceModel->update((int)$invoiceId, [
|
||||
'status' => FinancialStatus::INVOICE_ISSUED,
|
||||
'updated_at' => utc_now(),
|
||||
]), 'INVOICE_ISSUE_STATUS_UPDATE_FAILED', $this->invoiceModel);
|
||||
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice((int)$invoiceId);
|
||||
$this->requireTransactionStatus();
|
||||
$this->db->transCommit();
|
||||
|
||||
return new InvoiceLedgerResult((int)$invoiceId, 0, $ledger);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function requireWrite($result, string $code, $model = null): void
|
||||
{
|
||||
if ($result === false || $result === null || $result === 0) {
|
||||
$details = is_object($model) && method_exists($model, 'errors') ? (array)$model->errors() : [];
|
||||
throw new FinancialPersistenceException($code, $details);
|
||||
}
|
||||
}
|
||||
|
||||
private function requireTransactionStatus(string $code = 'TRANSACTION_FAILED'): void
|
||||
{
|
||||
if (method_exists($this->db, 'transStatus') && $this->db->transStatus() === false) {
|
||||
throw new FinancialPersistenceException($code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
class InvoiceLedgerResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $invoiceId,
|
||||
public readonly int $invoiceLineId,
|
||||
public readonly array $ledger
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ use App\Models\DiscountUsageModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\EventChargesModel;
|
||||
use App\Models\InvoiceEventModel;
|
||||
use App\Models\InvoiceLineModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\RefundModel;
|
||||
use App\Models\RefundPayoutModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
|
||||
@@ -23,6 +25,7 @@ class InvoiceLedgerService
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected RefundModel $refundModel;
|
||||
protected ?RefundPayoutModel $refundPayoutModel = null;
|
||||
protected DiscountUsageModel $discountUsageModel;
|
||||
protected AdditionalChargeModel $additionalChargeModel;
|
||||
protected ConfigurationModel $configurationModel;
|
||||
@@ -31,6 +34,7 @@ class InvoiceLedgerService
|
||||
protected ClassSectionModel $classSectionModel;
|
||||
protected EventChargesModel $eventChargesModel;
|
||||
protected InvoiceEventModel $invoiceEventModel;
|
||||
protected ?InvoiceLineModel $invoiceLineModel = null;
|
||||
protected StudentModel $studentModel;
|
||||
protected TuitionCalculatorInterface $oldCalculator;
|
||||
protected TuitionCalculatorInterface $newCalculator;
|
||||
@@ -40,6 +44,7 @@ class InvoiceLedgerService
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->refundModel = new RefundModel();
|
||||
$this->refundPayoutModel = new RefundPayoutModel();
|
||||
$this->discountUsageModel = new DiscountUsageModel();
|
||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||
$this->configurationModel = new ConfigurationModel();
|
||||
@@ -48,6 +53,7 @@ class InvoiceLedgerService
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->eventChargesModel = new EventChargesModel();
|
||||
$this->invoiceEventModel = new InvoiceEventModel();
|
||||
$this->invoiceLineModel = new InvoiceLineModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->oldCalculator = new OldTuitionCalculatorService();
|
||||
$this->newCalculator = new NewTuitionCalculatorService();
|
||||
@@ -60,9 +66,18 @@ class InvoiceLedgerService
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
$tuitionTotal = $this->calculateTuitionTotal($invoice);
|
||||
$eventTotal = $this->calculateEventTotal($invoice);
|
||||
$additionalTotal = $this->calculateAdditionalCharges($invoiceId);
|
||||
$frozenTotals = $this->calculateFrozenLineTotals($invoiceId);
|
||||
if ($frozenTotals !== null) {
|
||||
$tuitionTotal = $this->fromCentsNumber($frozenTotals['tuition_cents']);
|
||||
$eventTotal = $this->fromCentsNumber($frozenTotals['event_cents']);
|
||||
$additionalTotal = $this->fromCentsNumber($frozenTotals['additional_cents']);
|
||||
$frozenTotalCents = $frozenTotals['total_cents'];
|
||||
} else {
|
||||
$tuitionTotal = $this->calculateTuitionTotal($invoice);
|
||||
$eventTotal = $this->calculateEventTotal($invoice);
|
||||
$additionalTotal = $this->calculateAdditionalCharges($invoiceId);
|
||||
$frozenTotalCents = null;
|
||||
}
|
||||
$discountRawTotal = $this->calculateDiscounts($invoiceId);
|
||||
$paidTotal = $this->calculateValidPayments($invoiceId);
|
||||
$refundPaidTotal = $this->calculatePaidRefunds($invoiceId);
|
||||
@@ -74,8 +89,13 @@ class InvoiceLedgerService
|
||||
$paidCents = $this->toCents($paidTotal);
|
||||
$refundPaidCents = $this->toCents($refundPaidTotal);
|
||||
|
||||
if ($this->isCarryForwardInvoice($invoice)) {
|
||||
if ($frozenTotalCents !== null) {
|
||||
$totalAmountCents = $frozenTotalCents;
|
||||
$discountBaseCents = max(0, (int) ($frozenTotals['discount_eligible_base_cents'] ?? ($tuitionCents + $additionalCents)));
|
||||
$discountCents = $discountBaseCents > 0 ? min($discountRawCents, $discountBaseCents) : 0;
|
||||
} elseif ($this->isCarryForwardInvoice($invoice)) {
|
||||
$totalAmountCents = $this->toCents((float) ($invoice['total_amount'] ?? 0));
|
||||
$discountBaseCents = 0;
|
||||
$discountCents = 0;
|
||||
} else {
|
||||
$discountBaseCents = max(0, $tuitionCents + $additionalCents);
|
||||
@@ -83,7 +103,8 @@ class InvoiceLedgerService
|
||||
$totalAmountCents = $tuitionCents + $eventCents + $additionalCents;
|
||||
}
|
||||
|
||||
$balanceCents = max(0, $totalAmountCents - $discountCents - $paidCents - $refundPaidCents);
|
||||
$rawBalanceCents = $totalAmountCents - $discountCents - $paidCents + $refundPaidCents;
|
||||
$balanceCents = max(0, $rawBalanceCents);
|
||||
|
||||
if ($balanceCents === 0) {
|
||||
$status = FinancialStatus::INVOICE_PAID;
|
||||
@@ -95,6 +116,18 @@ class InvoiceLedgerService
|
||||
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'gross_charge_cents' => $totalAmountCents,
|
||||
'discount_eligible_base_cents' => $discountBaseCents,
|
||||
'requested_discount_cents' => $discountRawCents,
|
||||
'applied_discount_cents' => $discountCents,
|
||||
'net_charge_cents' => $totalAmountCents - $discountCents,
|
||||
'totalAmountCents' => $totalAmountCents,
|
||||
'discountCents' => $discountCents,
|
||||
'paidCents' => $paidCents,
|
||||
'completedRefundCents' => $refundPaidCents,
|
||||
'rawBalanceCents' => $rawBalanceCents,
|
||||
'balanceDueCents' => $balanceCents,
|
||||
'customerCreditCents' => max(0, -$rawBalanceCents),
|
||||
'tuition_total' => $this->fromCents($tuitionCents),
|
||||
'event_total' => $this->fromCents($eventCents),
|
||||
'additional_total' => $this->fromCents($additionalCents),
|
||||
@@ -103,6 +136,7 @@ class InvoiceLedgerService
|
||||
'paid_amount' => $this->fromCents($paidCents),
|
||||
'refund_paid_total' => $this->fromCents($refundPaidCents),
|
||||
'total_amount' => $this->fromCents($totalAmountCents),
|
||||
'customer_credit' => $this->fromCents(max(0, -$rawBalanceCents)),
|
||||
'balance' => $this->fromCents($balanceCents),
|
||||
'status' => $status,
|
||||
'has_discount' => $discountCents > 0 ? 1 : 0,
|
||||
@@ -125,16 +159,257 @@ class InvoiceLedgerService
|
||||
$payload['discount'] = $calculation['discount_total'];
|
||||
}
|
||||
|
||||
$this->invoiceModel->update($invoiceId, $payload);
|
||||
if (!$this->invoiceModel->update($invoiceId, $payload)) {
|
||||
throw new FinancialPersistenceException('INVOICE_LEDGER_UPDATE_FAILED', $this->invoiceModel->errors());
|
||||
}
|
||||
|
||||
return $calculation;
|
||||
}
|
||||
|
||||
public function recalculate(int $invoiceId): array
|
||||
{
|
||||
return $this->recalculateInvoice($invoiceId);
|
||||
}
|
||||
|
||||
public function getValidPaymentTotalCents(int $invoiceId): int
|
||||
{
|
||||
return $this->toCents($this->calculateValidPayments($invoiceId));
|
||||
}
|
||||
|
||||
public function issueInitialInvoiceLines(
|
||||
int $invoiceId,
|
||||
float $tuitionAmount,
|
||||
float $eventAmount,
|
||||
array $metadata = []
|
||||
): int {
|
||||
if ($invoiceId <= 0) {
|
||||
throw new \RuntimeException('Invoice ID is required to issue invoice lines.');
|
||||
}
|
||||
if (!$this->invoiceLinesAvailable()) {
|
||||
throw new \RuntimeException('Invoice lines table is not available.');
|
||||
}
|
||||
if ($this->invoiceHasLines($invoiceId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$version = (string) ($metadata['calculation_version'] ?? $this->getCalculationVersion());
|
||||
$rows = [];
|
||||
|
||||
$tuitionCents = $this->toCents($tuitionAmount);
|
||||
if ($tuitionCents !== 0) {
|
||||
$rows[] = $this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'tuition',
|
||||
'tuition_calculation',
|
||||
null,
|
||||
'Tuition charges',
|
||||
$tuitionCents,
|
||||
1,
|
||||
$version,
|
||||
$metadata,
|
||||
$now
|
||||
);
|
||||
}
|
||||
|
||||
$eventCents = $this->toCents($eventAmount);
|
||||
if ($eventCents !== 0) {
|
||||
$rows[] = $this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'event_fee',
|
||||
'event_charges',
|
||||
null,
|
||||
'Event charges',
|
||||
$eventCents,
|
||||
0,
|
||||
$version,
|
||||
$metadata,
|
||||
$now
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->loadApprovedAdjustmentRowsForIssuance($invoiceId, $metadata) as $charge) {
|
||||
$adjustmentCents = (string)($charge['charge_type'] ?? 'add') === 'deduct'
|
||||
? -1 * $this->toCents(abs((float)($charge['amount'] ?? 0)))
|
||||
: $this->toCents(abs((float)($charge['amount'] ?? 0)));
|
||||
if ($adjustmentCents === 0) {
|
||||
throw new \RuntimeException('Zero amount approved additional charges cannot be issued.');
|
||||
}
|
||||
|
||||
$row = $this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
$adjustmentCents > 0 ? 'additional_charge' : 'additional_deduction',
|
||||
'additional_charge',
|
||||
(int)$charge['id'],
|
||||
trim((string)($charge['title'] ?? 'Additional charge')) ?: 'Additional charge',
|
||||
$adjustmentCents,
|
||||
0,
|
||||
$version,
|
||||
$metadata + ['charge_type' => (string)($charge['charge_type'] ?? '')],
|
||||
$now
|
||||
);
|
||||
$row['active_source_key'] = 'additional_charge:' . (int)$charge['id'];
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
throw new \RuntimeException('Invoice issuance requires at least one non-zero invoice line.');
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
foreach ($rows as $row) {
|
||||
$lineId = $this->invoiceLineModel()->insert($row);
|
||||
if (!$lineId) {
|
||||
throw new FinancialPersistenceException('INVOICE_LINE_INSERT_FAILED', $this->invoiceLineModel()->errors());
|
||||
}
|
||||
$inserted++;
|
||||
|
||||
if (($row['source_type'] ?? null) === 'additional_charge' && !empty($row['source_id'])) {
|
||||
$this->additionalChargeModel->update((int)$row['source_id'], [
|
||||
'invoice_id' => $invoiceId,
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED,
|
||||
'applied_invoice_line_id' => (int)$lineId,
|
||||
'applied_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
protected function loadApprovedAdjustmentRowsForIssuance(int $invoiceId, array $metadata): array
|
||||
{
|
||||
$parentId = (int)($metadata['parent_id'] ?? 0);
|
||||
$schoolYear = (string)($metadata['school_year'] ?? '');
|
||||
$semester = (string)($metadata['semester'] ?? '');
|
||||
if ($parentId <= 0 || $schoolYear === '' || $semester === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->additionalChargeModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPROVED)
|
||||
->groupStart()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->orWhere('invoice_id IS NULL', null, false)
|
||||
->groupEnd()
|
||||
->orderBy('id', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
protected function loadInvoice(int $invoiceId): ?array
|
||||
{
|
||||
return $this->invoiceModel->find($invoiceId);
|
||||
}
|
||||
|
||||
protected function calculateFrozenLineTotals(int $invoiceId): ?array
|
||||
{
|
||||
if (!$this->invoiceLinesAvailable() || !$this->invoiceHasLines($invoiceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = $this->invoiceLineModel()
|
||||
->select('line_type, line_amount_cents, discount_eligible')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->findAll();
|
||||
|
||||
$totals = [
|
||||
'tuition_cents' => 0,
|
||||
'event_cents' => 0,
|
||||
'additional_cents' => 0,
|
||||
'total_cents' => 0,
|
||||
'discount_eligible_base_cents' => 0,
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amount = (int) ($row['line_amount_cents'] ?? 0);
|
||||
$type = (string) ($row['line_type'] ?? '');
|
||||
$totals['total_cents'] += $amount;
|
||||
if ((int) ($row['discount_eligible'] ?? 0) === 1) {
|
||||
$totals['discount_eligible_base_cents'] += $amount;
|
||||
}
|
||||
|
||||
if (str_contains($type, 'event')) {
|
||||
$totals['event_cents'] += $amount;
|
||||
} elseif (str_contains($type, 'additional') || str_contains($type, 'adjustment') || str_contains($type, 'charge')) {
|
||||
$totals['additional_cents'] += $amount;
|
||||
} else {
|
||||
$totals['tuition_cents'] += $amount;
|
||||
}
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
protected function invoiceHasLines(int $invoiceId): bool
|
||||
{
|
||||
if (!$this->invoiceLinesAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->invoiceLineModel()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->countAllResults() > 0;
|
||||
}
|
||||
|
||||
protected function invoiceLinesAvailable(): bool
|
||||
{
|
||||
try {
|
||||
return $this->invoiceLineModel()->db->tableExists('invoice_lines');
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function invoiceLineModel(): InvoiceLineModel
|
||||
{
|
||||
if ($this->invoiceLineModel === null) {
|
||||
$this->invoiceLineModel = new InvoiceLineModel();
|
||||
}
|
||||
|
||||
return $this->invoiceLineModel;
|
||||
}
|
||||
|
||||
protected function buildInvoiceLineRow(
|
||||
int $invoiceId,
|
||||
string $lineType,
|
||||
?string $sourceType,
|
||||
?int $sourceId,
|
||||
string $description,
|
||||
int $amountCents,
|
||||
int $discountEligible,
|
||||
string $version,
|
||||
array $metadata,
|
||||
string $timestamp
|
||||
): array {
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'line_type' => $lineType,
|
||||
'source_type' => $sourceType,
|
||||
'source_id' => $sourceId,
|
||||
'active_source_key' => null,
|
||||
'description' => $description,
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'discount_eligible' => $discountEligible,
|
||||
'calculation_version' => $version,
|
||||
'metadata_json' => json_encode($metadata, JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
'voided_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getCalculationVersion(): string
|
||||
{
|
||||
return 'invoice_lines_v1:' . get_class($this->resolveActiveCalculator());
|
||||
}
|
||||
|
||||
protected function isCarryForwardInvoice(array $invoice): bool
|
||||
{
|
||||
$invoiceNumber = (string) ($invoice['invoice_number'] ?? '');
|
||||
@@ -196,9 +471,9 @@ class InvoiceLedgerService
|
||||
protected function calculateAdditionalCharges(int $invoiceId): float
|
||||
{
|
||||
$rows = $this->additionalChargeModel
|
||||
->select('COALESCE(SUM(amount),0) AS total_amount')
|
||||
->select("COALESCE(SUM(CASE WHEN charge_type = 'deduct' THEN -ABS(amount) ELSE ABS(amount) END),0) AS total_amount", false)
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereNotIn('status', ['void', FinancialStatus::ADDITIONAL_CHARGE_VOIDED, 'cancelled', 'canceled'])
|
||||
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
|
||||
->findAll();
|
||||
|
||||
return (float) ($rows[0]['total_amount'] ?? 0);
|
||||
@@ -206,6 +481,15 @@ class InvoiceLedgerService
|
||||
|
||||
protected function calculateDiscounts(int $invoiceId): float
|
||||
{
|
||||
if ($this->discountUsageModel->db->fieldExists('applied_discount_cents', $this->discountUsageModel->table)) {
|
||||
$row = $this->discountUsageModel
|
||||
->select('COALESCE(SUM(applied_discount_cents),0) AS total_cents')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->first();
|
||||
|
||||
return ((int) ($row['total_cents'] ?? 0)) / 100;
|
||||
}
|
||||
|
||||
$row = $this->discountUsageModel
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
@@ -222,7 +506,7 @@ class InvoiceLedgerService
|
||||
|
||||
if ($this->paymentModel->db->fieldExists('status', $this->paymentModel->table)) {
|
||||
$query->groupStart()
|
||||
->whereNotIn('status', FinancialStatus::EXCLUDED_PAYMENT_STATUSES)
|
||||
->whereIn('status', FinancialStatus::VALID_PAYMENT_STATUSES)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
@@ -241,6 +525,20 @@ class InvoiceLedgerService
|
||||
|
||||
protected function calculatePaidRefunds(int $invoiceId): float
|
||||
{
|
||||
if ($this->refundPayoutsAvailable()) {
|
||||
$row = $this->refundPayoutModel()
|
||||
->select("COALESCE(SUM(CASE
|
||||
WHEN refund_payouts.payout_type = 'cash_out' AND refund_payouts.status = 'completed' THEN refund_payouts.amount_cents
|
||||
WHEN refund_payouts.payout_type = 'reversal' AND refund_payouts.status = 'completed' THEN -refund_payouts.amount_cents
|
||||
ELSE 0
|
||||
END),0) AS total_cents", false)
|
||||
->join('refunds', 'refunds.id = refund_payouts.refund_id', 'inner')
|
||||
->where('refunds.invoice_id', $invoiceId)
|
||||
->first();
|
||||
|
||||
return max(0, (float) ($row['total_cents'] ?? 0)) / 100;
|
||||
}
|
||||
|
||||
$row = $this->refundModel
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
@@ -250,6 +548,24 @@ class InvoiceLedgerService
|
||||
return (float) ($row['total_amount'] ?? 0);
|
||||
}
|
||||
|
||||
protected function refundPayoutsAvailable(): bool
|
||||
{
|
||||
try {
|
||||
return $this->refundPayoutModel()->db->tableExists('refund_payouts');
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function refundPayoutModel(): RefundPayoutModel
|
||||
{
|
||||
if ($this->refundPayoutModel === null) {
|
||||
$this->refundPayoutModel = new RefundPayoutModel();
|
||||
}
|
||||
|
||||
return $this->refundPayoutModel;
|
||||
}
|
||||
|
||||
protected function loadTuitionStudents(int $parentId, string $schoolYear): array
|
||||
{
|
||||
$enrollments = $this->enrollmentModel
|
||||
@@ -364,4 +680,9 @@ class InvoiceLedgerService
|
||||
{
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
|
||||
protected function fromCentsNumber(int $cents): float
|
||||
{
|
||||
return $cents / 100;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
class IssueInvoiceCommand
|
||||
{
|
||||
public function __construct(
|
||||
public readonly array $invoiceData,
|
||||
public readonly float $tuitionAmount,
|
||||
public readonly float $eventAmount,
|
||||
public readonly array $metadata = []
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class ParentLedgerService
|
||||
{
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected InvoiceLedgerService $invoiceLedgerService;
|
||||
protected RefundEligibilityService $refundEligibilityService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->refundEligibilityService = new RefundEligibilityService();
|
||||
}
|
||||
|
||||
public function getParentProjection(int $parentId, string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
$invoices = $this->loadInvoices($parentId, $schoolYear, $semester);
|
||||
|
||||
$grossChargesCents = 0;
|
||||
$discountCents = 0;
|
||||
$netInvoiceChargesCents = 0;
|
||||
$validPaymentCents = 0;
|
||||
$completedRefundCents = 0;
|
||||
$balanceDueCents = 0;
|
||||
$customerCreditCents = 0;
|
||||
$approvedRefundReservationCents = 0;
|
||||
$availableRefundableCreditCents = 0;
|
||||
$invoiceProjections = [];
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$invoiceId = (int)($invoice['id'] ?? 0);
|
||||
if ($invoiceId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ledger = $this->ledgerForInvoice($invoiceId);
|
||||
$eligibility = $this->eligibilityForSource(
|
||||
$parentId,
|
||||
$invoiceId,
|
||||
'invoice_overpayment',
|
||||
$invoiceId
|
||||
);
|
||||
|
||||
$invoiceGrossCents = (int)($ledger['totalAmountCents'] ?? 0);
|
||||
$invoiceDiscountCents = (int)($ledger['discountCents'] ?? 0);
|
||||
$grossChargesCents += $invoiceGrossCents;
|
||||
$discountCents += $invoiceDiscountCents;
|
||||
$netInvoiceChargesCents += max(0, $invoiceGrossCents - $invoiceDiscountCents);
|
||||
$validPaymentCents += (int)($ledger['paidCents'] ?? 0);
|
||||
$completedRefundCents += (int)($ledger['completedRefundCents'] ?? 0);
|
||||
$balanceDueCents += (int)($ledger['balanceDueCents'] ?? 0);
|
||||
$customerCreditCents += (int)($ledger['customerCreditCents'] ?? 0);
|
||||
$approvedRefundReservationCents += $eligibility->reservedAmountCents;
|
||||
$availableRefundableCreditCents += $eligibility->availableAmountCents;
|
||||
|
||||
$invoiceProjections[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => (string)($invoice['invoice_number'] ?? ''),
|
||||
'grossChargesCents' => $invoiceGrossCents,
|
||||
'discountCents' => $invoiceDiscountCents,
|
||||
'netInvoiceChargesCents' => max(0, $invoiceGrossCents - $invoiceDiscountCents),
|
||||
'validPaymentCents' => (int)($ledger['paidCents'] ?? 0),
|
||||
'completedRefundCents' => (int)($ledger['completedRefundCents'] ?? 0),
|
||||
'balanceDueCents' => (int)($ledger['balanceDueCents'] ?? 0),
|
||||
'customerCreditCents' => (int)($ledger['customerCreditCents'] ?? 0),
|
||||
'approvedRefundReservationCents' => $eligibility->reservedAmountCents,
|
||||
'availableRefundableCreditCents' => $eligibility->availableAmountCents,
|
||||
'status' => (string)($ledger['status'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'grossChargesCents' => $grossChargesCents,
|
||||
'discountCents' => $discountCents,
|
||||
'netInvoiceChargesCents' => $netInvoiceChargesCents,
|
||||
'validPaymentCents' => $validPaymentCents,
|
||||
'completedRefundCents' => $completedRefundCents,
|
||||
'balanceDueCents' => $balanceDueCents,
|
||||
'customerCreditCents' => $customerCreditCents,
|
||||
'approvedRefundReservationCents' => $approvedRefundReservationCents,
|
||||
'availableRefundableCreditCents' => $availableRefundableCreditCents,
|
||||
'gross_charges' => $this->fromCents($grossChargesCents),
|
||||
'discounts' => $this->fromCents($discountCents),
|
||||
'net_invoice_charges' => $this->fromCents($netInvoiceChargesCents),
|
||||
'valid_payments' => $this->fromCents($validPaymentCents),
|
||||
'completed_refunds' => $this->fromCents($completedRefundCents),
|
||||
'balance_due' => $this->fromCents($balanceDueCents),
|
||||
'customer_credit' => $this->fromCents($customerCreditCents),
|
||||
'approved_refund_reservations' => $this->fromCents($approvedRefundReservationCents),
|
||||
'available_refundable_credit' => $this->fromCents($availableRefundableCreditCents),
|
||||
'invoices' => $invoiceProjections,
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadInvoices(int $parentId, string $schoolYear, ?string $semester): array
|
||||
{
|
||||
$query = $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if ($semester !== null && $semester !== '') {
|
||||
$query->where('semester', $semester);
|
||||
}
|
||||
|
||||
return $query->orderBy('id', 'ASC')->findAll();
|
||||
}
|
||||
|
||||
protected function ledgerForInvoice(int $invoiceId): array
|
||||
{
|
||||
return $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
||||
}
|
||||
|
||||
protected function eligibilityForSource(
|
||||
int $parentId,
|
||||
?int $invoiceId,
|
||||
string $sourceType,
|
||||
int $sourceId
|
||||
): RefundEligibilityResult {
|
||||
return $this->refundEligibilityService->calculateAvailableCredit(
|
||||
$parentId,
|
||||
$invoiceId,
|
||||
$sourceType,
|
||||
$sourceId
|
||||
);
|
||||
}
|
||||
|
||||
private function fromCents(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
class RefundEligibilityResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $sourceCreditCents,
|
||||
public int $completedPayoutCents,
|
||||
public int $reservedAmountCents,
|
||||
public int $availableAmountCents,
|
||||
public array $reasonCodes = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'sourceCreditCents' => $this->sourceCreditCents,
|
||||
'completedPayoutCents' => $this->completedPayoutCents,
|
||||
'reservedAmountCents' => $this->reservedAmountCents,
|
||||
'availableAmountCents' => $this->availableAmountCents,
|
||||
'reasonCodes' => $this->reasonCodes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?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),
|
||||
'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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user