Files
usher-manage-stack/app/Traits/HasAccountingEntries.php
gbanyan 42099759e8 Add phone login support and member import functionality
Features:
- Support login via phone number or email (LoginRequest)
- Add members:import-roster command for Excel roster import
- Merge survey emails with roster data

Code Quality (Phase 1-4):
- Add database locking for balance calculation
- Add self-approval checks for finance workflow
- Create service layer (FinanceDocumentApprovalService, PaymentVerificationService)
- Add HasAccountingEntries and HasApprovalWorkflow traits
- Create FormRequest classes for validation
- Add status-badge component
- Define authorization gates in AuthServiceProvider
- Add accounting config file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 03:08:06 +08:00

215 lines
6.4 KiB
PHP

<?php
namespace App\Traits;
use App\Models\AccountingEntry;
use App\Models\ChartOfAccount;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Trait for models that can have accounting entries (double-entry bookkeeping).
*
* Usage:
* 1. Add `use HasAccountingEntries;` to your model
* 2. Define the `getAccountingDescription()` method in your model
* 3. Define the `getAccountingDate()` method in your model
* 4. Define the `getAccountingChartOfAccountId()` method if auto-generating entries
*/
trait HasAccountingEntries
{
/**
* Get all accounting entries for this model
*/
public function accountingEntries(): HasMany
{
return $this->hasMany(AccountingEntry::class, $this->getAccountingForeignKey());
}
/**
* Get the foreign key name for accounting entries
*/
protected function getAccountingForeignKey(): string
{
return 'finance_document_id';
}
/**
* Get debit entries for this model
*/
public function debitEntries(): HasMany
{
return $this->accountingEntries()->where('entry_type', AccountingEntry::ENTRY_TYPE_DEBIT);
}
/**
* Get credit entries for this model
*/
public function creditEntries(): HasMany
{
return $this->accountingEntries()->where('entry_type', AccountingEntry::ENTRY_TYPE_CREDIT);
}
/**
* Validate that debit and credit entries balance
*/
public function validateBalance(): bool
{
$debitTotal = $this->debitEntries()->sum('amount');
$creditTotal = $this->creditEntries()->sum('amount');
return bccomp((string) $debitTotal, (string) $creditTotal, 2) === 0;
}
/**
* Generate accounting entries for this model
* This creates the double-entry bookkeeping records
*/
public function generateAccountingEntries(array $entries): void
{
// Delete existing entries
$this->accountingEntries()->delete();
// Create new entries
foreach ($entries as $entry) {
$this->accountingEntries()->create([
'chart_of_account_id' => $entry['chart_of_account_id'],
'entry_type' => $entry['entry_type'],
'amount' => $entry['amount'],
'entry_date' => $entry['entry_date'] ?? $this->getAccountingDate(),
'description' => $entry['description'] ?? $this->getAccountingDescription(),
]);
}
}
/**
* Auto-generate simple accounting entries based on account type
* For basic income/expense transactions
*/
public function autoGenerateAccountingEntries(): void
{
$chartOfAccountId = $this->getAccountingChartOfAccountId();
// Only auto-generate if chart_of_account_id is set
if (! $chartOfAccountId) {
return;
}
$entries = [];
$entryDate = $this->getAccountingDate();
$description = $this->getAccountingDescription();
$amount = $this->getAccountingAmount();
// Get account to determine type
$account = ChartOfAccount::find($chartOfAccountId);
if (! $account) {
return;
}
if ($account->account_type === 'income') {
// Income: Debit Cash, Credit Income Account
$entries[] = [
'chart_of_account_id' => $this->getCashAccountId(),
'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT,
'amount' => $amount,
'entry_date' => $entryDate,
'description' => '收入 - '.$description,
];
$entries[] = [
'chart_of_account_id' => $chartOfAccountId,
'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT,
'amount' => $amount,
'entry_date' => $entryDate,
'description' => $description,
];
} elseif ($account->account_type === 'expense') {
// Expense: Debit Expense Account, Credit Cash
$entries[] = [
'chart_of_account_id' => $chartOfAccountId,
'entry_type' => AccountingEntry::ENTRY_TYPE_DEBIT,
'amount' => $amount,
'entry_date' => $entryDate,
'description' => $description,
];
$entries[] = [
'chart_of_account_id' => $this->getCashAccountId(),
'entry_type' => AccountingEntry::ENTRY_TYPE_CREDIT,
'amount' => $amount,
'entry_date' => $entryDate,
'description' => '支出 - '.$description,
];
}
if (! empty($entries)) {
$this->generateAccountingEntries($entries);
}
}
/**
* Get the cash account ID using config
*/
protected function getCashAccountId(): int
{
static $cashAccountId = null;
if ($cashAccountId === null) {
$cashCode = config('accounting.account_codes.cash', '1101');
$cashAccount = ChartOfAccount::where('account_code', $cashCode)->first();
$cashAccountId = $cashAccount ? $cashAccount->id : 1;
}
return $cashAccountId;
}
/**
* Get the bank account ID using config
*/
protected function getBankAccountId(): int
{
static $bankAccountId = null;
if ($bankAccountId === null) {
$bankCode = config('accounting.account_codes.bank', '1201');
$bankAccount = ChartOfAccount::where('account_code', $bankCode)->first();
$bankAccountId = $bankAccount ? $bankAccount->id : 2;
}
return $bankAccountId;
}
/**
* Get description for accounting entries
* Override in model if needed
*/
protected function getAccountingDescription(): string
{
return $this->description ?? $this->title ?? 'Transaction';
}
/**
* Get date for accounting entries
* Override in model if needed
*/
protected function getAccountingDate()
{
return $this->submitted_at ?? $this->created_at ?? now();
}
/**
* Get chart of account ID for auto-generation
* Override in model if needed
*/
protected function getAccountingChartOfAccountId(): ?int
{
return $this->chart_of_account_id ?? null;
}
/**
* Get amount for accounting entries
* Override in model if needed
*/
protected function getAccountingAmount(): float
{
return (float) ($this->amount ?? 0);
}
}