diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md
new file mode 100644
index 0000000..6125609
--- /dev/null
+++ b/.planning/codebase/ARCHITECTURE.md
@@ -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*
diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md
new file mode 100644
index 0000000..39f9327
--- /dev/null
+++ b/.planning/codebase/CONCERNS.md
@@ -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*
diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md
new file mode 100644
index 0000000..56f3920
--- /dev/null
+++ b/.planning/codebase/CONVENTIONS.md
@@ -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*
diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md
new file mode 100644
index 0000000..69ac1b9
--- /dev/null
+++ b/.planning/codebase/INTEGRATIONS.md
@@ -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*
diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md
new file mode 100644
index 0000000..5702e6a
--- /dev/null
+++ b/.planning/codebase/STACK.md
@@ -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*
diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md
new file mode 100644
index 0000000..914ca6a
--- /dev/null
+++ b/.planning/codebase/STRUCTURE.md
@@ -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: ``
+
+**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*
diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md
new file mode 100644
index 0000000..59f28b1
--- /dev/null
+++ b/.planning/codebase/TESTING.md
@@ -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
+ 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` `` 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*