db = $db ?? db_connect(); $this->invoiceModel = $invoiceModel ?? new InvoiceModel(); $this->invoiceLineModel = $invoiceLineModel ?? new InvoiceLineModel(); $this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService(); } public function issueInvoice(IssueInvoiceCommand $command): InvoiceLedgerResult { $invoiceData = $command->invoiceData; if (trim((string)($invoiceData['invoice_number'] ?? '')) === '') { $invoiceData['invoice_number'] = $this->generateInvoiceNumber( (string)($invoiceData['school_year'] ?? ''), (int)($invoiceData['parent_id'] ?? 0) ); } $invoiceData['status'] = FinancialStatus::INVOICE_DRAFT; $invoiceData['total_amount'] = $invoiceData['total_amount'] ?? number_format($command->tuitionAmount + $command->eventAmount, 2, '.', ''); $invoiceData['balance'] = $invoiceData['balance'] ?? $invoiceData['total_amount']; $this->db->transBegin(); try { $invoiceId = $this->invoiceModel->insert($invoiceData); $this->requireWrite($invoiceId, 'INVOICE_ISSUE_INSERT_FAILED', $this->invoiceModel); $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int)$invoiceId]); $inserted = $this->invoiceLedgerService->issueInitialInvoiceLines( (int)$invoiceId, $command->tuitionAmount, $command->eventAmount, $command->metadata ); if ($inserted <= 0) { throw new FinancialPersistenceException('INVOICE_ISSUE_NO_LINES'); } $lineTotals = $this->db->table('invoice_lines') ->select('COUNT(*) AS line_count, COALESCE(SUM(line_amount_cents),0) AS total_cents') ->where('invoice_id', (int)$invoiceId) ->where('voided_at IS NULL', null, false) ->get() ->getRowArray(); if ((int)($lineTotals['line_count'] ?? 0) !== $inserted) { throw new FinancialPersistenceException('INVOICE_ISSUE_LINE_COUNT_MISMATCH'); } if ((int)($lineTotals['total_cents'] ?? 0) === 0) { throw new FinancialPersistenceException('INVOICE_ISSUE_ZERO_LINE_TOTAL'); } $this->requireWrite($this->invoiceModel->update((int)$invoiceId, [ 'status' => FinancialStatus::INVOICE_ISSUED, 'updated_at' => utc_now(), ]), 'INVOICE_ISSUE_STATUS_UPDATE_FAILED', $this->invoiceModel); $ledger = $this->invoiceLedgerService->recalculateInvoice((int)$invoiceId); $this->requireTransactionStatus(); $this->db->transCommit(); return new InvoiceLedgerResult((int)$invoiceId, 0, $ledger); } catch (\Throwable $e) { $this->db->transRollback(); throw $e; } } public function generateInvoiceNumber(string $schoolYear, ?int $parentId = null): string { $prefix = $this->invoiceNumberPrefix($schoolYear, $parentId); do { $invoiceNumber = $prefix . '-' . uniqid(); $exists = $this->db->table('invoices') ->where('invoice_number', $invoiceNumber) ->countAllResults() > 0; } while ($exists); return $invoiceNumber; } private function invoiceNumberPrefix(string $schoolYear, ?int $parentId = null): string { $year = date('y'); if (preg_match('/^(\d{4})-\d{4}$/', $schoolYear, $matches) === 1) { $year = substr($matches[1], -2); } $parentSuffix = $this->parentSchoolIdSuffix((int)($parentId ?? 0)); if ($parentSuffix !== '') { return 'INV-' . $year . $parentSuffix; } return 'INV-' . $year . str_pad((string) max(0, (int)($parentId ?? 0)), 5, '0', STR_PAD_LEFT); } private function parentSchoolIdSuffix(int $parentId): string { if ($parentId <= 0) { return ''; } $row = $this->db->table('users') ->select('school_id') ->where('id', $parentId) ->get() ->getRowArray(); $schoolId = trim((string)($row['school_id'] ?? '')); if (preg_match('/^\d{2}(\d{5})$/', $schoolId, $matches) === 1) { return $matches[1]; } return ''; } 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); } } }