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>
300 lines
8.9 KiB
PHP
300 lines
8.9 KiB
PHP
<?php
|
||
|
||
namespace Tests\Unit;
|
||
|
||
use App\Models\FinanceDocument;
|
||
use App\Models\User;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
/**
|
||
* Finance Document Model Unit Tests
|
||
*
|
||
* Tests business logic methods in FinanceDocument model
|
||
* Using new workflow: Secretary → Chair → Board
|
||
*/
|
||
class FinanceDocumentTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
/** @test */
|
||
public function it_determines_small_amount_tier_correctly()
|
||
{
|
||
$document = new FinanceDocument(['amount' => 4999]);
|
||
$this->assertEquals('small', $document->determineAmountTier());
|
||
|
||
$document->amount = 3000;
|
||
$this->assertEquals('small', $document->determineAmountTier());
|
||
|
||
$document->amount = 1;
|
||
$this->assertEquals('small', $document->determineAmountTier());
|
||
}
|
||
|
||
/** @test */
|
||
public function it_determines_medium_amount_tier_correctly()
|
||
{
|
||
$document = new FinanceDocument(['amount' => 5000]);
|
||
$this->assertEquals('medium', $document->determineAmountTier());
|
||
|
||
$document->amount = 25000;
|
||
$this->assertEquals('medium', $document->determineAmountTier());
|
||
|
||
$document->amount = 50000;
|
||
$this->assertEquals('medium', $document->determineAmountTier());
|
||
}
|
||
|
||
/** @test */
|
||
public function it_determines_large_amount_tier_correctly()
|
||
{
|
||
$document = new FinanceDocument(['amount' => 50001]);
|
||
$this->assertEquals('large', $document->determineAmountTier());
|
||
|
||
$document->amount = 100000;
|
||
$this->assertEquals('large', $document->determineAmountTier());
|
||
|
||
$document->amount = 1000000;
|
||
$this->assertEquals('large', $document->determineAmountTier());
|
||
}
|
||
|
||
/** @test */
|
||
public function small_amount_does_not_need_board_meeting()
|
||
{
|
||
$document = new FinanceDocument(['amount' => 4999]);
|
||
$this->assertFalse($document->needsBoardMeetingApproval());
|
||
}
|
||
|
||
/** @test */
|
||
public function medium_amount_does_not_need_board_meeting()
|
||
{
|
||
$document = new FinanceDocument(['amount' => 50000]);
|
||
$this->assertFalse($document->needsBoardMeetingApproval());
|
||
}
|
||
|
||
/** @test */
|
||
public function large_amount_needs_board_meeting()
|
||
{
|
||
$document = new FinanceDocument(['amount' => 50001]);
|
||
$this->assertTrue($document->needsBoardMeetingApproval());
|
||
}
|
||
|
||
/** @test */
|
||
public function small_amount_approval_stage_is_complete_after_secretary()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'amount' => 3000,
|
||
'amount_tier' => 'small',
|
||
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
|
||
]);
|
||
|
||
$this->assertTrue($document->isApprovalComplete());
|
||
}
|
||
|
||
/** @test */
|
||
public function medium_amount_approval_stage_needs_chair()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'amount' => 25000,
|
||
'amount_tier' => 'medium',
|
||
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
|
||
]);
|
||
|
||
$this->assertFalse($document->isApprovalComplete());
|
||
|
||
$document->status = FinanceDocument::STATUS_APPROVED_CHAIR;
|
||
$this->assertTrue($document->isApprovalComplete());
|
||
}
|
||
|
||
/** @test */
|
||
public function large_amount_approval_stage_needs_chair_and_board()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'amount' => 75000,
|
||
'amount_tier' => 'large',
|
||
'status' => FinanceDocument::STATUS_APPROVED_CHAIR,
|
||
]);
|
||
|
||
$this->assertFalse($document->isApprovalComplete());
|
||
|
||
$document->status = FinanceDocument::STATUS_APPROVED_BOARD;
|
||
$this->assertTrue($document->isApprovalComplete());
|
||
}
|
||
|
||
/** @test */
|
||
public function secretary_cannot_approve_own_submission()
|
||
{
|
||
$user = User::factory()->create();
|
||
|
||
$document = new FinanceDocument([
|
||
'submitted_by_user_id' => $user->id,
|
||
'status' => FinanceDocument::STATUS_PENDING,
|
||
]);
|
||
|
||
$this->assertFalse($document->canBeApprovedBySecretary($user));
|
||
}
|
||
|
||
/** @test */
|
||
public function secretary_can_approve_others_submission()
|
||
{
|
||
$submitter = User::factory()->create();
|
||
$secretary = User::factory()->create();
|
||
|
||
$document = new FinanceDocument([
|
||
'submitted_by_user_id' => $submitter->id,
|
||
'status' => FinanceDocument::STATUS_PENDING,
|
||
]);
|
||
|
||
$this->assertTrue($document->canBeApprovedBySecretary($secretary));
|
||
}
|
||
|
||
/** @test */
|
||
public function chair_cannot_approve_before_secretary()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'status' => FinanceDocument::STATUS_PENDING,
|
||
'amount_tier' => 'medium',
|
||
]);
|
||
|
||
$this->assertFalse($document->canBeApprovedByChair());
|
||
}
|
||
|
||
/** @test */
|
||
public function chair_can_approve_after_secretary()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
|
||
'amount_tier' => 'medium',
|
||
]);
|
||
|
||
$this->assertTrue($document->canBeApprovedByChair());
|
||
}
|
||
|
||
/** @test */
|
||
public function chair_can_approve_after_secretary_for_medium_amounts()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
|
||
'amount_tier' => 'medium',
|
||
]);
|
||
|
||
$this->assertTrue($document->canBeApprovedByChair());
|
||
}
|
||
|
||
/** @test */
|
||
public function payment_order_can_be_created_after_approval_stage()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'amount' => 3000,
|
||
'amount_tier' => 'small',
|
||
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
|
||
]);
|
||
|
||
$this->assertTrue($document->canCreatePaymentOrder());
|
||
}
|
||
|
||
/** @test */
|
||
public function payment_order_cannot_be_created_before_approval_complete()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'status' => FinanceDocument::STATUS_PENDING,
|
||
'amount_tier' => 'small',
|
||
]);
|
||
|
||
$this->assertFalse($document->canCreatePaymentOrder());
|
||
}
|
||
|
||
/** @test */
|
||
public function disbursement_requires_dual_confirmation()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'amount' => 3000,
|
||
'amount_tier' => 'small',
|
||
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
|
||
]);
|
||
|
||
$this->assertFalse($document->isDisbursementComplete());
|
||
|
||
// Only requester confirmed
|
||
$document->requester_confirmed_at = now();
|
||
$this->assertFalse($document->isDisbursementComplete());
|
||
|
||
// Both confirmed
|
||
$document->cashier_confirmed_at = now();
|
||
$this->assertTrue($document->isDisbursementComplete());
|
||
}
|
||
|
||
/** @test */
|
||
public function payment_completed_check_works()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'payment_order_created_at' => now(),
|
||
'payment_verified_at' => now(),
|
||
'payment_executed_at' => null,
|
||
]);
|
||
|
||
$this->assertFalse($document->isPaymentCompleted());
|
||
|
||
$document->payment_executed_at = now();
|
||
$this->assertTrue($document->isPaymentCompleted());
|
||
}
|
||
|
||
/** @test */
|
||
public function recording_complete_check_works()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'accountant_recorded_at' => null,
|
||
]);
|
||
|
||
$this->assertFalse($document->isRecordingComplete());
|
||
|
||
$document->accountant_recorded_at = now();
|
||
$this->assertTrue($document->isRecordingComplete());
|
||
}
|
||
|
||
/** @test */
|
||
public function reconciled_check_works()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'bank_reconciliation_id' => null,
|
||
]);
|
||
|
||
$this->assertFalse($document->isReconciled());
|
||
|
||
$document->bank_reconciliation_id = 1;
|
||
$this->assertTrue($document->isReconciled());
|
||
}
|
||
|
||
/** @test */
|
||
public function amount_tier_text_is_correct()
|
||
{
|
||
$small = new FinanceDocument(['amount_tier' => 'small']);
|
||
$this->assertEquals('小額(< 5000)', $small->getAmountTierText());
|
||
|
||
$medium = new FinanceDocument(['amount_tier' => 'medium']);
|
||
$this->assertEquals('中額(5000-50000)', $medium->getAmountTierText());
|
||
|
||
$large = new FinanceDocument(['amount_tier' => 'large']);
|
||
$this->assertEquals('大額(> 50000)', $large->getAmountTierText());
|
||
}
|
||
|
||
/** @test */
|
||
public function fully_processed_check_works()
|
||
{
|
||
$document = new FinanceDocument([
|
||
'amount' => 3000,
|
||
'amount_tier' => 'small',
|
||
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
|
||
]);
|
||
|
||
$this->assertFalse($document->isFullyProcessed());
|
||
|
||
// Complete disbursement
|
||
$document->requester_confirmed_at = now();
|
||
$document->cashier_confirmed_at = now();
|
||
$this->assertFalse($document->isFullyProcessed());
|
||
|
||
// Complete recording
|
||
$document->accountant_recorded_at = now();
|
||
$this->assertTrue($document->isFullyProcessed());
|
||
}
|
||
}
|