Add membership fee system with disability discount and fix document permissions

Features:
- Implement two fee types: entrance fee and annual fee (both NT$1,000)
- Add 50% discount for disability certificate holders
- Add disability certificate upload in member profile
- Integrate disability verification into cashier approval workflow
- Add membership fee settings in system admin

Document permissions:
- Fix hard-coded role logic in Document model
- Use permission-based authorization instead of role checks

Additional features:
- Add announcements, general ledger, and trial balance modules
- Add income management and accounting entries
- Add comprehensive test suite with factories
- Update UI translations to Traditional Chinese

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-01 09:56:01 +08:00
parent 83ce1f7fc8
commit 642b879dd4
207 changed files with 19487 additions and 3048 deletions

View File

@@ -0,0 +1,273 @@
<?php
namespace Tests\Traits;
use App\Models\BankReconciliation;
use App\Models\Budget;
use App\Models\BudgetItem;
use App\Models\CashierLedgerEntry;
use App\Models\ChartOfAccount;
use App\Models\FinanceDocument;
use App\Models\PaymentOrder;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
trait CreatesFinanceData
{
/**
* Create a finance document at a specific approval stage
*/
protected function createFinanceDocument(array $attributes = []): FinanceDocument
{
return FinanceDocument::factory()->create($attributes);
}
/**
* Create a small amount finance document (< 5000)
*/
protected function createSmallAmountDocument(array $attributes = []): FinanceDocument
{
$doc = $this->createFinanceDocument(array_merge([
'amount' => 3000,
], $attributes));
// Verify it's small amount
assert($doc->determineAmountTier() === FinanceDocument::AMOUNT_TIER_SMALL);
return $doc;
}
/**
* Create a medium amount finance document (5000 - 50000)
*/
protected function createMediumAmountDocument(array $attributes = []): FinanceDocument
{
$doc = $this->createFinanceDocument(array_merge([
'amount' => 25000,
], $attributes));
// Verify it's medium amount
assert($doc->determineAmountTier() === FinanceDocument::AMOUNT_TIER_MEDIUM);
return $doc;
}
/**
* Create a large amount finance document (> 50000)
*/
protected function createLargeAmountDocument(array $attributes = []): FinanceDocument
{
$doc = $this->createFinanceDocument(array_merge([
'amount' => 75000,
], $attributes));
// Verify it's large amount
assert($doc->determineAmountTier() === FinanceDocument::AMOUNT_TIER_LARGE);
return $doc;
}
/**
* Create a finance document at specific approval stage
*/
protected function createDocumentAtStage(string $stage, array $attributes = []): FinanceDocument
{
$statusMap = [
'pending' => FinanceDocument::STATUS_PENDING,
'cashier_approved' => FinanceDocument::STATUS_APPROVED_CASHIER,
'accountant_approved' => FinanceDocument::STATUS_APPROVED_ACCOUNTANT,
'chair_approved' => FinanceDocument::STATUS_APPROVED_CHAIR,
'rejected' => FinanceDocument::STATUS_REJECTED,
];
return $this->createFinanceDocument(array_merge([
'status' => $statusMap[$stage] ?? FinanceDocument::STATUS_PENDING,
], $attributes));
}
/**
* Create a payment order
*/
protected function createPaymentOrder(array $attributes = []): PaymentOrder
{
if (!isset($attributes['finance_document_id'])) {
$document = $this->createDocumentAtStage('chair_approved');
$attributes['finance_document_id'] = $document->id;
}
return PaymentOrder::factory()->create($attributes);
}
/**
* Create a payment order at specific stage
*/
protected function createPaymentOrderAtStage(string $stage, array $attributes = []): PaymentOrder
{
$statusMap = [
'draft' => PaymentOrder::STATUS_DRAFT,
'pending_verification' => PaymentOrder::STATUS_PENDING_VERIFICATION,
'verified' => PaymentOrder::STATUS_VERIFIED,
'executed' => PaymentOrder::STATUS_EXECUTED,
'cancelled' => PaymentOrder::STATUS_CANCELLED,
];
return $this->createPaymentOrder(array_merge([
'status' => $statusMap[$stage] ?? PaymentOrder::STATUS_DRAFT,
], $attributes));
}
/**
* Create a cashier ledger entry
*/
protected function createCashierLedgerEntry(array $attributes = []): CashierLedgerEntry
{
$cashier = $attributes['recorded_by_cashier_id'] ?? User::factory()->create()->id;
return CashierLedgerEntry::create(array_merge([
'entry_type' => 'receipt',
'entry_date' => now(),
'amount' => 10000,
'payment_method' => 'bank_transfer',
'bank_account' => 'Test Bank Account',
'balance_before' => 0,
'balance_after' => 10000,
'recorded_by_cashier_id' => $cashier,
'recorded_at' => now(),
], $attributes));
}
/**
* Create a receipt entry (income)
*/
protected function createReceiptEntry(int $amount, string $bankAccount = 'Test Account', array $attributes = []): CashierLedgerEntry
{
$latestBalance = CashierLedgerEntry::getLatestBalance($bankAccount);
return $this->createCashierLedgerEntry(array_merge([
'entry_type' => 'receipt',
'amount' => $amount,
'bank_account' => $bankAccount,
'balance_before' => $latestBalance,
'balance_after' => $latestBalance + $amount,
], $attributes));
}
/**
* Create a payment entry (expense)
*/
protected function createPaymentEntry(int $amount, string $bankAccount = 'Test Account', array $attributes = []): CashierLedgerEntry
{
$latestBalance = CashierLedgerEntry::getLatestBalance($bankAccount);
return $this->createCashierLedgerEntry(array_merge([
'entry_type' => 'payment',
'amount' => $amount,
'bank_account' => $bankAccount,
'balance_before' => $latestBalance,
'balance_after' => $latestBalance - $amount,
], $attributes));
}
/**
* Create a bank reconciliation
*/
protected function createBankReconciliation(array $attributes = []): BankReconciliation
{
$cashier = $attributes['prepared_by_cashier_id'] ?? User::factory()->create()->id;
return BankReconciliation::create(array_merge([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 100000,
'outstanding_checks' => [],
'deposits_in_transit' => [],
'bank_charges' => [],
'prepared_by_cashier_id' => $cashier,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 0,
], $attributes));
}
/**
* Create a bank reconciliation with discrepancy
*/
protected function createReconciliationWithDiscrepancy(int $discrepancy, array $attributes = []): BankReconciliation
{
return $this->createBankReconciliation(array_merge([
'bank_statement_balance' => 100000,
'system_book_balance' => 100000 - $discrepancy,
'discrepancy_amount' => $discrepancy,
'reconciliation_status' => 'discrepancy',
], $attributes));
}
/**
* Create a completed bank reconciliation
*/
protected function createCompletedReconciliation(array $attributes = []): BankReconciliation
{
$cashier = User::factory()->create();
$accountant = User::factory()->create();
$manager = User::factory()->create();
return $this->createBankReconciliation(array_merge([
'prepared_by_cashier_id' => $cashier->id,
'prepared_at' => now()->subDays(3),
'reviewed_by_accountant_id' => $accountant->id,
'reviewed_at' => now()->subDays(2),
'approved_by_manager_id' => $manager->id,
'approved_at' => now()->subDay(),
'reconciliation_status' => 'completed',
], $attributes));
}
/**
* Create a budget
*/
protected function createBudget(array $attributes = []): Budget
{
return Budget::factory()->create($attributes);
}
/**
* Create a budget with items
*/
protected function createBudgetWithItems(int $itemCount = 3, array $budgetAttributes = []): Budget
{
$budget = $this->createBudget($budgetAttributes);
for ($i = 0; $i < $itemCount; $i++) {
$account = ChartOfAccount::factory()->create();
BudgetItem::factory()->create([
'budget_id' => $budget->id,
'chart_of_account_id' => $account->id,
'budgeted_amount' => rand(10000, 50000),
]);
}
return $budget->fresh('items');
}
/**
* Create a fake attachment file
*/
protected function createFakeAttachment(string $name = 'document.pdf'): UploadedFile
{
return UploadedFile::fake()->create($name, 100, 'application/pdf');
}
/**
* Get valid finance document data
*/
protected function getValidFinanceDocumentData(array $overrides = []): array
{
return array_merge([
'title' => 'Test Finance Document',
'description' => 'Test description',
'amount' => 10000,
'request_type' => FinanceDocument::REQUEST_TYPE_EXPENSE_REIMBURSEMENT,
'payee_name' => 'Test Payee',
'notes' => 'Test notes',
], $overrides);
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Tests\Traits;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
trait CreatesMemberData
{
/**
* Create a member with associated user
*/
protected function createMember(array $memberAttributes = [], array $userAttributes = []): Member
{
$user = User::factory()->create($userAttributes);
return Member::factory()->create(array_merge(
['user_id' => $user->id],
$memberAttributes
));
}
/**
* Create a pending member (awaiting payment)
*/
protected function createPendingMember(array $attributes = []): Member
{
return $this->createMember(array_merge([
'membership_status' => Member::STATUS_PENDING,
'membership_started_at' => null,
'membership_expires_at' => null,
], $attributes));
}
/**
* Create an active member with valid membership
*/
protected function createActiveMember(array $attributes = []): Member
{
return $this->createMember(array_merge([
'membership_status' => Member::STATUS_ACTIVE,
'membership_started_at' => now()->subMonth(),
'membership_expires_at' => now()->addYear(),
], $attributes));
}
/**
* Create an expired member
*/
protected function createExpiredMember(array $attributes = []): Member
{
return $this->createMember(array_merge([
'membership_status' => Member::STATUS_EXPIRED,
'membership_started_at' => now()->subYear()->subMonth(),
'membership_expires_at' => now()->subMonth(),
], $attributes));
}
/**
* Create a suspended member
*/
protected function createSuspendedMember(array $attributes = []): Member
{
return $this->createMember(array_merge([
'membership_status' => Member::STATUS_SUSPENDED,
], $attributes));
}
/**
* Create a member with a pending payment
*/
protected function createMemberWithPendingPayment(array $memberAttributes = [], array $paymentAttributes = []): array
{
Storage::fake('private');
$member = $this->createPendingMember($memberAttributes);
$payment = MembershipPayment::factory()->create(array_merge([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_PENDING,
'amount' => 1000,
'payment_method' => MembershipPayment::METHOD_BANK_TRANSFER,
], $paymentAttributes));
return ['member' => $member, 'payment' => $payment];
}
/**
* Create a member with payment at specific approval stage
*/
protected function createMemberWithPaymentAtStage(string $stage, array $memberAttributes = []): array
{
Storage::fake('private');
$member = $this->createPendingMember($memberAttributes);
$statusMap = [
'pending' => MembershipPayment::STATUS_PENDING,
'cashier_approved' => MembershipPayment::STATUS_APPROVED_CASHIER,
'accountant_approved' => MembershipPayment::STATUS_APPROVED_ACCOUNTANT,
'fully_approved' => MembershipPayment::STATUS_APPROVED_CHAIR,
];
$payment = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => $statusMap[$stage] ?? MembershipPayment::STATUS_PENDING,
]);
return ['member' => $member, 'payment' => $payment];
}
/**
* Create a fake receipt file for testing
*/
protected function createFakeReceipt(string $name = 'receipt.jpg'): UploadedFile
{
return UploadedFile::fake()->image($name, 800, 600);
}
/**
* Get valid member registration data
*/
protected function getValidMemberRegistrationData(array $overrides = []): array
{
$uniqueEmail = 'test'.uniqid().'@example.com';
return array_merge([
'full_name' => 'Test User',
'email' => $uniqueEmail,
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
'phone' => '0912345678',
'national_id' => 'A123456789',
'address_line_1' => '123 Test Street',
'address_line_2' => '',
'city' => 'Taipei',
'postal_code' => '100',
'emergency_contact_name' => 'Emergency Contact',
'emergency_contact_phone' => '0987654321',
'terms_accepted' => true,
], $overrides);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Tests\Traits;
use App\Models\User;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
trait SeedsRolesAndPermissions
{
/**
* Seed all roles and permissions for testing
*/
protected function seedRolesAndPermissions(): void
{
$this->artisan('db:seed', ['--class' => 'FinancialWorkflowPermissionsSeeder', '--force' => true]);
}
/**
* Create a user with a specific role
*/
protected function createUserWithRole(string $role, array $attributes = []): User
{
$user = User::factory()->create($attributes);
$user->assignRole($role);
return $user;
}
/**
* Create an admin user
*/
protected function createAdmin(array $attributes = []): User
{
return $this->createUserWithRole('admin', $attributes);
}
/**
* Create a finance cashier user
*/
protected function createCashier(array $attributes = []): User
{
return $this->createUserWithRole('finance_cashier', $attributes);
}
/**
* Create a finance accountant user
*/
protected function createAccountant(array $attributes = []): User
{
return $this->createUserWithRole('finance_accountant', $attributes);
}
/**
* Create a finance chair user
*/
protected function createChair(array $attributes = []): User
{
return $this->createUserWithRole('finance_chair', $attributes);
}
/**
* Create a finance board member user
*/
protected function createBoardMember(array $attributes = []): User
{
return $this->createUserWithRole('finance_board_member', $attributes);
}
/**
* Create a membership manager user
*/
protected function createMembershipManager(array $attributes = []): User
{
return $this->createUserWithRole('membership_manager', $attributes);
}
/**
* Create a user with specific permissions
*/
protected function createUserWithPermissions(array $permissions, array $attributes = []): User
{
$user = User::factory()->create($attributes);
foreach ($permissions as $permission) {
Permission::findOrCreate($permission, 'web');
$user->givePermissionTo($permission);
}
return $user;
}
/**
* Get all finance approval users (cashier, accountant, chair)
*/
protected function createFinanceApprovalTeam(): array
{
return [
'cashier' => $this->createCashier(['email' => 'cashier@test.com']),
'accountant' => $this->createAccountant(['email' => 'accountant@test.com']),
'chair' => $this->createChair(['email' => 'chair@test.com']),
];
}
}