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:
273
tests/Traits/CreatesFinanceData.php
Normal file
273
tests/Traits/CreatesFinanceData.php
Normal 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);
|
||||
}
|
||||
}
|
||||
145
tests/Traits/CreatesMemberData.php
Normal file
145
tests/Traits/CreatesMemberData.php
Normal 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);
|
||||
}
|
||||
}
|
||||
101
tests/Traits/SeedsRolesAndPermissions.php
Normal file
101
tests/Traits/SeedsRolesAndPermissions.php
Normal 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user