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>
374 lines
13 KiB
PHP
374 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\EndToEnd;
|
|
|
|
use App\Mail\MembershipActivatedMail;
|
|
use App\Mail\PaymentApprovedByAccountantMail;
|
|
use App\Mail\PaymentApprovedByCashierMail;
|
|
use App\Mail\PaymentFullyApprovedMail;
|
|
use App\Mail\PaymentRejectedMail;
|
|
use App\Mail\PaymentSubmittedMail;
|
|
use App\Models\Member;
|
|
use App\Models\MembershipPayment;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Tests\TestCase;
|
|
use Tests\Traits\CreatesMemberData;
|
|
use Tests\Traits\SeedsRolesAndPermissions;
|
|
|
|
/**
|
|
* End-to-End Membership Workflow Tests
|
|
*
|
|
* Tests the complete membership registration and payment verification workflow
|
|
* from member registration through three-tier approval to membership activation.
|
|
*/
|
|
class MembershipWorkflowEndToEndTest extends TestCase
|
|
{
|
|
use RefreshDatabase, SeedsRolesAndPermissions, CreatesMemberData;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Storage::fake('private');
|
|
$this->seedRolesAndPermissions();
|
|
}
|
|
|
|
/**
|
|
* Test complete member registration to activation workflow
|
|
*/
|
|
public function test_complete_member_registration_to_activation_workflow(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
// Create approval team
|
|
$team = $this->createFinanceApprovalTeam();
|
|
|
|
// Step 1: Member registration
|
|
$registrationData = $this->getValidMemberRegistrationData();
|
|
$response = $this->post(route('register.member.store'), $registrationData);
|
|
|
|
$user = User::where('email', $registrationData['email'])->first();
|
|
$this->assertNotNull($user);
|
|
$member = $user->member;
|
|
$this->assertNotNull($member);
|
|
$this->assertEquals(Member::STATUS_PENDING, $member->membership_status);
|
|
|
|
// Step 2: Member submits payment
|
|
$file = $this->createFakeReceipt();
|
|
$this->actingAs($user)->post(route('member.payments.store'), [
|
|
'amount' => 1000,
|
|
'paid_at' => now()->format('Y-m-d'),
|
|
'payment_method' => MembershipPayment::METHOD_BANK_TRANSFER,
|
|
'reference' => 'REF123456',
|
|
'receipt' => $file,
|
|
]);
|
|
|
|
$payment = MembershipPayment::where('member_id', $member->id)->first();
|
|
$this->assertNotNull($payment);
|
|
$this->assertEquals(MembershipPayment::STATUS_PENDING, $payment->status);
|
|
|
|
// Step 3: Cashier approves
|
|
$this->actingAs($team['cashier'])->post(
|
|
route('admin.payment-verifications.approve-cashier', $payment),
|
|
['notes' => 'Receipt verified']
|
|
);
|
|
$payment->refresh();
|
|
$this->assertEquals(MembershipPayment::STATUS_APPROVED_CASHIER, $payment->status);
|
|
|
|
// Step 4: Accountant approves
|
|
$this->actingAs($team['accountant'])->post(
|
|
route('admin.payment-verifications.approve-accountant', $payment),
|
|
['notes' => 'Amount verified']
|
|
);
|
|
$payment->refresh();
|
|
$this->assertEquals(MembershipPayment::STATUS_APPROVED_ACCOUNTANT, $payment->status);
|
|
|
|
// Step 5: Chair approves and activates membership
|
|
$this->actingAs($team['chair'])->post(
|
|
route('admin.payment-verifications.approve-chair', $payment),
|
|
['notes' => 'Final approval']
|
|
);
|
|
$payment->refresh();
|
|
$member->refresh();
|
|
|
|
$this->assertEquals(MembershipPayment::STATUS_APPROVED_CHAIR, $payment->status);
|
|
$this->assertTrue($payment->isFullyApproved());
|
|
$this->assertEquals(Member::STATUS_ACTIVE, $member->membership_status);
|
|
$this->assertNotNull($member->membership_started_at);
|
|
$this->assertNotNull($member->membership_expires_at);
|
|
$this->assertTrue($member->hasPaidMembership());
|
|
|
|
// Verify emails were sent
|
|
Mail::assertQueued(MembershipActivatedMail::class);
|
|
Mail::assertQueued(PaymentFullyApprovedMail::class);
|
|
}
|
|
|
|
/**
|
|
* Test cashier can approve first tier
|
|
*/
|
|
public function test_member_registration_payment_cashier_approval(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$cashier = $this->createCashier();
|
|
$data = $this->createMemberWithPendingPayment();
|
|
|
|
$response = $this->actingAs($cashier)->post(
|
|
route('admin.payment-verifications.approve-cashier', $data['payment']),
|
|
['notes' => 'Verified']
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$data['payment']->refresh();
|
|
|
|
$this->assertEquals(MembershipPayment::STATUS_APPROVED_CASHIER, $data['payment']->status);
|
|
$this->assertEquals($cashier->id, $data['payment']->verified_by_cashier_id);
|
|
$this->assertNotNull($data['payment']->cashier_verified_at);
|
|
|
|
Mail::assertQueued(PaymentApprovedByCashierMail::class);
|
|
}
|
|
|
|
/**
|
|
* Test accountant can approve second tier
|
|
*/
|
|
public function test_member_registration_payment_accountant_approval(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$accountant = $this->createAccountant();
|
|
$data = $this->createMemberWithPaymentAtStage('cashier_approved');
|
|
|
|
$response = $this->actingAs($accountant)->post(
|
|
route('admin.payment-verifications.approve-accountant', $data['payment']),
|
|
['notes' => 'Amount verified']
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$data['payment']->refresh();
|
|
|
|
$this->assertEquals(MembershipPayment::STATUS_APPROVED_ACCOUNTANT, $data['payment']->status);
|
|
$this->assertEquals($accountant->id, $data['payment']->verified_by_accountant_id);
|
|
$this->assertNotNull($data['payment']->accountant_verified_at);
|
|
|
|
Mail::assertQueued(PaymentApprovedByAccountantMail::class);
|
|
}
|
|
|
|
/**
|
|
* Test chair can approve third tier and activate membership
|
|
*/
|
|
public function test_member_registration_payment_chair_approval_and_activation(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$chair = $this->createChair();
|
|
$data = $this->createMemberWithPaymentAtStage('accountant_approved');
|
|
|
|
$response = $this->actingAs($chair)->post(
|
|
route('admin.payment-verifications.approve-chair', $data['payment']),
|
|
['notes' => 'Final approval']
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$data['payment']->refresh();
|
|
$data['member']->refresh();
|
|
|
|
$this->assertEquals(MembershipPayment::STATUS_APPROVED_CHAIR, $data['payment']->status);
|
|
$this->assertTrue($data['payment']->isFullyApproved());
|
|
$this->assertEquals(Member::STATUS_ACTIVE, $data['member']->membership_status);
|
|
$this->assertTrue($data['member']->hasPaidMembership());
|
|
|
|
Mail::assertQueued(PaymentFullyApprovedMail::class);
|
|
Mail::assertQueued(MembershipActivatedMail::class);
|
|
}
|
|
|
|
/**
|
|
* Test payment rejection at cashier level
|
|
*/
|
|
public function test_payment_rejection_at_cashier_level(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$cashier = $this->createCashier();
|
|
$data = $this->createMemberWithPendingPayment();
|
|
|
|
$response = $this->actingAs($cashier)->post(
|
|
route('admin.payment-verifications.reject', $data['payment']),
|
|
['rejection_reason' => 'Invalid receipt - image is blurry']
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$data['payment']->refresh();
|
|
|
|
$this->assertEquals(MembershipPayment::STATUS_REJECTED, $data['payment']->status);
|
|
$this->assertEquals('Invalid receipt - image is blurry', $data['payment']->rejection_reason);
|
|
$this->assertEquals($cashier->id, $data['payment']->rejected_by_user_id);
|
|
$this->assertNotNull($data['payment']->rejected_at);
|
|
|
|
Mail::assertQueued(PaymentRejectedMail::class);
|
|
}
|
|
|
|
/**
|
|
* Test payment rejection at accountant level
|
|
*/
|
|
public function test_payment_rejection_at_accountant_level(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$accountant = $this->createAccountant();
|
|
$data = $this->createMemberWithPaymentAtStage('cashier_approved');
|
|
|
|
$response = $this->actingAs($accountant)->post(
|
|
route('admin.payment-verifications.reject', $data['payment']),
|
|
['rejection_reason' => 'Amount does not match receipt']
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$data['payment']->refresh();
|
|
|
|
$this->assertEquals(MembershipPayment::STATUS_REJECTED, $data['payment']->status);
|
|
$this->assertEquals('Amount does not match receipt', $data['payment']->rejection_reason);
|
|
$this->assertEquals($accountant->id, $data['payment']->rejected_by_user_id);
|
|
|
|
Mail::assertQueued(PaymentRejectedMail::class);
|
|
}
|
|
|
|
/**
|
|
* Test payment rejection at chair level
|
|
*/
|
|
public function test_payment_rejection_at_chair_level(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$chair = $this->createChair();
|
|
$data = $this->createMemberWithPaymentAtStage('accountant_approved');
|
|
|
|
$response = $this->actingAs($chair)->post(
|
|
route('admin.payment-verifications.reject', $data['payment']),
|
|
['rejection_reason' => 'Membership application incomplete']
|
|
);
|
|
|
|
$response->assertRedirect();
|
|
$data['payment']->refresh();
|
|
|
|
$this->assertEquals(MembershipPayment::STATUS_REJECTED, $data['payment']->status);
|
|
$this->assertEquals($chair->id, $data['payment']->rejected_by_user_id);
|
|
}
|
|
|
|
/**
|
|
* Test member can resubmit payment after rejection
|
|
*/
|
|
public function test_member_resubmit_payment_after_rejection(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$data = $this->createMemberWithPendingPayment();
|
|
$member = $data['member'];
|
|
$user = $member->user;
|
|
|
|
// Simulate rejection
|
|
$data['payment']->update([
|
|
'status' => MembershipPayment::STATUS_REJECTED,
|
|
'rejection_reason' => 'Invalid receipt',
|
|
]);
|
|
|
|
// Member submits new payment
|
|
$newReceipt = $this->createFakeReceipt('new_receipt.jpg');
|
|
$response = $this->actingAs($user)->post(route('member.payments.store'), [
|
|
'amount' => 1000,
|
|
'paid_at' => now()->format('Y-m-d'),
|
|
'payment_method' => MembershipPayment::METHOD_BANK_TRANSFER,
|
|
'reference' => 'NEWREF123',
|
|
'receipt' => $newReceipt,
|
|
]);
|
|
|
|
$response->assertRedirect();
|
|
|
|
// Verify new payment was created
|
|
$newPayment = MembershipPayment::where('member_id', $member->id)
|
|
->where('status', MembershipPayment::STATUS_PENDING)
|
|
->latest()
|
|
->first();
|
|
|
|
$this->assertNotNull($newPayment);
|
|
$this->assertNotEquals($data['payment']->id, $newPayment->id);
|
|
$this->assertEquals(MembershipPayment::STATUS_PENDING, $newPayment->status);
|
|
}
|
|
|
|
/**
|
|
* Test multiple members can register concurrently
|
|
*/
|
|
public function test_multiple_members_concurrent_registration(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$members = [];
|
|
for ($i = 1; $i <= 3; $i++) {
|
|
$registrationData = $this->getValidMemberRegistrationData([
|
|
'email' => "member{$i}@example.com",
|
|
'full_name' => "Test Member {$i}",
|
|
]);
|
|
|
|
$this->post(route('register.member.store'), $registrationData);
|
|
$members[$i] = User::where('email', "member{$i}@example.com")->first();
|
|
}
|
|
|
|
// Verify all members were created
|
|
foreach ($members as $i => $user) {
|
|
$this->assertNotNull($user);
|
|
$this->assertNotNull($user->member);
|
|
$this->assertEquals(Member::STATUS_PENDING, $user->member->membership_status);
|
|
$this->assertEquals("Test Member {$i}", $user->member->full_name);
|
|
}
|
|
|
|
$this->assertCount(3, Member::where('membership_status', Member::STATUS_PENDING)->get());
|
|
}
|
|
|
|
/**
|
|
* Test member status transitions through workflow
|
|
*/
|
|
public function test_member_status_transitions_through_workflow(): void
|
|
{
|
|
Mail::fake();
|
|
|
|
$team = $this->createFinanceApprovalTeam();
|
|
$data = $this->createMemberWithPendingPayment();
|
|
$member = $data['member'];
|
|
$payment = $data['payment'];
|
|
|
|
// Initial status
|
|
$this->assertEquals(Member::STATUS_PENDING, $member->membership_status);
|
|
$this->assertFalse($member->hasPaidMembership());
|
|
|
|
// After cashier approval - member still pending
|
|
$this->actingAs($team['cashier'])->post(
|
|
route('admin.payment-verifications.approve-cashier', $payment)
|
|
);
|
|
$member->refresh();
|
|
$this->assertEquals(Member::STATUS_PENDING, $member->membership_status);
|
|
|
|
// After accountant approval - member still pending
|
|
$payment->refresh();
|
|
$this->actingAs($team['accountant'])->post(
|
|
route('admin.payment-verifications.approve-accountant', $payment)
|
|
);
|
|
$member->refresh();
|
|
$this->assertEquals(Member::STATUS_PENDING, $member->membership_status);
|
|
|
|
// After chair approval - member becomes active
|
|
$payment->refresh();
|
|
$this->actingAs($team['chair'])->post(
|
|
route('admin.payment-verifications.approve-chair', $payment)
|
|
);
|
|
$member->refresh();
|
|
|
|
$this->assertEquals(Member::STATUS_ACTIVE, $member->membership_status);
|
|
$this->assertTrue($member->hasPaidMembership());
|
|
$this->assertNotNull($member->membership_started_at);
|
|
$this->assertNotNull($member->membership_expires_at);
|
|
$this->assertTrue($member->membership_expires_at->isAfter(now()));
|
|
}
|
|
}
|