255 lines
11 KiB
PHP
255 lines
11 KiB
PHP
<?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,
|
|
'school_year' => (string)$invoice['school_year'],
|
|
'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,
|
|
'school_year' => (string)$invoice['school_year'],
|
|
'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);
|
|
}
|
|
}
|
|
}
|