db = $db ?? db_connect(); $this->additionalChargeModel = $additionalChargeModel ?? new AdditionalChargeModel(); $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.'); } $now = utc_now(); $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' => null, '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, 0, $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); if ($this->signedChargeAmountCents($charge) === 0) { throw new \RuntimeException('Zero amount additional charges cannot be reversed.'); } $now = utc_now(); $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, 0, $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 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); } } }