From 47218c1874032ab4e921829205a70a9a9c3cc775 Mon Sep 17 00:00:00 2001 From: gbanyan Date: Fri, 13 Feb 2026 10:34:18 +0800 Subject: [PATCH] docs: map existing codebase --- .planning/codebase/ARCHITECTURE.md | 296 ++++++++++++++++++++ .planning/codebase/CONCERNS.md | 293 ++++++++++++++++++++ .planning/codebase/CONVENTIONS.md | 199 +++++++++++++ .planning/codebase/INTEGRATIONS.md | 281 +++++++++++++++++++ .planning/codebase/STACK.md | 223 +++++++++++++++ .planning/codebase/STRUCTURE.md | 362 ++++++++++++++++++++++++ .planning/codebase/TESTING.md | 429 +++++++++++++++++++++++++++++ 7 files changed, 2083 insertions(+) create mode 100644 .planning/codebase/ARCHITECTURE.md create mode 100644 .planning/codebase/CONCERNS.md create mode 100644 .planning/codebase/CONVENTIONS.md create mode 100644 .planning/codebase/INTEGRATIONS.md create mode 100644 .planning/codebase/STACK.md create mode 100644 .planning/codebase/STRUCTURE.md create mode 100644 .planning/codebase/TESTING.md 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*