docs: map existing codebase

This commit is contained in:
2026-02-13 10:34:18 +08:00
parent 296a70010d
commit 47218c1874
7 changed files with 2083 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
# Architecture
**Analysis Date:** 2026-02-13
## Pattern Overview
**Overall:** MVC (Model-View-Controller) with Service Layer for complex business logic
**Key Characteristics:**
- **Layered architecture**: Controllers → Services → Models → Traits
- **Multi-tier approval workflows**: Amount-based approval hierarchy (Secretary → Chair → Board)
- **Double-entry bookkeeping**: Automatic accounting entry generation for financial documents
- **Trait-based shared behavior**: Cross-cutting concerns (approval workflow, accounting entries)
- **Audit logging**: Centralized action tracking via `AuditLogger::log()`
- **Role-based access control (RBAC)**: Spatie Laravel Permission with financial roles
## Layers
**Presentation Layer:**
- Purpose: Render web UI and handle HTTP requests
- Location: `resources/views/`, `app/Http/Controllers/`
- Contains: Blade templates, controllers, Form Requests, API Resources
- Depends on: Models, Services, View Components
- Used by: Web browsers, Next.js frontend (via API)
**Controller Layer:**
- Purpose: HTTP request handling, input validation, orchestration
- Location: `app/Http/Controllers/`
- Contains: Controllers split by domain (Admin, Auth, Api)
- Depends on: Models, Services, Form Requests
- Pattern: Validation via Form Request classes (e.g., `StoreFinanceDocumentRequest`), service calls for business logic
**Service Layer:**
- Purpose: Encapsulate complex business logic
- Location: `app/Services/`
- Contains: `FinanceDocumentApprovalService`, `MembershipFeeCalculator`, `SettingsService`, `PaymentVerificationService`, `SiteRevalidationService`
- Depends on: Models, Audit Logger, Mail
- Example: `FinanceDocumentApprovalService` orchestrates multi-tier approval workflow; returns `['success' => bool, 'message' => string, 'complete' => bool]`
**Model Layer:**
- Purpose: Data access, business rules, relationships
- Location: `app/Models/`
- Contains: 36 Eloquent models including `Member`, `FinanceDocument`, `User`, `Issue`, `Article`, `Document`
- Depends on: Database, Traits
- Key models and relationships documented in Finance Workflow section
**Trait Layer:**
- Purpose: Shared behavior across multiple models
- Location: `app/Traits/`
- Contains: `HasApprovalWorkflow`, `HasAccountingEntries`
- Usage: Models use these traits to inherit approval/accounting behavior without duplication
**Support Layer:**
- Purpose: Utility functions and helper classes
- Location: `app/Support/`
- Contains: `AuditLogger` (centralized audit logging), `DownloadFile` (file download handler)
- Global helper: `settings('key')` function in `app/helpers.php` — returns cached system settings
## Data Flow
### Member Lifecycle Flow
1. **Registration**: Non-member completes form at `/register/member` → stored in `Members` table with `status = pending`
2. **Profile Activation**: Member logs in, fills profile at `/create-member-profile`
3. **Payment Submission**: Member submits payment proof at `/member/submit-payment``MembershipPayments` table
4. **Payment Verification**: Admin verifies payment via multi-tier approval workflow
5. **Activation**: Admin activates member → `status = active`, `membership_expires_at` set
6. **Expiry**: Cron job or admin action → `status = expired` when `membership_expires_at` passes
**Key Methods:**
- `Member::hasPaidMembership()` — checks if active with future expiry
- `Member::canSubmitPayment()` — validates pending status with no pending payments
- `Member::getNextFeeType()` — returns entrance_fee or annual_fee
### Finance Document Approval Flow (3-Stage Lifecycle)
#### Stage 1: Approval (Amount-Based Multi-Tier)
1. **Submission**: User submits finance document → `FinanceDocument::STATUS_PENDING`
- `submitted_by_user_id`, `submitted_at`, `amount`, `title` set
- Amount tier auto-determined: `determineAmountTier()` → small/medium/large
2. **Secretary Approval**: Secretary reviews → `approveBySecretary()`
- For small amount (<5,000): Approval complete, notifies submitter
- For medium/large: Escalates to Chair, notifies next approvers
- Status: `STATUS_APPROVED_SECRETARY`
3. **Chair Approval**: Chair reviews (if amount ≥ 5,000) → `approveByChair()`
- For medium amount (5,000-50,000): Approval complete
- For large amount (>50,000): Escalates to Board, notifies board members
- Status: `STATUS_APPROVED_CHAIR`
4. **Board Approval**: Board members review (if amount > 50,000) → `approveByBoard()`
- Final approval stage
- Status: `STATUS_APPROVED_BOARD`
**Rejection Handling**: User can reject at any stage → `STATUS_REJECTED`, triggers email notification
**Helper Methods**:
```php
$doc->isApprovalComplete() // All required approvals obtained
$doc->canBeApprovedBySecretary($user) // Validates secretary can approve
$doc->canBeApprovedByChair($user) // Validates chair can approve
$doc->getAmountTierLabel() // Returns 小額/中額/大額
```
#### Stage 2: Disbursement (Dual Confirmation)
1. **Requester Confirmation**: Document requester confirms disbursement details
- `DISBURSEMENT_REQUESTER_CONFIRMED` status
- Field: `disbursement_requester_confirmed_at`
2. **Cashier Confirmation**: Cashier verifies funds transferred
- `DISBURSEMENT_CASHIER_CONFIRMED` status
- Field: `disbursement_cashier_confirmed_at`
- Creates `PaymentOrder` record
3. **Completion**: Once both confirm → `DISBURSEMENT_COMPLETED`
**Helper**: `$doc->isDisbursementComplete()` — both parties confirmed
#### Stage 3: Recording (Ledger Entry)
1. **Cashier Ledger Entry**: Cashier records transaction to ledger
- Creates `CashierLedgerEntry` with debit/credit amounts
- Status: `RECORDING_PENDING``RECORDING_COMPLETED`
2. **Accounting Entry**: Automatic double-entry bookkeeping via `HasAccountingEntries` trait
- Creates `AccountingEntry` records (debit + credit pair)
- Account codes from `config/accounting.php`
- Field: `chart_of_account_id`
**Helper**: `$doc->isRecordingComplete()` — ledger entry created
**Full Completion**: `$doc->isFullyProcessed()` — all three stages complete
---
### Payment Verification Flow
Separate approval workflow for `MembershipPayments` with three tiers:
1. **Cashier Approval**: Verifies payment received
2. **Accountant Approval**: Validates accounting entry
3. **Chair Approval**: Final sign-off for large payments
**Route**: `/admin/payment-verifications` with status-based filtering
---
### CMS Content Flow (Articles/Pages)
1. **Creation**: Admin creates article at `/admin/articles/create` with:
- Title, slug, body (markdown via EasyMDE), featured image, status
- Categories and tags (many-to-many relationships)
2. **Publication**: Admin publishes → `status = published`, `published_at` set
3. **API Exposure**: Content available via `/api/v1/articles`, `/api/v1/pages`
4. **Site Revalidation**: On publish/archive, fires webhook to Next.js for static site rebuild
- Service: `SiteRevalidationService`
- Webhook: POST to `services.nextjs.revalidate_url` with token
---
### Document Library Flow
1. **Upload**: Admin uploads document at `/admin/documents/create`
- Creates `Document` with `status = active`
- Multiple versions tracked via `DocumentVersion` table
2. **Version Control**:
- New version upload creates `DocumentVersion` record
- Promote version: sets as current version
- Archive: soft delete via `status = archived`
3. **Public Access**: Documents visible at `/documents` if `access_level` allows
- Access tracking via `DocumentAccessLog`
- QR code generation at `/documents/{uuid}/qrcode`
4. **Permission Check**:
- Method: `$doc->canBeViewedBy($user)` validates access
- Visibility: public, members-only, or admin-only
---
**State Management:**
- **Status Fields**: Use class constants, not enums (e.g., `FinanceDocument::STATUS_PENDING`)
- **Timestamps**: Created/updated via timestamps; specific approval/rejection times via explicit fields
- **Soft Deletes**: Used for documents (`status = archived`), not traditional soft delete
- **Transactions**: DB transactions wrap multi-step operations (approval, payment, recording)
## Key Abstractions
**HasApprovalWorkflow Trait:**
- Purpose: Provides reusable multi-tier approval methods
- Files: `app/Traits/HasApprovalWorkflow.php`
- Used by: `FinanceDocument`, `MembershipPayment`, `Budget`
- Methods: `isSelfApproval()`, `canProceedWithApproval()`, `canBeRejected()`, `getApprovalHistory()`
- Pattern: Models define STATUS_* constants; trait provides helper methods; services orchestrate workflow
**HasAccountingEntries Trait:**
- Purpose: Automatic double-entry bookkeeping
- Files: `app/Traits/HasAccountingEntries.php`
- Used by: `FinanceDocument`, `Income`, `Transaction`
- Methods: `debitEntries()`, `creditEntries()`, `validateBalance()`, `autoGenerateAccountingEntries()`
- Pattern: Account IDs from `config/accounting.php`; amount tier determines debit/credit mapping
**Audit Logging:**
- Purpose: Track all business-critical actions
- Class: `app/Support/AuditLogger`
- Usage: `AuditLogger::log('finance_document.approved_by_secretary', $document, ['approved_by' => $user->name])`
- Storage: `AuditLog` model with JSON metadata
- Pattern: Static method callable from anywhere; filters secrets/uploads
**Settings Service:**
- Purpose: Centralized system configuration
- File: `app/Services/SettingsService`
- Usage: `settings('membership.entrance_fee')` or `settings('key', 'default')`
- Cache: Redis/cache layer to avoid repeated DB queries
- Locations: `SystemSetting` model, `config/` files
## Entry Points
**Web Routes:**
- Location: `routes/web.php` (393 lines covering all web endpoints)
- Public: `/`, `/register/member` (if enabled), `/documents`
- Authenticated: `/dashboard`, `/my-membership`, `/member/submit-payment`
- Admin: `/admin/*` with `admin` middleware and named route prefix `admin.{module}.{action}`
**API Routes:**
- Location: `routes/api.php`
- Version: v1 prefix
- Endpoints: `/api/v1/articles`, `/api/v1/pages`, `/api/v1/homepage`, `/api/v1/public-documents`
- Authentication: Sanctum (optional for public endpoints)
**Artisan Commands:**
- Location: `app/Console/Commands/`
- Custom commands: `assign:role`, `import:members`, `import:accounting-data`, `import:documents`, `send:membership-expiry-reminders`, `archive:expired-documents`
**Auth Routes:**
- Location: `routes/auth.php`
- Includes: Login, registration (if enabled), password reset, email verification
- Middleware: `auth`, `verified`
## Error Handling
**Strategy:** Exception-based with Blade error views
**Patterns:**
- **Form validation errors**: Automatically displayed in Blade templates via `@error` directive
- **Authorization errors**: Thrown by `@can` directive or `authorize()` in controllers
- **Business logic errors**: Services return `['success' => false, 'message' => 'reason']` or throw exceptions
- **Audit logging errors**: Wrapped in try-catch to prevent breaking main flow
- **Exception handling**: `app/Exceptions/Handler.php` defines response rendering
**File uploads:**
- Stored in `storage/app/` (private by default)
- Finance documents: `storage/app/finance-documents/`
- Article uploads: `storage/app/articles/`
- Disability certificates: `storage/app/disability-certificates/`
## Cross-Cutting Concerns
**Logging:**
- Framework: Laravel's default logging (PSR-3 via Monolog)
- Audit trail: `AuditLogger` class for business actions
- Config: `config/logging.php`
**Validation:**
- Location: `app/Http/Requests/` — Form Request classes enforce rules
- Pattern: Controller calls `$request->validated()` after implicit validation
- Examples: `StoreMemberRequest`, `StoreFinanceDocumentRequest`, `UpdateMemberRequest`
**Authentication:**
- Framework: Laravel Breeze (session-based)
- Models: `User` model with `HasFactory` and relationships to roles/permissions
- Guards: `web` (default for sessions), `sanctum` (for API)
**Authorization:**
- Framework: Spatie Laravel Permission
- Roles: `admin`, `finance_requester`, `finance_cashier`, `finance_accountant`, `finance_chair`, `finance_board_member`, `membership_manager`
- Permissions: `view_*`, `create_*`, `edit_*`, `delete_*`, `publish_*`, custom permissions for finance tiers
- Checking: `$user->can('permission-name')` in controllers, `@can` in Blade
**Encryption:**
- National IDs: AES-256 encrypted in `national_id_encrypted` column
- Hash for searching: SHA256 in `national_id_hash` column
- Virtual accessor `national_id`: Auto-decrypts on read
- Method: `Member::findByNationalId($id)` uses hash for query
---
*Architecture analysis: 2026-02-13*

