Files
alrahma_sunday_school/app/Libraries/InvoiceAdjustmentService.php
T
root 2be16553df
Tests / PHPUnit (push) Successful in 1m21s
fix test run issue
2026-07-18 23:18:49 -04:00

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);
}
}
}