@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user