View File

@@ -0,0 +1,293 @@
# Codebase Concerns
**Analysis Date:** 2026-02-13
## Tech Debt
### FinanceDocument Model Complexity
- **Issue:** `app/Models/FinanceDocument.php` is 882 lines with 27+ status constants and multiple workflows (approval, disbursement, recording, payment). Contains both old workflow (cashier→accountant) and new workflow (secretary→chair→board) fields and methods coexisting.
- **Files:** `app/Models/FinanceDocument.php` (lines 1-882)
- **Impact:** Hard to maintain, difficult to extend, high risk of introducing bugs in approval logic. Multiple overlapping status fields make state transitions error-prone.
- **Fix approach:**
- Extract new workflow logic into separate `FinanceDocumentApprovalService` class (already partially exists in codebase)
- Remove deprecated cashier/accountant approval workflow entirely once old code migrated
- Consider using state machine package (e.g., `thiagoprz/eloquent-state-machine`) to manage state transitions
### Large Model Files
- **Issue:** Multiple models exceed 400+ lines: `Income` (467), `Document` (466), `Article` (460), `Member` (437), `Announcement` (427), `Issue` (363)
- **Files:** `app/Models/{Income,Document,Article,Member,Announcement,Issue}.php`
- **Impact:** Models are bloated with business logic; difficult to test and maintain
- **Fix approach:** Extract domain logic to service classes, keep models for data relationships only
### Backward Compatibility Overhead
- **Issue:** FinanceDocument maintains both old (cashier/accountant) and new (secretary/chair/board) approval workflows with deprecated methods marked `@deprecated`
- **Files:** `app/Models/FinanceDocument.php` (lines 551-568, 757+)
- **Impact:** Code duplication, confusing for developers, increases cognitive load
- **Fix approach:** Set migration deadline (e.g., March 2026), remove all deprecated methods and legacy status constants
## Known Bugs
### Site Revalidation Webhook Silent Failures
- **Symptoms:** Next.js static site may not update when articles/pages/documents are modified in Laravel admin
- **Files:** `app/Services/SiteRevalidationService.php` (lines 34-59)
- **Trigger:** Log warnings only at WARNING level when webhook fails; developers may not notice missed revalidations
- **Current state:** Catches exceptions but logs only warnings; webhook timeout is 5 seconds
- **Workaround:** Check Laravel logs manually for `"Site revalidation failed"` warnings
- **Fix approach:**
- Queue webhook calls with retry logic (e.g., 3 retries with exponential backoff)
- Log errors at ERROR level with alert notification
- Add monitoring for failed revalidations
### Email Notifications May Fail Silently
- **Symptoms:** Users don't receive approval/rejection notifications (e.g., `FinanceDocumentFullyApproved`, `PaymentSubmittedMail`)
- **Files:** `app/Http/Controllers/FinanceDocumentController.php` (lines 105-108, 174, 184, etc.)
- **Trigger:** Uses `Mail::queue()` without error handling or confirmation
- **Current state:** Emails queued but no verification of delivery
- **Fix approach:**
- Add event listeners to track mail failures
- Implement fallback notification channel (SMS via existing SMS service or in-app notifications)
- Log all mail queue operations with request ID for audit trail
## Security Considerations
### National ID Decryption Exception Not Fully Handled
- **Risk:** Corrupted or mismatched encryption keys could cause decryption to fail; exception logged but member object may be in inconsistent state
- **Files:** `app/Models/Member.php` (lines 177-190)
- **Current mitigation:** Try-catch block logs error to Laravel log
- **Recommendations:**
- Rotate encryption keys with migration strategy for existing encrypted data
- Add health check command to verify all encrypted data can be decrypted
- Implement decryption retry with fallback (query original source if available)
- Document key rotation procedure in SECURITY.md
### Password Generation from Phone Number
- **Risk:** Password derived from last 4 digits of phone (`substr($phone, -4)`) is not cryptographically secure for initial user setup
- **Files:** `app/Console/Commands/ImportMembersCommand.php` (lines 135-146)
- **Current mitigation:** Used only during bulk import; users should change password after first login
- **Recommendations:**
- Force password reset on first login (add `force_password_reset` flag to User model)
- Use random password generation instead of phone-derived passwords
- Send temporary password via email (encrypted) or secure channel, never via phone number
### Mail Headers Not Validated
- **Risk:** Site revalidation webhook uses unvalidated `x-revalidate-token` header; no CSRF protection
- **Files:** `app/Services/SiteRevalidationService.php` (line 54)
- **Current mitigation:** Token stored in `.env` file
- **Recommendations:**
- Document token rotation process
- Add rate limiting to revalidation endpoint
- Consider using HMAC-SHA256 signed requests instead of bearer tokens
### Public Document UUID Leakage
- **Risk:** Documents have `public_uuid` exposed in API; uuid may be guessable if predictable
- **Files:** `app/Models/Document.php` (revalidation calls public_uuid)
- **Current mitigation:** Appears to use Laravel UUIDs (non-sequential)
- **Recommendations:**
- Verify UUID generation uses `Illuminate\Support\Str::uuid()` (v4, cryptographically random)
- Audit Document access control in `app/Http/Controllers/PublicDocumentController.php`
## Performance Bottlenecks
### Large Load in FinanceDocument Show Page
- **Problem:** `FinanceDocumentController::show()` eager loads 17 relationships, many of which may be unused in view
- **Files:** `app/Http/Controllers/FinanceDocumentController.php` (lines 116-141)
- **Cause:** Over-eager loading; should load relationships based on view requirements
- **Improvement path:**
- Split load into view-specific queries
- Use `with()` conditionally based on user role
- Measure N+1 queries with Laravel Debugbar
### N+1 Queries on Finance Index Listing
- **Problem:** FinanceDocument index loads 15 results with full relationships but filters happen in PHP, not database
- **Files:** `app/Http/Controllers/FinanceDocumentController.php` (lines 20-56)
- **Cause:** Workflow stage filtering happens after query, not in WHERE clause
- **Improvement path:**
- Move workflow_stage logic into Eloquent scopes
- Use `whereRaw()` for date-based stage detection if necessary
- Index database columns: `payment_order_created_at`, `payment_executed_at`, `cashier_ledger_entry_id`, `accounting_transaction_id`
### Import Commands Process Large Files In Memory
- **Problem:** `ImportMembersCommand`, `ImportAccountingData`, `ImportHugoContent` load entire Excel files into memory with PhpSpreadsheet
- **Files:** `app/Console/Commands/{ImportMembersCommand,ImportAccountingData,ImportHugoContent}.php`
- **Cause:** Using `IOFactory::load()` without streaming
- **Improvement path:**
- Use chunked reading for large files (PhpSpreadsheet supports `ChunkReadFilter`)
- Batch insert rows in groups of 100-500
- Add progress bar using Artisan command
### Mail Queue Without Batch Processing
- **Problem:** Finance document approval sends individual emails in loop (e.g., line 105-108) without batching
- **Files:** `app/Http/Controllers/FinanceDocumentController.php` (lines 105-108, 182-185, 215-218)
- **Cause:** Loop creates separate Mail::queue() call for each cashier/chair/board member
- **Improvement path:**
- Collect all recipients and send single batch notification
- Use Mailable::withSymfonyMessage() to set BCC instead of TO:recipient loop
- Consider notification channels for role-based sending
## Fragile Areas
### Multi-Tier Approval Workflow State Machine
- **Files:** `app/Models/FinanceDocument.php`, `app/Http/Controllers/FinanceDocumentController.php`
- **Why fragile:**
- Complex conditional logic determining next approver (amount tier + status = next tier)
- No atomic state transitions; multiple `update()` calls without transactions
- Amount tier can change after approval (potential issue if amount modified)
- Self-approval prevention logic repeated in multiple methods
- **Safe modification:**
- Add database constraint: `UNIQUE(finance_documents, status, approved_by_secretary_id)` to prevent duplicate approvals
- Wrap approval logic in explicit DB::transaction() in controller
- Add test for each tier (small, medium, large) with all roles
- **Test coverage:** Good coverage exists (`tests/Unit/FinanceDocumentTest.php`) but missing edge cases
### Member Encryption/Decryption
- **Files:** `app/Models/Member.php` (lines 175-207)
- **Why fragile:**
- Decryption failure returns null without distinction from empty field
- No validation that decrypted data is valid national ID format
- Hash function (SHA256) hardcoded; no version indicator if hashing algorithm changes
- **Safe modification:**
- Create `MemberEncryption` helper class with version support
- Add `$member->national_id_decryption_failed` flag to track failures
- Validate decrypted ID format matches Taiwan ID requirements
- **Test coverage:** Missing - no tests for encryption/decryption failure scenarios
### Article Slug Generation with Chinese Characters
- **Files:** `app/Models/Article.php` (lines 84-109)
- **Why fragile:**
- Slug fallback uses `Str::ascii()` which may produce empty string for pure Chinese titles
- Final fallback `'article-'.time()` creates non-semantic slugs
- No uniqueness guarantee if two articles created in same second
- **Safe modification:**
- Use pinyin conversion library for Chinese titles
- Always append counter to ensure uniqueness (current code does this, but starting from base slug not timestamp)
- **Test coverage:** Missing - no tests for Chinese title slug generation
### Document Access Control
- **Files:** `app/Http/Controllers/PublicDocumentController.php`, `app/Http/Controllers/Api/PublicDocumentController.php`
- **Why fragile:**
- Document access depends on category and user role
- Multiple access control points (controller + model) make audit trail difficult
- No consistent logging of document access
- **Safe modification:**
- Centralize access checks in `Document::canBeViewedBy()` method
- Add audit logging to `DocumentAccessLog` table on every access
- Use policy classes for clearer authorization
- **Test coverage:** Missing - no tests for document access control by category
## Scaling Limits
### SQLite in Development, MySQL in Production
- **Current capacity:** SQLite limited by single process/file locks
- **Limit:** Development environment may not catch MySQL-specific issues (JSON functions, full text search, etc.)
- **Scaling path:**
- Add CI step running tests against MySQL
- Use Docker Compose with MySQL for local development
- Document differences in CLAUDE.md
### Audit Logging Unbounded Growth
- **Current capacity:** `AuditLog` table grows with every action (`AuditLogger::log()` called throughout codebase)
- **Limit:** No retention policy; table will grow indefinitely
- **Scaling path:**
- Add scheduled cleanup command (`php artisan audit-logs:prune --days=90`)
- Archive old logs to separate table or external storage
- Add indexes on `auditable_id`, `auditable_type`, `created_at` for queries
### File Upload Storage
- **Current capacity:** Attachments stored in `storage/app/local` and `storage/app/public` without quota
- **Limit:** Disk space will fill with finance documents, articles, disability certificates
- **Scaling path:**
- Migrate uploads to S3 or cloud storage
- Add cleanup for soft-deleted documents
- Implement virus scanning for uploaded files
## Dependencies at Risk
### barryvdh/laravel-dompdf (PDF Generation)
- **Risk:** Package maintained but based on Dompdf which has history of security issues in HTML/CSS parsing
- **Impact:** User-submitted content (article attachments, issue descriptions) rendered to PDF could be exploited
- **Migration plan:**
- Sanitize HTML before PDF generation
- Consider mPDF or TCPDF as alternatives
- Test with OWASP XSS payloads in PDF generation
### maatwebsite/excel (Excel Import/Export)
- **Risk:** PhpSpreadsheet (underlying library) may have formula injection vulnerabilities
- **Impact:** Imported Excel files with formulas could execute on user machines
- **Migration plan:**
- Strip formulas during import (`setReadDataOnly(true)`)
- Validate cell content is not formula before importing
- Document in user guide: "Do not import untrusted Excel files"
### simplesoftwareio/simple-qrcode
- **Risk:** Package has been archived; no active maintenance
- **Impact:** Potential security vulnerabilities won't be patched
- **Migration plan:**
- Consider switch to `chillerlan/php-qrcode` (actively maintained)
- Audit current QR generation code for security issues
- Test QR code generation with large payloads
### spatie/laravel-permission (Role/Permission)
- **Risk:** Complex permission system; incorrect configuration could expose restricted data
- **Impact:** Member lifecycle, finance approvals depend entirely on permission configuration
- **Migration plan:**
- Document all role/permission requirements in RBAC_SPECIFICATION.md
- Add smoke test verifying each role can/cannot access correct routes
- Audit permission checks with debugbar on each protected route
## Missing Critical Features
### No Rate Limiting on Finance Approval
- **Problem:** No rate limit prevents rapid approval/rejection spam
- **Blocks:** Audit trail integrity; potential for accidental approvals
- **Recommendation:** Add throttle middleware on `FinanceDocumentController::approve()` (1 approval per 2 seconds per user)
### No Email Delivery Confirmation
- **Problem:** System queues emails but provides no confirmation users received them
- **Blocks:** Important notifications (payment rejection, expense approval) may go unseen
- **Recommendation:** Implement email open tracking or delivery webhook with SendGrid/Mailgun
### No Database Transaction on Multi-Step Operations
- **Problem:** Finance document approval updates multiple fields in separate queries (lines 160-164)
- **Blocks:** Partial failures could leave document in inconsistent state
- **Recommendation:** Wrap all multi-step operations in explicit `DB::transaction()`
### No Async Job Retry Logic
- **Problem:** Site revalidation webhook has only catch block, no retry queue
- **Blocks:** Temporary network issues cause permanent cache inconsistency
- **Recommendation:** Use queued jobs with `ShouldBeEncrypted` and exponential backoff
## Test Coverage Gaps
### Finance Approval Multi-Tier Logic
- **What's not tested:** All tier combinations with role changes (e.g., chair approves small amount, medium amount requires board)
- **Files:** `app/Models/FinanceDocument.php` (determineAmountTier, canBeApprovedByChair, etc.)
- **Risk:** Tier logic changes could silently break approval chain
- **Priority:** High - this is financial workflow core
### Member Encryption Edge Cases
- **What's not tested:** Decryption failures, key rotation, migration from plaintext
- **Files:** `app/Models/Member.php` (getNationalIdAttribute, setNationalIdAttribute)
- **Risk:** Corrupted encryption keys could cause data loss
- **Priority:** High - financial impact
### Article Slug Generation for Chinese Titles
- **What's not tested:** Pure Chinese titles, mixed Chinese/English, special characters
- **Files:** `app/Models/Article.php` (generateUniqueSlug)
- **Risk:** Frontend routing breaks if slug generation fails
- **Priority:** Medium - affects CMS functionality
### Document Access Control by Category and Role
- **What's not tested:** Board members accessing member-only documents, public documents accessible without auth
- **Files:** `app/Http/Controllers/PublicDocumentController.php`, `app/Models/Document.php`
- **Risk:** Private documents exposed to wrong users
- **Priority:** High - security critical
### Email Notification Delivery
- **What's not tested:** Actual email sends, template rendering, missing email addresses
- **Files:** All Mail classes in `app/Mail/`
- **Risk:** Users don't know about approvals/rejections
- **Priority:** Medium - business critical but hard to test
---
*Concerns audit: 2026-02-13*

