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