Files
usher-manage-stack/tests/Feature/BankReconciliationWorkflowTest.php
Gbanyan 642b879dd4 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>
2025-12-01 09:56:01 +08:00

376 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\BankReconciliation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
/**
* Bank Reconciliation Workflow Feature Tests
*
* Tests bank reconciliation creation, review, and approval
*/
class BankReconciliationWorkflowTest extends TestCase
{
use RefreshDatabase, WithFaker;
protected User $cashier;
protected User $accountant;
protected User $manager;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware([\App\Http\Middleware\EnsureUserIsAdmin::class, \App\Http\Middleware\VerifyCsrfToken::class]);
$this->artisan('db:seed', ['--class' => 'FinancialWorkflowPermissionsSeeder']);
\Spatie\Permission\Models\Permission::findOrCreate('prepare_bank_reconciliation', 'web');
\Spatie\Permission\Models\Permission::findOrCreate('review_bank_reconciliation', 'web');
\Spatie\Permission\Models\Permission::findOrCreate('approve_bank_reconciliation', 'web');
\Spatie\Permission\Models\Permission::findOrCreate('view_bank_reconciliations', 'web');
Role::firstOrCreate(['name' => 'finance_cashier']);
Role::firstOrCreate(['name' => 'finance_accountant']);
Role::firstOrCreate(['name' => 'finance_chair']);
$this->cashier = User::factory()->create(['email' => 'cashier@test.com']);
$this->accountant = User::factory()->create(['email' => 'accountant@test.com']);
$this->manager = User::factory()->create(['email' => 'manager@test.com']);
$this->cashier->assignRole('finance_cashier');
$this->accountant->assignRole('finance_accountant');
$this->manager->assignRole('finance_chair');
$this->cashier->givePermissionTo(['prepare_bank_reconciliation', 'view_bank_reconciliations']);
$this->accountant->givePermissionTo(['review_bank_reconciliation', 'view_bank_reconciliations']);
$this->manager->givePermissionTo(['approve_bank_reconciliation', 'view_bank_reconciliations']);
}
/** @test */
public function cashier_can_create_bank_reconciliation()
{
Storage::fake('local');
$this->actingAs($this->cashier);
$statement = UploadedFile::fake()->create('statement.pdf', 100);
$response = $this->post(route('admin.bank-reconciliations.store'), [
'reconciliation_month' => now()->format('Y-m'),
'bank_statement_date' => now()->format('Y-m-d'),
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'bank_statement_file' => $statement,
'outstanding_checks' => [
['check_number' => 'CHK001', 'amount' => 3000, 'description' => 'Vendor payment'],
['check_number' => 'CHK002', 'amount' => 2000, 'description' => 'Service fee'],
],
'deposits_in_transit' => [
['date' => now()->format('Y-m-d'), 'amount' => 5000, 'description' => 'Member dues'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Monthly service charge'],
],
'notes' => 'Monthly reconciliation',
]);
$response->assertRedirect();
$this->assertDatabaseHas('bank_reconciliations', [
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'prepared_by_cashier_id' => $this->cashier->id,
'reconciliation_status' => 'pending',
]);
}
/** @test */
public function reconciliation_calculates_adjusted_balance_correctly()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'outstanding_checks' => [
['check_number' => 'CHK001', 'amount' => 3000, 'description' => 'Test'],
['check_number' => 'CHK002', 'amount' => 2000, 'description' => 'Test'],
],
'deposits_in_transit' => [
['date' => now()->format('Y-m-d'), 'amount' => 5000, 'description' => 'Test'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Test'],
],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
]);
// Adjusted balance = 95000 + 5000 - 3000 - 2000 - 500 = 94500
$adjustedBalance = $reconciliation->calculateAdjustedBalance();
$this->assertEquals(94500, $adjustedBalance);
}
/** @test */
public function discrepancy_is_calculated_correctly()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'outstanding_checks' => [
['check_number' => 'CHK001', 'amount' => 3000, 'description' => 'Test'],
],
'deposits_in_transit' => [
['date' => now()->format('Y-m-d'), 'amount' => 5000, 'description' => 'Test'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Test'],
],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
]);
// Adjusted balance = 95000 + 5000 - 3000 - 500 = 96500
// Discrepancy = |100000 - 96500| = 3500
$discrepancy = $reconciliation->calculateDiscrepancy();
$this->assertEquals(3500, $discrepancy);
$reconciliation->discrepancy_amount = $discrepancy;
$reconciliation->save();
$this->assertTrue($reconciliation->hasDiscrepancy());
}
/** @test */
public function accountant_can_review_reconciliation()
{
$reconciliation = BankReconciliation::create([
'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' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 0,
]);
$this->actingAs($this->accountant);
$response = $this->post(route('admin.bank-reconciliations.review', $reconciliation), [
'review_notes' => 'Reviewed and looks correct',
]);
$response->assertRedirect();
$reconciliation->refresh();
$this->assertNotNull($reconciliation->reviewed_at);
$this->assertEquals($this->accountant->id, $reconciliation->reviewed_by_accountant_id);
}
/** @test */
public function manager_can_approve_reviewed_reconciliation()
{
$reconciliation = BankReconciliation::create([
'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' => $this->cashier->id,
'prepared_at' => now(),
'reviewed_by_accountant_id' => $this->accountant->id,
'reviewed_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 0,
]);
$this->actingAs($this->manager);
$response = $this->post(route('admin.bank-reconciliations.approve', $reconciliation), [
'approval_notes' => 'Approved',
]);
$response->assertRedirect();
$reconciliation->refresh();
$this->assertNotNull($reconciliation->approved_at);
$this->assertEquals($this->manager->id, $reconciliation->approved_by_manager_id);
$this->assertEquals('completed', $reconciliation->reconciliation_status);
}
/** @test */
public function cannot_approve_unreviewed_reconciliation()
{
$reconciliation = BankReconciliation::create([
'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' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 0,
]);
$this->assertFalse($reconciliation->canBeApproved());
}
/** @test */
public function reconciliation_with_large_discrepancy_is_flagged()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 90000, // Large discrepancy
'outstanding_checks' => [],
'deposits_in_transit' => [],
'bank_charges' => [],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 10000,
]);
$this->assertTrue($reconciliation->hasDiscrepancy());
$this->assertTrue($reconciliation->hasUnresolvedDiscrepancy());
}
/** @test */
public function outstanding_items_summary_is_calculated_correctly()
{
$reconciliation = BankReconciliation::create([
'reconciliation_month' => now()->startOfMonth(),
'bank_statement_date' => now(),
'bank_statement_balance' => 100000,
'system_book_balance' => 95000,
'outstanding_checks' => [
['check_number' => 'CHK001', 'amount' => 3000, 'description' => 'Test 1'],
['check_number' => 'CHK002', 'amount' => 2000, 'description' => 'Test 2'],
],
'deposits_in_transit' => [
['date' => now()->format('Y-m-d'), 'amount' => 5000, 'description' => 'Test 1'],
['date' => now()->format('Y-m-d'), 'amount' => 3000, 'description' => 'Test 2'],
],
'bank_charges' => [
['amount' => 500, 'description' => 'Test 1'],
['amount' => 200, 'description' => 'Test 2'],
],
'prepared_by_cashier_id' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
]);
$summary = $reconciliation->getOutstandingItemsSummary();
$this->assertEquals(5000, $summary['total_outstanding_checks']);
$this->assertEquals(2, $summary['outstanding_checks_count']);
$this->assertEquals(8000, $summary['total_deposits_in_transit']);
$this->assertEquals(2, $summary['deposits_in_transit_count']);
$this->assertEquals(700, $summary['total_bank_charges']);
$this->assertEquals(2, $summary['bank_charges_count']);
}
/** @test */
public function completed_reconciliation_can_generate_pdf()
{
$reconciliation = BankReconciliation::create([
'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' => $this->cashier->id,
'prepared_at' => now(),
'reviewed_by_accountant_id' => $this->accountant->id,
'reviewed_at' => now(),
'approved_by_manager_id' => $this->manager->id,
'approved_at' => now(),
'reconciliation_status' => 'completed',
'discrepancy_amount' => 0,
]);
$this->assertTrue($reconciliation->isCompleted());
$this->actingAs($this->cashier);
$response = $this->get(route('admin.bank-reconciliations.pdf', $reconciliation));
$response->assertStatus(200);
}
/** @test */
public function reconciliation_status_text_is_correct()
{
$pending = new BankReconciliation(['reconciliation_status' => 'pending']);
$this->assertEquals('待覆核', $pending->getStatusText());
$completed = new BankReconciliation(['reconciliation_status' => 'completed']);
$this->assertEquals('已完成', $completed->getStatusText());
$discrepancy = new BankReconciliation(['reconciliation_status' => 'discrepancy']);
$this->assertEquals('有差異', $discrepancy->getStatusText());
}
/** @test */
public function reconciliation_workflow_is_sequential()
{
$reconciliation = BankReconciliation::create([
'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' => $this->cashier->id,
'prepared_at' => now(),
'reconciliation_status' => 'pending',
'discrepancy_amount' => 0,
]);
// Can be reviewed initially
$this->assertTrue($reconciliation->canBeReviewed());
$this->assertFalse($reconciliation->canBeApproved());
// After review
$reconciliation->reviewed_by_accountant_id = $this->accountant->id;
$reconciliation->reviewed_at = now();
$reconciliation->save();
$this->assertFalse($reconciliation->canBeReviewed());
$this->assertTrue($reconciliation->canBeApproved());
// After approval
$reconciliation->approved_by_manager_id = $this->manager->id;
$reconciliation->approved_at = now();
$reconciliation->reconciliation_status = 'completed';
$reconciliation->save();
$this->assertFalse($reconciliation->canBeReviewed());
$this->assertFalse($reconciliation->canBeApproved());
$this->assertTrue($reconciliation->isCompleted());
}
}