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>
This commit is contained in:
214
app/Traits/HasAccountingEntries.php
Normal file
214
app/Traits/HasAccountingEntries.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
135
app/Traits/HasApprovalWorkflow.php
Normal file
135
app/Traits/HasApprovalWorkflow.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Trait for models with multi-tier approval workflows.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Add `use HasApprovalWorkflow;` to your model
|
||||
* 2. Define STATUS_* constants for each approval stage
|
||||
* 3. Define STATUS_REJECTED constant
|
||||
* 4. Override methods as needed for custom approval logic
|
||||
*/
|
||||
trait HasApprovalWorkflow
|
||||
{
|
||||
/**
|
||||
* Check if document/payment is rejected
|
||||
*/
|
||||
public function isRejected(): bool
|
||||
{
|
||||
return $this->status === static::STATUS_REJECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if document/payment is pending (initial state)
|
||||
*/
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === static::STATUS_PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if self-approval is being attempted
|
||||
* Prevents users from approving their own submissions
|
||||
*
|
||||
* @param User|null $user The user attempting to approve
|
||||
* @param string $submitterField The field containing the submitter's user ID
|
||||
*/
|
||||
protected function isSelfApproval(?User $user, string $submitterField = 'submitted_by_user_id'): bool
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$submitterId = $this->{$submitterField};
|
||||
|
||||
return $submitterId && $submitterId === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the human-readable status label
|
||||
* Override in model for custom labels
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return ucfirst(str_replace('_', ' ', $this->status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if approval can proceed based on current status
|
||||
*
|
||||
* @param string $requiredStatus The status required before this approval
|
||||
* @param User|null $user The user attempting to approve
|
||||
* @param bool $checkSelfApproval Whether to check for self-approval
|
||||
*/
|
||||
protected function canProceedWithApproval(
|
||||
string $requiredStatus,
|
||||
?User $user = null,
|
||||
bool $checkSelfApproval = true
|
||||
): bool {
|
||||
if ($this->status !== $requiredStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($checkSelfApproval && $this->isSelfApproval($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rejection details
|
||||
*/
|
||||
public function getRejectionDetails(): ?array
|
||||
{
|
||||
if (! $this->isRejected()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'reason' => $this->rejection_reason ?? null,
|
||||
'rejected_by' => $this->rejectedBy ?? null,
|
||||
'rejected_at' => $this->rejected_at ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model can be rejected
|
||||
* Default: can reject if not already rejected
|
||||
*/
|
||||
public function canBeRejected(): bool
|
||||
{
|
||||
return ! $this->isRejected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next approver role required
|
||||
* Override in model to implement specific logic
|
||||
*/
|
||||
public function getNextApproverRole(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get approval history
|
||||
* Returns array of approval stages that have been completed
|
||||
*/
|
||||
public function getApprovalHistory(): array
|
||||
{
|
||||
$history = [];
|
||||
|
||||
// This should be overridden in each model to provide specific fields
|
||||
// Example structure:
|
||||
// [
|
||||
// ['stage' => 'cashier', 'user' => User, 'at' => Carbon],
|
||||
// ['stage' => 'accountant', 'user' => User, 'at' => Carbon],
|
||||
// ]
|
||||
|
||||
return $history;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user