View File

@@ -0,0 +1,199 @@
# Coding Conventions
**Analysis Date:** 2026-02-13
## Naming Patterns
**Files:**
- Classes: PascalCase (e.g., `FinanceDocument.php`, `MembershipFeeCalculator.php`)
- Controllers: PascalCase with `Controller` suffix (e.g., `MemberPaymentController.php`)
- Traits: PascalCase with descriptive names (e.g., `HasApprovalWorkflow.php`, `HasAccountingEntries.php`)
- Models: PascalCase singular (e.g., `Member.php`, `FinanceDocument.php`)
- Services: PascalCase with `Service` suffix (e.g., `MembershipFeeCalculator.php`, `SettingsService.php`)
- Requests: PascalCase with action + `Request` suffix (e.g., `StoreMemberRequest.php`, `UpdateIssueRequest.php`)
- Database: Migration files use timestamp prefix with snake_case (Laravel convention)
**Functions:**
- Methods: camelCase (e.g., `createMember()`, `getBaseAmount()`, `isSelfApproval()`)
- Helper functions: camelCase, wrapped in function_exists check (e.g., `settings()` in `app/helpers.php`)
- Test methods: camelCase, prefixed with `test_` or use `/** @test */` doc comment (see `tests/Unit/MembershipPaymentTest.php`)
- Model query scopes: camelCase (Laravel convention)
**Variables:**
- Local variables: camelCase (e.g., `$baseAmount`, `$feeDetails`, `$discountRate`)
- Model attributes: snake_case in database, accessed as camelCase properties (Laravel convention)
- Constants: UPPER_SNAKE_CASE (e.g., `STATUS_PENDING`, `AMOUNT_TIER_SMALL`, `IDENTITY_PATIENT`)
- Protected/private properties: camelCase with prefix (e.g., `$settings`, `$feeCalculator`)
**Types:**
- Model classes: Use `User`, `Member`, `FinanceDocument` (singular)
- Collections: Arrays or `Collection` type hints
- Enums: Not used; status values stored as class constants (e.g., in `FinanceDocument.php` lines 15-59)
- Nullable types: Use `?Type` (e.g., `?User $user`)
- Return type hints: Always specified (e.g., `public function calculate(...): array`)
## Code Style
**Formatting:**
- Tool: Laravel Pint (PHP code style formatter)
- Run command: `./vendor/bin/pint`
- Indentation: 4 spaces (configured in `.editorconfig`)
- Line endings: LF (configured in `.editorconfig`)
- Final newline: Required (configured in `.editorconfig`)
- Trailing whitespace: Trimmed (configured in `.editorconfig`)
**Language versions:**
- PHP: 8.1+ (required in `composer.json`)
- Laravel: 10.10+ (required in `composer.json`)
**Linting:**
- Tool: PHPStan (static analysis)
- Run command: `./vendor/bin/phpstan analyse`
- No ESLint/Prettier for frontend (basic Tailwind + Alpine.js)
## Import Organization
**Order:**
1. PHP core/standard library classes (`use Illuminate\*`, `use App\*`)
2. Third-party library classes (`use Spatie\*`, `use Barryvdh\*`)
3. Application namespaces (`use App\Models\*`, `use App\Services\*`, `use App\Traits\*`)
4. Facades and static imports last (`use Illuminate\Support\Facades\*`)
**Example from `MemberPaymentController.php`:**
```php
use App\Mail\PaymentSubmittedMail;
use App\Models\Member;
use App\Models\MembershipPayment;
use App\Models\User;
use App\Services\MembershipFeeCalculator;
use App\Support\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rule;
```
**Path Aliases:**
- No custom aliases configured; use standard PSR-4 autoloading
- Namespace hierarchy: `App\Models\`, `App\Services\`, `App\Http\Controllers\`, `App\Http\Requests\`, `App\Traits\`, `App\Support\`
## Error Handling
**Patterns:**
- Use try-catch blocks for complex operations with side effects (see `BankReconciliationController.php` lines 75-81)
- Catch generic `\Exception` when handling database/file operations
- Use DB transactions for multi-step operations requiring rollback
- Authorization: Use `$this->authorize('permission-name')` in controllers (Gate authorization)
- Validation: Use Form Request classes (`StoreMemberRequest`, `UpdateMemberRequest`) in controller methods
- Permission checks: Use Spatie Laravel Permission with roles and permissions (see `SeedsRolesAndPermissions` trait)
**Example from `BankReconciliationController.php`:**
```php
DB::beginTransaction();
try {
// Handle bank statement file upload
$statementPath = null;
if ($request->hasFile('bank_statement_file')) {
$statementPath = $request->file('bank_statement_file')->store('bank-statements', 'local');
}
// ... process data
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
return redirect()->back()->with('error', 'Error message');
}
```
## Logging
**Framework:** None configured; uses Laravel's default logging or direct logging via `Storage` facades.
**Patterns:**
- Use `AuditLogger` static class for business logic audit trail (see `app/Support/AuditLogger.php`)
- Call pattern: `AuditLogger::log($action, $auditable, $metadata)`
- Example usage in `MemberPaymentController.php` and other controllers
- All member/finance/permission changes should be logged via AuditLogger
## Comments
**When to Comment:**
- Trait usage instructions (see `HasApprovalWorkflow.php` lines 7-14)
- Complex business logic requiring explanation (e.g., multi-tier approval, fee calculations)
- Prevent: Obvious comments describing what code does (let code be self-documenting)
- Include: Comments explaining WHY, not WHAT
**JSDoc/TSDoc:**
- Use PHPDoc for public methods (see `MembershipFeeCalculator.php` lines 17-23)
- Parameter types documented with `@param Type $variable`
- Return types documented with `@return Type`
- Example from `calculate()` method:
```php
/**
* 計算會費金額
*
* @param Member $member 會員
* @param string $feeType 會費類型 (entrance_fee | annual_fee)
* @return array{base_amount: float, discount_amount: float, final_amount: float, disability_discount: bool, fee_type: string}
*/
```
**Chinese Comments:**
- UI-facing text and business logic comments may use Traditional Chinese
- Code comments default to English for consistency
- No specific rule enforced; follow existing pattern in related files
## Function Design
**Size:** Keep under 30-40 lines per method
- Controllers: Each action method typically 20-30 lines
- Services: Business logic methods 15-25 lines
- Traits: Utility methods 5-15 lines
**Parameters:**
- Maximum 5 parameters per method
- Use dependency injection for services (constructor or method parameter)
- Use Form Request classes for validation parameters (not raw `Request`)
- Optional parameters use `?Type` or default values
**Return Values:**
- Always include return type hint (e.g., `: array`, `: bool`, `: void`)
- Use arrays for multiple values needing structure (see `MembershipFeeCalculator.php` line 24)
- Use model instances for Eloquent operations
- Return `null` explicitly when appropriate (use `?Type` hint)
## Module Design
**Exports:**
- Laravel models are auto-discovered via namespace
- Controllers are auto-discovered via `App\Http\Controllers` namespace
- Requests are auto-discovered via `App\Http\Requests` namespace
- Traits are explicitly `use`d in consuming classes
**Barrel Files:**
- Not used; each file has single responsibility
- Use explicit imports rather than wildcard imports
**Status Constants Pattern:**
- All statuses defined as class constants, never magic strings or enums
- Grouped by category (approval status, disbursement status, recording status)
- Examples from `FinanceDocument.php`:
```php
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED_SECRETARY = 'approved_secretary';
public const DISBURSEMENT_PENDING = 'pending';
public const DISBURSEMENT_REQUESTER_CONFIRMED = 'requester_confirmed';
```
**Service Injection Pattern:**
- Constructor injection for services (see `MemberPaymentController.__construct()`)
- Store in protected property for method access
- Use type hints for IDE support
**Trait Usage Pattern:**
- Mixed concern traits (e.g., `HasApprovalWorkflow`, `HasAccountingEntries`) used in models
- Test helper traits (e.g., `SeedsRolesAndPermissions`, `CreatesFinanceData`) used in test classes
- Traits documented at top with usage instructions
---
*Convention analysis: 2026-02-13*

View File

@@ -0,0 +1,281 @@
# External Integrations
**Analysis Date:** 2026-02-13
## APIs & External Services
**Next.js Frontend Site Revalidation:**
- Service: Custom webhook to Next.js `/api/revalidate`
- What it's used for: Trigger static site regeneration when articles, pages, or documents change
- SDK/Client: Guzzle HTTP client (`guzzlehttp/guzzle`)
- Implementation: `app/Services/SiteRevalidationService.php`
- Methods:
- `SiteRevalidationService::revalidateArticle(?slug)` - Trigger article cache invalidation
- `SiteRevalidatService::revalidatePage(?slug)` - Trigger page cache invalidation
- `SiteRevalidationService::revalidateDocument(?slug)` - Trigger document cache invalidation
- Triggered by: `app/Observers/DocumentObserver.php` on article/page create/update/delete
- Timeout: 5 seconds
- Error handling: Logged as warning, does not fail request
**Email Service Provider (Configurable):**
- Services supported: SMTP, Mailgun, Postmark, AWS SES
- SDK/Client: Laravel Mail facades
- Implementation: Controllers use `Mail::to($email)->queue(MailClass::class)`
- Auth: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, or API keys
- Dev/Test: Mailpit on `localhost:1025` (configured in `.env.example`)
- Queue integration: All mail uses async queue for better performance
## Data Storage
**Primary Database:**
- Type/Provider: MySQL (production) or SQLite (development)
- Connection config: `config/database.php`
- Connection env vars:
- `DB_CONNECTION` - Type: `mysql` or `sqlite`
- `DB_HOST` - Database server (e.g., `127.0.0.1`)
- `DB_PORT` - Port (default 3306 for MySQL)
- `DB_DATABASE` - Database name
- `DB_USERNAME` - Username
- `DB_PASSWORD` - Password
- ORM/Client: Laravel Eloquent (built-in)
- Encryption: AES-256-CBC via `config/app.php` cipher
- Notable tables:
- `users` - System users (staff, admin)
- `members` - Member profiles (encrypted national IDs)
- `finance_documents` - Finance approvals with multi-tier workflows
- `payments` - Member fee payments
- `articles`, `pages`, `categories`, `tags` - CMS content
- `documents` - Document library entries
- `roles`, `permissions`, `model_has_roles` - Spatie permission tables
- `settings` - System-wide configuration (cached)
**File Storage:**
- Databases: Local disk and S3 configurable
- Private files: `storage/app/` (served via controller)
- Public files: `storage/app/public/` (served via `/storage/...` URL)
- S3 Config: `config/filesystems.php``disks.s3`
- S3 env vars:
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `AWS_DEFAULT_REGION`
- `AWS_BUCKET`
- `AWS_URL` (optional CDN)
- `AWS_ENDPOINT` (optional for S3-compatible services)
**Caching:**
- Providers: File (default), Redis, Memcached, DynamoDB
- Config: `config/cache.php`
- Default driver: `file` (suitable for single-server, can switch to Redis)
- Used for: `settings()` helper caching, query result caching
- Cache prefix: `laravel_cache_` (configurable via `CACHE_PREFIX` env)
- Settings cache: Automatically cleared on `SystemSetting` model mutation
**Session Storage:**
- Providers: File (default), Database, Redis
- Config: `config/session.php`
- Default driver: `file`
- Lifetime: 120 minutes (configurable via `SESSION_LIFETIME` env)
- CSRF tokens: Protected via Laravel middleware
## Authentication & Identity
**Auth Provider:**
- Type: Custom (Session-based)
- Implementation: Built-in Laravel authentication with `User` model
- Guard: `web` (session-based)
- Config: `config/auth.php`
- Provider: Eloquent user provider (`App\Models\User`)
**API Token Authentication:**
- Service: Laravel Sanctum
- Implementation: `config/sanctum.php`
- For: Public API endpoints (`/api/v1/*`)
- Stateful domains: `localhost`, `127.0.0.1`, custom domain (via `SANCTUM_STATEFUL_DOMAINS` env)
- Token prefix: Customizable via `SANCTUM_TOKEN_PREFIX` env
- Expiration: No default expiration (tokens live indefinitely unless custom set)
**Role-Based Access Control (RBAC):**
- Service: Spatie Laravel Permission
- Package: `spatie/laravel-permission@^6.23`
- Config: `config/permission.php`
- Models: `Spatie\Permission\Models\Role`, `Spatie\Permission\Models\Permission`
- Tables:
- `roles` - Available roles
- `permissions` - Available permissions
- `model_has_roles` - User-to-role assignments
- `model_has_permissions` - User-to-permission direct assignments
- `role_has_permissions` - Role-to-permission assignments
- Core roles (seeded in `database/seeders/`):
- `admin` - Full system access
- `finance_requester` - Submit finance documents
- `finance_cashier` - Tier 1 approvals (small amounts)
- `finance_accountant` - Tier 2 approvals (medium amounts) and ledger recording
- `finance_chair` - Tier 2 approvals (medium amounts)
- `finance_board_member` - Tier 3 approvals (large amounts)
- `secretary_general` - CMS management, member management
- `membership_manager` - Member lifecycle management
**Identity/National ID Encryption:**
- Encryption: AES-256 via Laravel's encryption
- Fields in `members` table:
- `national_id_encrypted` - Stores encrypted national ID
- `national_id_hash` - SHA256 hash for searching (indexed)
- `national_id` - Virtual accessor (decrypts on read)
- Location: `app/Models/Member.php`
## Webhooks & Callbacks
**Incoming Webhooks:**
- Site revalidation endpoint: `/api/revalidate` (public, requires valid token)
- Token: `NEXTJS_REVALIDATE_TOKEN` env var
- Method: `POST`
- Payload: `{ type: 'article'|'page'|'document', slug?: string }`
**Outgoing Webhooks:**
- Next.js site revalidation: `POST {NEXTJS_REVALIDATE_URL}`
- URL: `NEXTJS_REVALIDATE_TOKEN` env var
- Token header: `x-revalidate-token`
- Payload: `{ type: 'article'|'page'|'document', slug?: string }`
- Triggered on: Article/Page/Document create/update/delete
- Implementation: `app/Services/SiteRevalidationService.php`
- Timeout: 5 seconds, failures logged but do not block request
## Monitoring & Observability
**Error Tracking:**
- Service: Spatie Ignition (error page companion)
- Package: `spatie/laravel-ignition@^2.0`
- Used in: Development and error page debugging
- Config: `config/app.php` → providers
**Logging:**
- Framework: Monolog (via Laravel)
- Config: `config/logging.php`
- Default channel: `stack` (multi-handler)
- Channels available:
- `single` - Single log file: `storage/logs/laravel.log`
- `daily` - Rotate daily, keep 14 days
- `slack` - Send logs to Slack webhook (env: `LOG_SLACK_WEBHOOK_URL`)
- `papertrail` - Syslog UDP to Papertrail (env: `PAPERTRAIL_URL`, `PAPERTRAIL_PORT`)
- `stderr` - Output to stderr
- `syslog` - System syslog
- `errorlog` - PHP error_log
- `log` - File channel with custom naming
- `array` - In-memory (testing only)
- Log level: `debug` (default, configurable via `LOG_LEVEL` env)
- Deprecations channel: Null by default (can redirect via `LOG_DEPRECATIONS_CHANNEL` env)
**Audit Logging:**
- Service: Custom audit logger
- Implementation: `app/Support/AuditLogger.php`
- Usage: `AuditLogger::log($action, $auditable, $metadata)`
- Tracked: Model mutations (create/update/delete) for compliance
- Storage: Appended to audit log entries
## CI/CD & Deployment
**Hosting:**
- Platform: Dedicated servers, Docker (Laravel Sail), or cloud (AWS, etc.)
- Port: API typically runs on port 8001 (not 8000 - that's often occupied)
**Build Process:**
- Frontend assets: `npm run build` (Vite compilation)
- Backend: `composer install` (or `composer install --no-dev` for production)
- Migrations: `php artisan migrate --force`
- Cache: `php artisan config:cache`, `php artisan view:cache`
**Code Quality (Pre-commit):**
- Linter: Laravel Pint (PSR-12)
- Run: `./vendor/bin/pint`
- Fixes style issues automatically
- Static analysis: PHPStan
- Run: `./vendor/bin/phpstan analyse`
- Config: `phpstan.neon` (if present)
**Testing:**
- Unit & Feature Tests: PHPUnit
- Run: `php artisan test`
- Run specific: `php artisan test --filter=ClassName`
- Config: `phpunit.xml`
- Browser/E2E Tests: Laravel Dusk
- Run: `php artisan dusk`
- Uses Chrome/Chromium driver
## Environment Configuration
**Required Environment Variables:**
Core:
- `APP_NAME` - Application name
- `APP_ENV` - Environment: `local`, `production`
- `APP_DEBUG` - Boolean: enable/disable debug mode
- `APP_KEY` - Base64 encryption key (auto-generated)
- `APP_URL` - Full URL: `https://member.usher.org.tw` (production)
Database:
- `DB_CONNECTION` - `mysql` (default) or `sqlite`
- `DB_HOST` - Database server
- `DB_PORT` - Port (3306 for MySQL)
- `DB_DATABASE` - Database name
- `DB_USERNAME` - Username
- `DB_PASSWORD` - Password
Mail:
- `MAIL_MAILER` - `smtp` (default), `mailgun`, `postmark`, `ses`, `log`
- `MAIL_HOST` - SMTP host
- `MAIL_PORT` - SMTP port (587 typical)
- `MAIL_USERNAME` - SMTP username
- `MAIL_PASSWORD` - SMTP password
- `MAIL_ENCRYPTION` - `tls`, `ssl`, or null
- `MAIL_FROM_ADDRESS` - From email address
- `MAIL_FROM_NAME` - From name
Caching & Queuing:
- `CACHE_DRIVER` - `file` (default), `redis`, `memcached`, `dynamodb`
- `QUEUE_CONNECTION` - `sync` (default), `database`, `redis`, `sqs`
- `SESSION_DRIVER` - `file` (default), `database`, `redis`
Feature Toggles:
- `REGISTRATION_ENABLED` - Boolean: allow public registration (`false` by default)
Frontend Integration:
- `NEXTJS_REVALIDATE_URL` - Full URL to Next.js revalidation endpoint
- `NEXTJS_REVALIDATE_TOKEN` - Bearer token for revalidation requests
- `NEXTJS_PUBLIC_PATH` - Optional: path to Next.js `public/` directory (for local asset sync)
**Secrets Location:**
- Method: Environment variables (`.env` file, never committed)
- Production: Use hosted secrets manager or CI/CD environment variables
- Never commit: Passwords, API keys, encryption keys, tokens
## Optional: Redis
**If using Redis for caching/sessions/queue:**
- Config: `config/database.php``redis`
- Env vars:
- `REDIS_HOST` - `127.0.0.1` (default)
- `REDIS_PORT` - `6379` (default)
- `REDIS_PASSWORD` - Password (null by default)
- `REDIS_CLIENT` - `phpredis` (default)
- `REDIS_CLUSTER` - `redis` (default)
- Databases:
- Default: 0 (primary connection)
- Cache: 1 (separate database for cache keys)
## Optional: AWS Integration
**If using AWS S3 for file storage:**
- Config: `config/filesystems.php``disks.s3`
- Env vars: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_REGION`, `AWS_BUCKET`
**If using AWS SES for email:**
- Config: `config/mail.php``mailers.ses`
- Env vars: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_REGION`
**If using AWS SQS for queue:**
- Config: `config/queue.php``connections.sqs`
- Env vars: AWS credentials + `SQS_PREFIX`, `SQS_QUEUE`, `SQS_SUFFIX`
---
*Integration audit: 2026-02-13*

223
.planning/codebase/STACK.md Normal file
View File

@@ -0,0 +1,223 @@
# Technology Stack
**Analysis Date:** 2026-02-13
## Languages
**Primary:**
- PHP 8.1+ - Backend framework, ORM, business logic
**Secondary:**
- TypeScript - Not used (JavaScript tooling only)
- JavaScript - Frontend interactivity via Alpine.js and Vite build
## Runtime
**Environment:**
- Laravel 10 (minimum 10.10)
- PHP 8.1+ (configured in `composer.json`)
**Package Managers:**
- Composer (PHP) - Dependency management for backend
- Lockfile: `composer.lock` (present)
- npm (Node.js) - Frontend asset builds and development
- Lockfile: `package-lock.json` (present)
## Frameworks
**Core Backend:**
- Laravel 10 - Full-stack web framework for routing, models, migrations, services
- Location: `app/`, `routes/`, `config/`, `database/`
**Frontend Templating:**
- Blade (Laravel templating engine) - Server-side template rendering
- Location: `resources/views/`
**Frontend Interactivity:**
- Alpine.js 3.4 - Lightweight reactive JavaScript for form handling and DOM manipulation
- Package: `alpinejs@^3.4.2` in `package.json`
**Build & Asset Pipeline:**
- Vite 5 - Fast build tool for compiling CSS/JS
- Package: `vite@^5.0.0`
- Config: `vite.config.js` (Laravel Vite plugin configured)
- Laravel Vite Plugin - Integration between Laravel and Vite
- Package: `laravel-vite-plugin@^1.0.0`
**Styling:**
- Tailwind CSS 3.1 - Utility-first CSS framework
- Package: `tailwindcss@^3.1.0`
- Dark mode: `darkMode: 'class'` configured in tailwind config
- Forms plugin: `@tailwindcss/forms@^0.5.2` for styled form elements
- PostCSS 8.4.31 - CSS processing
- Autoprefixer 10.4.2 - Browser vendor prefixes
**Testing:**
- PHPUnit 10.1 - PHP unit testing framework
- Config: `phpunit.xml`
- Test directory: `tests/`
- Laravel Dusk 8.3 - Browser testing framework for end-to-end tests
- Run: `php artisan dusk`
- Mockery 1.4.4 - PHP mocking library for tests
**Development Quality:**
- Laravel Pint 1.0 - Code style formatter (PSR-12)
- Laravel Tinker 2.8 - Interactive PHP REPL
- Laravel Breeze 1.29 - Lightweight authentication scaffolding
- FakerPHP 1.9.1 - Fake data generation for seeders
- Spatie Ignition 2.0 - Error page debugging companion
- Collision 7.0 - Error page styling
**DevOps/Deployment:**
- Laravel Sail 1.18 - Docker-based local development environment (optional)
## Key Dependencies
**Critical Business Logic:**
- `spatie/laravel-permission` 6.23 - RBAC role and permission system
- Used for: Finance approvals, user role management
- Config: `config/permission.php`
- Tables: `roles`, `permissions`, `model_has_roles`, `model_has_permissions`, `role_has_permissions`
- `barryvdh/laravel-dompdf` 3.1 - PDF generation for finance reports
- Used for: Document export, compliance reports
- Provider: `Barryvdh\DomPDF\ServiceProvider`
- `maatwebsite/excel` 3.1 - Excel file import/export
- Used for: Member data imports, financial report exports
- Laravel 10 compatible version
**HTTP & API Integration:**
- `guzzlehttp/guzzle` 7.2 - HTTP client for external requests
- Used in: `SiteRevalidationService` for Next.js webhook calls
- Location: `app/Services/SiteRevalidationService.php`
**QR Code Generation:**
- `simplesoftwareio/simple-qrcode` 4.2 - QR code generation library
- Used for: Member ID verification, document tracking
**Authentication:**
- `laravel/sanctum` 3.3 - API token authentication and CSRF protection
- Used for: Public API endpoints (`/api/v1/*`)
- Config: `config/sanctum.php`
- Stateful domains: `localhost`, `127.0.0.1` (customizable via env)
## Configuration
**Environment Configuration:**
- `.env.example` - Template for environment variables
- Required variables (non-secret):
- `APP_NAME`, `APP_ENV`, `APP_DEBUG`, `APP_URL` - Application identity
- `DB_CONNECTION`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` - Database
- `MAIL_MAILER`, `MAIL_HOST`, `MAIL_PORT`, `MAIL_FROM_ADDRESS` - Email
- `CACHE_DRIVER`, `QUEUE_CONNECTION`, `SESSION_DRIVER` - Caching/queueing
- `REGISTRATION_ENABLED` - Toggle public registration on/off
- `NEXTJS_REVALIDATE_URL`, `NEXTJS_REVALIDATE_TOKEN` - Webhook to frontend
- `NEXTJS_PUBLIC_PATH` - Optional: Local Next.js repo for asset syncing
**Core Configs:**
- `config/app.php` - Timezone: `Asia/Taipei`, Locale: `zh_TW`, Cipher: `AES-256-CBC`
- `config/database.php` - Database connection options (MySQL primary, SQLite dev)
- `config/auth.php` - Authentication guards (session-based), public registration toggle
- `config/mail.php` - SMTP, Mailgun, Postmark, SES support
- `config/accounting.php` - Account codes, amount tier thresholds, currency (TWD)
- `config/filesystems.php` - Local, public, private, and S3 disk definitions
- `config/cache.php` - File (default), Redis, Memcached, DynamoDB support
- `config/queue.php` - Sync (default), database, Redis, SQS, Beanstalkd support
- `config/services.php` - Third-party service credentials (Mailgun, Postmark, AWS SES, Next.js webhooks)
- `config/logging.php` - Monolog channels (stack, single, daily, Slack, Papertrail)
- `config/cors.php` - CORS policy for API endpoints
- `config/session.php` - Session driver and lifetime (120 minutes default)
## Database
**Primary (Production):**
- MySQL 5.7+ (configured in `config/database.php`)
- Character set: `utf8mb4`, Collation: `utf8mb4_unicode_ci`
- Strict mode enabled
**Development:**
- SQLite supported as alternative (`DB_CONNECTION=sqlite`)
- Location: `database/database.sqlite`
**Migrations:**
- Directory: `database/migrations/`
- Run: `php artisan migrate`
- Fresh reset: `php artisan migrate:fresh --seed`
## Caching & Session
**Cache (Default: File):**
- Driver: `file` (can be switched to Redis, Memcached, DynamoDB)
- Location: `storage/framework/cache/data/`
- Used for: Settings caching (`settings()` helper), query result caching
**Sessions (Default: File):**
- Driver: `file` (can be switched to database, Redis, etc.)
- Lifetime: 120 minutes
- Storage: `storage/framework/sessions/`
**Queue (Default: Sync):**
- Driver: `sync` (processes jobs immediately in production, can use `database`/`redis`)
- Used for: Email sending, bulk operations
- Failed jobs table: `failed_jobs`
## Email
**Mailers Supported:**
- SMTP (default) - Mailgun, custom SMTP servers
- Mailgun API
- Postmark API
- AWS SES
- Log (development)
- Failover chains (SMTP → Log)
**Dev Default:**
- Mailpit (mock SMTP server) on `localhost:1025`
- Configured in `.env.example` for local testing
**Mail Queue:**
- All transactional emails use `Mail::...->queue()` for async sending
- Location: Email classes in `app/Mail/`
## File Storage
**Private Files:**
- Disk: `private` (stores in `storage/app/`)
- Access: Served via controller responses only (no direct URL)
- Used for: Encrypted documents, sensitive uploads
**Public Files:**
- Disk: `public` (stores in `storage/app/public/`)
- URL: Accessible via `/storage/...` after running `php artisan storage:link`
- Used for: Admin-uploaded images, article attachments
**Optional S3:**
- Disk: `s3` (configured for AWS S3)
- Requires: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_REGION`, `AWS_BUCKET`
**Next.js Asset Sync (Optional):**
- Location: `config/services.php``nextjs.public_path`
- Feature: Auto-copy uploaded images to Next.js `public/` directory
- Git integration: Optional auto-commit and push of assets
- Used for: Serving static images from static Next.js build
## Deployment
**Hosting Targets:**
- Dedicated servers (traditional deployment with PHP-FPM + Nginx)
- Docker (via Laravel Sail)
- Vercel/Netlify (headless API mode)
**Production Considerations:**
- Database: Migrate to MySQL 8.0+
- Cache: Switch from `file` to Redis for multi-server setups
- Queue: Switch from `sync` to `database` or `redis`
- Session: Switch from `file` to `database` or `redis`
- Mail: Configure Mailgun/SES for production email
- Storage: Use S3 for file uploads instead of local disk
- Assets: Compile with `npm run build` before deployment
---
*Stack analysis: 2026-02-13*

View File

@@ -0,0 +1,362 @@
# Codebase Structure
**Analysis Date:** 2026-02-13
## Directory Layout
```
usher-manage-stack/
├── app/ # Application code
│ ├── Console/Commands/ # Custom Artisan commands
│ ├── Exceptions/ # Exception handlers
│ ├── Http/
│ │ ├── Controllers/ # Web controllers split by domain
│ │ │ ├── Admin/ # Admin panel controllers (10 controllers)
│ │ │ ├── Api/ # API controllers (4 controllers)
│ │ │ ├── Auth/ # Auth controllers
│ │ │ ├── *Controller.php # Main route controllers
│ │ ├── Middleware/ # HTTP middleware (11 total)
│ │ ├── Requests/ # Form Request validation classes
│ │ └── Resources/ # API response resources
│ ├── Jobs/ # Queued jobs
│ ├── Mail/ # Mail classes (notifications)
│ ├── Models/ # Eloquent models (36 models)
│ ├── Observers/ # Model observers
│ ├── Providers/ # Service providers
│ ├── Services/ # Business logic services (7 services)
│ ├── Support/ # Helper classes
│ ├── Traits/ # Shared model traits (2 traits)
│ ├── View/Components/ # Blade components
│ ├── helpers.php # Global helper functions
├── bootstrap/ # Bootstrap files
├── config/ # Configuration files
├── database/
│ ├── migrations/ # Database migrations
│ ├── seeders/ # Database seeders
│ ├── factories/ # Model factories for testing
│ └── schema/ # Schema dump
├── resources/
│ ├── css/ # Tailwind CSS
│ ├── views/ # Blade templates
│ │ ├── admin/ # Admin panel views (20+ subdirectories)
│ │ ├── auth/ # Auth views (login, register, etc.)
│ │ ├── components/ # Reusable components
│ │ ├── emails/ # Email templates
│ │ ├── layouts/ # Layout files
│ │ ├── member/ # Member-specific views
│ │ ├── public/ # Public-facing views
│ │ └── profile/ # Profile views
├── routes/
│ ├── web.php # Web routes (393 lines, 200+ named routes)
│ ├── api.php # API routes (v1)
│ ├── auth.php # Auth routes
│ ├── console.php # Console commands
│ └── channels.php # Broadcasting channels
├── storage/
│ ├── app/ # File uploads (private)
│ ├── logs/ # Application logs
│ └── framework/ # Framework files
├── tests/ # Test suite
├── .env.example # Environment template
├── CLAUDE.md # Project-specific instructions
├── composer.json # PHP dependencies
└── artisan # Artisan CLI entry point
```
## Directory Purposes
**app/Models:**
- Purpose: Eloquent models representing database tables
- Contains: 36 models with relationships, scopes, accessors
- Key files:
- `Member.php` — Member lifecycle (status, disability, identity type)
- `FinanceDocument.php` — Finance approval workflow (27+ status constants)
- `User.php` — User authentication and roles
- `MembershipPayment.php` — Payment verification workflow
- `Document.php` — Document library with versioning
- `Article.php`, `Page.php` — CMS content
- `Issue.php` — Issue tracker with comments, attachments, time logs
- `AccountingEntry.php` — Double-entry bookkeeping records
**app/Http/Controllers:**
- Purpose: Handle HTTP requests and coordinate responses
- Contains: 25+ controller classes
- Structure:
- `Admin/` — 10 controllers for admin features (articles, documents, pages, etc.)
- `Api/` — 4 controllers for REST API endpoints
- Root controllers for public and authenticated routes
- Pattern: Inject dependencies, call services, return view/response
**app/Http/Controllers/Admin:**
- `DocumentController.php` — Document CRUD with version control
- `ArticleController.php` — Article CRUD with publish/archive
- `PageController.php` — Page CRUD with publish
- `AnnouncementController.php` — Announcements with pinning
- `SystemSettingsController.php` — System settings UI
- `GeneralLedgerController.php`, `TrialBalanceController.php` — Accounting reports
**app/Services:**
- Purpose: Encapsulate complex business logic
- Files:
- `FinanceDocumentApprovalService.php` — Multi-tier approval orchestration
- `MembershipFeeCalculator.php` — Fee calculation with disability discount
- `SettingsService.php` — System settings caching/retrieval
- `PaymentVerificationService.php` — Payment workflow coordination
- `SiteRevalidationService.php` — Next.js static site rebuild webhook
- `SiteAssetSyncService.php`, `NextjsRepoSyncService.php` — Frontend sync
**app/Traits:**
- Purpose: Shared behavior across multiple models
- `HasApprovalWorkflow.php` — Multi-tier approval methods (6 methods)
- `HasAccountingEntries.php` — Double-entry bookkeeping (8 methods)
- Usage: Models add `use TraitName;` to inherit behavior
**app/Http/Requests:**
- Purpose: Form request validation classes (implicit validation in controllers)
- Files:
- `StoreMemberRequest.php` — Member creation rules
- `UpdateMemberRequest.php` — Member update rules
- `StoreFinanceDocumentRequest.php` — Finance document rules
- `StoreIssueRequest.php` — Issue creation rules
- `ProfileUpdateRequest.php` — User profile rules
- Pattern: Define `rules()` and `messages()` methods; controller receives validated data
**app/Http/Middleware:**
- `EnsureUserIsAdmin.php` — Admin access check (admin role OR any permission)
- `Authenticate.php` — Session authentication
- `CheckPaidMembership.php` — Membership status validation
- Standard middleware: CSRF, encryption, trusted proxies
**app/Support:**
- `AuditLogger.php` — Static class for centralized audit logging
- `DownloadFile.php` — File download handler with cleanup
- Usage: `AuditLogger::log('action_name', $model, $metadata)`
**resources/views:**
- Purpose: Blade templates rendering HTML
- Structure:
- `admin/` — 20+ directories for admin features
- `admin/finance/` — Finance documents
- `admin/members/` — Member management
- `admin/issues/` — Issue tracker
- `admin/articles/`, `admin/pages/` — CMS
- `admin/announcements/`, `admin/documents/` — Announcements and document library
- `auth/` — Login, register, password reset
- `member/` — Member-facing views
- `public/` — Public-facing views
- `layouts/` — Layout templates (app.blade.php, guest.blade.php)
- `components/` — Reusable Blade components
**config/:**
- `accounting.php` — Account codes, amount tiers, chart of accounts
- `auth.php` — Authentication guards, registration enabled flag
- `permission.php` — Spatie permission defaults
- `services.php` — External service configuration (Nextjs revalidate URL)
- `app.php`, `database.php`, `cache.php`, etc. — Standard Laravel configs
**routes/:**
- `web.php` — All 200+ web routes
- Public: `/`, `/documents`, `/register/member` (if enabled), `/beta/bug-report`
- Auth: `/dashboard`, `/my-membership`, `/member/submit-payment`, `/profile`
- Admin: `/admin/*` with group prefix and `admin` middleware
- `api.php` — API v1 routes
- `/api/v1/articles`, `/api/v1/pages`, `/api/v1/homepage`, `/api/v1/public-documents`
- Authentication: Sanctum (optional)
- `auth.php` — Login, register, password reset routes
## Key File Locations
**Entry Points:**
- `public/index.php` — Application entry point
- `app/Providers/RouteServiceProvider.php` — Route registration
- `routes/web.php` — All web routes
- `routes/api.php` — API routes
**Configuration:**
- `config/accounting.php` — Finance tier thresholds, account codes
- `config/auth.php` — Registration enabled flag, authentication settings
- `config/services.php` — External service URLs and tokens
- `.env` file (not committed) — Environment variables
**Core Logic:**
- `app/Models/FinanceDocument.php` — Finance workflow logic (approval, disbursement, recording)
- `app/Models/Member.php` — Member lifecycle logic (status checks, fees)
- `app/Services/FinanceDocumentApprovalService.php` — Approval orchestration
- `app/Traits/HasApprovalWorkflow.php` — Reusable approval behavior
- `app/Traits/HasAccountingEntries.php` — Reusable accounting behavior
**Business Rules:**
- `app/Services/MembershipFeeCalculator.php` — Fee calculation
- `app/Services/PaymentVerificationService.php` — Payment workflow
- `app/Http/Controllers/FinanceDocumentController.php` — Finance CRUD and workflow actions
- `app/Http/Controllers/AdminMemberController.php` — Member CRUD and lifecycle
**Testing:**
- `tests/` — Test suite (PHPUnit)
- Database tests use `RefreshDatabase` trait
- Test accounts in seeders: `admin@test.com`, `requester@test.com`, `cashier@test.com`, etc.
**Database:**
- `database/migrations/` — 20+ migrations
- `database/seeders/` — RoleSeeder, ChartOfAccountSeeder, TestDataSeeder, FinancialWorkflowTestDataSeeder
- `database/factories/` — Model factories for testing
## Naming Conventions
**Files:**
- Controllers: `PluralCamelCase + Controller.php` (e.g., `FinanceDocumentController.php`)
- Models: `SingularCamelCase.php` (e.g., `FinanceDocument.php`)
- Requests: `Action + ModelName + Request.php` (e.g., `StoreFinanceDocumentRequest.php`)
- Migrations: `YYYY_MM_DD_HHMMSS_action_table_name.php`
- Views: `snake_case.blade.php` (e.g., `create.blade.php`, `edit.blade.php`)
**Directories:**
- Controllers: Group by feature/domain (Admin/, Api/, Auth/)
- Views: Mirror controller structure (admin/, auth/, member/, public/)
- Models: Flat in app/Models/
- Services: Flat in app/Services/
- Migrations: Flat in database/migrations/
**Routes:**
- Named routes: `domain.action` (e.g., `admin.finance.index`, `member.payments.store`)
- Admin routes: Prefix `admin.` and grouped under `/admin` URL prefix
- Public routes: No prefix (e.g., `documents.index`, `register.member`)
**Database:**
- Tables: `snake_case` plural (e.g., `finance_documents`, `membership_payments`)
- Columns: `snake_case` (e.g., `submitted_by_user_id`, `approved_at`)
- Foreign keys: `model_id` singular (e.g., `member_id`, `user_id`)
- Timestamps: `created_at`, `updated_at` (auto-managed)
- Encrypted fields: `field_encrypted` and `field_hash` for search (e.g., `national_id_encrypted`, `national_id_hash`)
**Constants:**
- Status values: `ModelName::STATUS_STATE` all caps (e.g., `FinanceDocument::STATUS_PENDING`)
- Type values: `ModelName::TYPE_NAME` all caps (e.g., `Member::TYPE_INDIVIDUAL`)
- Amount tiers: `ModelName::AMOUNT_TIER_LEVEL` all caps (e.g., `FinanceDocument::AMOUNT_TIER_SMALL`)
## Where to Add New Code
**New Feature (CRUD):**
- Primary code:
- Model: `app/Models/NewModel.php` — Define relationships, scopes, accessors
- Controller: `app/Http/Controllers/Admin/NewModelController.php` or root controller
- Requests: `app/Http/Requests/Store/UpdateNewModelRequest.php`
- Views: `resources/views/admin/new-models/` with index/create/edit/show
- Routes: Add to `routes/web.php` within appropriate middleware group
- Example structure:
```php
// Model relationships
public function relationships()
// Form validation in FormRequest
public function rules()
// Controller action
public function store(StoreNewModelRequest $request)
// View rendering
return view('admin.new-models.create', ['data' => ...]);
```
**New Component/Module (Multi-Model Feature):**
- Implementation:
- Models: Multiple related models in `app/Models/`
- Service: `app/Services/NewModuleService.php` for complex logic
- Controller: `app/Http/Controllers/NewModuleController.php` or `Admin/NewModuleController.php`
- Views: `resources/views/admin/new-module/` or `resources/views/new-module/`
- Routes: Add feature routes to `routes/web.php`
- Migrations: Database tables in `database/migrations/`
- Example: Finance Documents (FinanceDocument → PaymentOrder → CashierLedgerEntry → AccountingEntry)
```php
// Service orchestrates workflow
$service->approveBySecretary($document, $user);
// Models track state
$document->status, $document->disbursement_status, $document->recording_status
```
**Utilities/Helpers:**
- Shared helpers: `app/Support/HelperClass.php` or method in `app/helpers.php`
- Example: `AuditLogger::log()`, `DownloadFile::download()`
- Global function: Add to `app/helpers.php` if used in multiple places
- Config values: Add to appropriate `config/` file
**New API Endpoint:**
- Controller: `app/Http/Controllers/Api/ResourceController.php`
- Resource: `app/Http/Resources/ResourceResource.php` for response formatting
- Routes: Add to `routes/api.php` under `prefix('v1')`
- Example:
```php
// routes/api.php
Route::get('/articles', [ArticleController::class, 'index']);
// app/Http/Controllers/Api/ArticleController.php
public function index() {
return ArticleResource::collection(Article::active()->get());
}
```
**New Blade Component:**
- File: `resources/views/components/component-name.blade.php`
- Class (if needed): `app/View/Components/ComponentName.php`
- Usage: `<x-component-name :data="$data" />`
**New Service:**
- File: `app/Services/NewService.php`
- Usage: Dependency injection in controllers or other services
- Pattern:
```php
// In controller
public function action(NewService $service) {
$result = $service->doSomething();
}
```
**New Trait (Shared Behavior):**
- File: `app/Traits/HasNewBehavior.php`
- Usage: `use HasNewBehavior;` in models
- Pattern: Define methods that multiple models need
## Special Directories
**storage/app/:**
- Purpose: Private file uploads
- Subdirectories: `finance-documents/`, `articles/`, `disability-certificates/`
- Generated: Yes
- Committed: No (in .gitignore)
- Access: `Storage::disk('local')->get($path)` or via authenticated controller
**database/schema/:**
- Purpose: Schema dump for reference
- Generated: By `php artisan schema:dump`
- Committed: Yes (read-only reference)
- Use: Check table structure without opening migrations
**resources/css/:**
- Purpose: Tailwind CSS source
- Style: Uses `@apply` for utility classes
- Config: `tailwind.config.js` with dark mode class strategy
- Build: `npm run build` compiles to `public/css/app.css`
**config/:**
- Purpose: Application configuration
- Committed: Yes (except `.env`)
- Environment-specific: Values read from `.env` via `env('KEY', 'default')`
- Access: `config('key.subkey')` in code
**tests/:**
- Purpose: PHPUnit test suite
- Structure: Mirror app structure (Unit/, Feature/)
- Pattern: `RefreshDatabase` trait for database reset, use test factories
- Run: `php artisan test`
---
*Structure analysis: 2026-02-13*

View File

@@ -0,0 +1,429 @@
# Testing Patterns
**Analysis Date:** 2026-02-13
## Test Framework
**Runner:**
- PHPUnit 10.1+ (configured in `composer.json`)
- Config file: `phpunit.xml`
**Assertion Library:**
- PHPUnit assertions (built-in)
- Laravel testing traits: `RefreshDatabase`, `WithFaker`, `DatabaseMigrations`
**Run Commands:**
```bash
php artisan test # Run all tests
php artisan test --filter=ClassName # Run specific test class
php artisan test --filter=test_method_name # Run specific test method
php artisan dusk # Run browser (Dusk) tests
```
**Coverage:**
```bash
php artisan test --coverage # Generate coverage report
```
## Test File Organization
**Location:**
- Unit tests: `tests/Unit/`
- Feature tests: `tests/Feature/`
- Browser tests: `tests/Browser/`
- Shared test utilities: `tests/Traits/`
- Test base classes: `tests/TestCase.php`, `tests/DuskTestCase.php`
**Naming:**
- Test files: PascalCase + `Test.php` suffix (e.g., `FinanceDocumentTest.php`, `CashierLedgerWorkflowTest.php`)
- Test methods: `test_` prefix (e.g., `test_payment_belongs_to_member()`) OR `/** @test */` annotation
- Test trait files: PascalCase with context (e.g., `CreatesFinanceData.php`, `SeedsRolesAndPermissions.php`)
**Structure:**
```
tests/
├── Unit/ # Model logic, calculations, state checks
│ ├── FinanceDocumentTest.php
│ ├── MemberTest.php
│ ├── BudgetTest.php
│ └── MembershipPaymentTest.php
├── Feature/ # HTTP requests, workflows, integrations
│ ├── CashierLedgerWorkflowTest.php
│ ├── Auth/
│ ├── BankReconciliation/
│ └── ProfileTest.php
├── Browser/ # Full page interactions with Dusk
│ ├── MemberDashboardBrowserTest.php
│ ├── FinanceWorkflowBrowserTest.php
│ └── Pages/
├── Traits/ # Reusable test setup helpers
│ ├── SeedsRolesAndPermissions.php
│ ├── CreatesFinanceData.php
│ ├── CreatesMemberData.php
│ └── CreatesApplication.php
├── TestCase.php # Base test class with setup
└── DuskTestCase.php # Base for browser tests
```
## Test Structure
**Suite Organization:**
```php
<?php
namespace Tests\Unit;
use App\Models\FinanceDocument;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
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());
}
}
```
**Patterns:**
1. **Setup Method:**
```php
protected function setUp(): void
{
parent::setUp();
// Seed roles/permissions once per test class
$this->artisan('db:seed', ['--class' => 'FinancialWorkflowPermissionsSeeder']);
// Fake storage if needed
Storage::fake('private');
// Create test users
$this->admin = User::factory()->create();
$this->admin->assignRole('admin');
}
```
2. **Teardown:** Not typically needed; `RefreshDatabase` trait handles rollback automatically.
3. **Assertion Pattern:**
Use specific assertions for clarity:
```php
// Model relationships
$this->assertInstanceOf(Member::class, $payment->member);
$this->assertEquals($cashier->id, $payment->verifiedByCashier->id);
// Status checks
$this->assertTrue($payment->isPending());
$this->assertFalse($document->needsBoardMeetingApproval());
// Database state
$this->assertDatabaseHas('cashier_ledger_entries', [
'entry_type' => 'receipt',
'amount' => 5000,
]);
// HTTP responses
$response->assertStatus(200);
$response->assertRedirect();
```
## Mocking
**Framework:** Mockery (configured in `composer.json`)
**Patterns:**
Minimal mocking in this codebase. When needed:
1. **File Storage Mocking:**
```php
protected function setUp(): void
{
parent::setUp();
Storage::fake('private'); // All storage operations use fake disk
}
// In test
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$path = $file->store('payment-receipts', 'private');
```
2. **Database Factories:** Preferred over mocking for model creation
```php
$member = Member::factory()->create(['membership_status' => 'active']);
$payment = MembershipPayment::factory()->create(['member_id' => $member->id]);
```
**What to Mock:**
- External APIs (not used in current codebase)
- File uploads (use `Storage::fake()`)
- Email sending (Laravel's test mailers or `Mail::fake()`)
**What NOT to Mock:**
- Database models (use factories instead)
- Business logic methods (test them directly)
- Service classes (inject real instances)
- Permission/role system (use real Spatie setup)
## Fixtures and Factories
**Test Data:**
Models use Laravel factories (`database/factories/`):
```php
// In test
$member = Member::factory()->create([
'membership_status' => 'active',
'membership_expires_at' => now()->addYear(),
]);
```
**Helper Traits:**
Reusable test setup in `tests/Traits/`:
1. **`SeedsRolesAndPermissions`** (`tests/Traits/SeedsRolesAndPermissions.php`):
- `seedRolesAndPermissions()`: Seed all financial roles
- `createUserWithRole(string $role)`: Create user + assign role
- `createAdmin()`, `createSecretary()`, `createChair()`: Convenience methods
- `createFinanceApprovalTeam()`: Create all approval users at once
2. **`CreatesFinanceData`** (`tests/Traits/CreatesFinanceData.php`):
- `createFinanceDocument(array $attributes)`: Basic document
- `createSmallAmountDocument()`: Auto-set amount < 5000
- `createMediumAmountDocument()`: Auto-set 5000-50000
- `createLargeAmountDocument()`: Auto-set > 50000
- `createDocumentAtStage(string $stage)`: Pre-set approval status
- `createPaymentOrder()`, `createBankReconciliation()`: Domain-specific
- `createFakeAttachment()`: PDF file for testing
- `getValidFinanceDocumentData()`: Reusable form data
3. **`CreatesMemberData`** (`tests/Traits/CreatesMemberData.php`):
- `createMember()`: Create member + associated user
- `createPendingMember()`, `createActiveMember()`, `createExpiredMember()`: Status helpers
- `createMemberWithPendingPayment()`: Pre-populated workflow state
**Location:**
- Traits: `tests/Traits/`
- Factories: `database/factories/`
- Database seeders: `database/seeders/`
**Usage Example:**
```php
class FinanceWorkflowTest extends TestCase
{
use RefreshDatabase, SeedsRolesAndPermissions, CreatesFinanceData;
protected function setUp(): void
{
parent::setUp();
$this->seedRolesAndPermissions();
}
public function test_approval_workflow()
{
$secretary = $this->createSecretary();
$document = $this->createSmallAmountDocument();
$this->actingAs($secretary)
->post(route('admin.finance-document.approve', $document), [
'approval_notes' => 'Approved'
])
->assertRedirect();
}
}
```
## Coverage
**Requirements:** No minimum coverage enforced (not configured in `phpunit.xml`).
**View Coverage:**
```bash
php artisan test --coverage # Text report
php artisan test --coverage --html # HTML report in coverage/ directory
```
**Target areas (recommended):**
- Model methods: 80%+
- Service classes: 80%+
- Controllers: 70%+ (integration tests)
- Traits: 80%+
## Test Types
**Unit Tests** (`tests/Unit/`):
- Scope: Single method/model logic
- Database: Yes, but minimal (models with factories)
- Approach: Direct method calls, assertions on return values
- Example (`FinanceDocumentTest.php`):
```php
public function test_it_determines_small_amount_tier_correctly()
{
$document = new FinanceDocument(['amount' => 4999]);
$this->assertEquals('small', $document->determineAmountTier());
}
```
**Feature Tests** (`tests/Feature/`):
- Scope: Full request → response cycle
- Database: Yes (full workflow)
- Approach: HTTP methods (`$this->get()`, `$this->post()`), database assertions
- Example (`CashierLedgerWorkflowTest.php` lines 41-64):
```php
public function cashier_can_create_receipt_entry()
{
$this->actingAs($this->cashier);
$response = $this->post(route('admin.cashier-ledger.store'), [
'entry_type' => 'receipt',
'amount' => 5000,
// ...
]);
$response->assertRedirect();
$this->assertDatabaseHas('cashier_ledger_entries', [
'entry_type' => 'receipt',
'amount' => 5000,
]);
}
```
**Browser Tests** (`tests/Browser/` with Laravel Dusk):
- Scope: Full page interactions (JavaScript, dynamic content)
- Database: Yes
- Approach: Browser automation, user journey testing
- Example (`ExampleTest.php`):
```php
public function testBasicExample(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->assertSee('Laravel');
});
}
```
## Common Patterns
**Async Testing:**
Use `RefreshDatabase` trait for atomic transactions:
```php
public function test_concurrent_payment_submissions()
{
$member = $this->createActiveMember();
$payment1 = MembershipPayment::factory()->create([
'member_id' => $member->id,
'status' => MembershipPayment::STATUS_PENDING
]);
// Multiple operations automatically rolled back after test
}
```
**Error Testing:**
```php
public function test_invalid_amount_rejected()
{
$validated = $this->validate([
'amount' => 'required|numeric|min:0'
], ['amount' => -1000]);
$this->assertFalse($validated); // Validation fails
}
```
**Permission Testing:**
```php
public function test_non_admin_cannot_approve_large_document()
{
$user = $this->createMembershipManager();
$document = $this->createLargeAmountDocument();
$this->actingAs($user)
->post(route('admin.finance-document.approve', $document))
->assertForbidden(); // 403 Forbidden
}
```
**State Transitions:**
```php
public function test_document_approval_workflow()
{
$secretary = $this->createSecretary();
$document = $this->createDocumentAtStage('pending');
// Step 1: Secretary approves
$this->actingAs($secretary)
->post(route('admin.finance-document.approve', $document));
$this->assertEquals(
FinanceDocument::STATUS_APPROVED_SECRETARY,
$document->fresh()->status
);
// Step 2: Chair approves
$chair = $this->createChair();
$this->actingAs($chair)
->post(route('admin.finance-document.approve', $document->fresh()));
$this->assertEquals(
FinanceDocument::STATUS_APPROVED_CHAIR,
$document->fresh()->status
);
}
```
**Database Assertions:**
```php
// Check record exists with specific values
$this->assertDatabaseHas('finance_documents', [
'id' => $document->id,
'status' => FinanceDocument::STATUS_APPROVED_SECRETARY,
]);
// Check record count
$this->assertCount(3, MembershipPayment::all());
// Count with condition
$this->assertEquals(
2,
MembershipPayment::where('status', 'pending')->count()
);
```
## Environment & Configuration
**Test Environment:**
- Set via `phpunit.xml` `<env>` elements
- `APP_ENV=testing`
- `DB_CONNECTION=sqlite`
- `DB_DATABASE=:memory:` (transient in-memory database)
- `MAIL_MAILER=array` (no real emails sent)
- `QUEUE_CONNECTION=sync` (synchronous job processing)
**Database State:**
- `RefreshDatabase` trait: Runs migrations before each test, rolls back after
- `:memory:` SQLite: Fresh database per test, extremely fast
- No fixtures needed; use factories instead
**Disabling Middleware (when needed):**
```php
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware([
\App\Http\Middleware\EnsureUserIsAdmin::class,
\App\Http\Middleware\VerifyCsrfToken::class
]);
}
```
---
*Testing analysis: 2026-02-13*