Client portal for accounting firm receipts and returns
Laravel and MySQL portal for client document uploads, missing-item checklists, e-signed engagement letters, tax return downloads, 2FA and audited file access.
laravelmysqllivewirebladefortifypest
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add client-portal-for-accounting-firm-receipts-and-returns-113177CLAUDE.md# Client portal (Laravel)
## Purpose
This is a client portal for a small accounting firm. Clients upload monthly receipts and bank statements, see which checklist items are still missing, sign engagement letters, and download their tax returns. Staff see a dashboard of clients who are late for the current month, manage checklist items, upload documents, and review the audit log.
## Stack
- Laravel. Use the version in composer.json.
- MySQL.
- Blade and Livewire for views.
- Laravel Fortify for authentication and TOTP two-factor (2FA).
- Pest or PHPUnit, whichever composer.json uses.
- Larastan for static analysis and Pint for formatting.
## Commands
- `composer install`
- `php artisan serve`
- `php artisan test`
- `vendor/bin/pint --test`
- `vendor/bin/phpstan analyse`
- `npm run build`
- `php artisan migrate` (ask first)
## Roles and access
- Roles are client, staff and admin.
- Clients may only see their own records.
- Enforce access with Policies and a global scope on client-owned models. Never rely on checks in controllers alone.
## Non-negotiable rules
- Store files encrypted at rest on the private disk. Never use the public disk, and never serve files by direct URL.
- Every download, by client or staff, writes one row to `document_access_logs` before the stream starts. The table is append-only.
- Require 2FA for all client accounts. Check it in middleware on every portal route.
- Never log or echo file contents, decrypted keys, TOTP secrets, recovery codes or tax IDs.
- Mask SSN and tax ID fields in views and logs.
## Code conventions
- Validate input with Form Requests.
- Authorize with Policies.
- Put file and audit logic in `app/Services`.
- Run virus scans and the late-client calculation in Jobs.
- Upload rules: allowed types are pdf, jpg, png, csv and xlsx. The maximum size comes from config. Store the original name as metadata only and generate random storage names.
## Gotchas
- Never edit a migration that has been committed. Add a new one.
- Never run `migrate:fresh`, `db:wipe` or `migrate:reset`.
- Tests use a separate database (sqlite in memory or a MySQL test database, as set in phpunit.xml). Never run them against the dev database.
- Dev mail uses the log driver.
## Workflow
- Explore, plan, code, then run the verify skill before saying a task is done.
- Use `/clear` between unrelated tasks.
- Use the test-runner subagent for full test runs.
- Use the security-reviewer subagent for changes to auth, storage, policies or logging.
## Pointers
@REVIEW.md
- SPEC.md has the requirements and data model.
- PLAN.md has the phase goals and the checklist.
.claude/agents/security-reviewer.md---
name: security-reviewer
description: Reviews diffs that touch auth, 2FA, policies, document storage, downloads or audit logging against REVIEW.md. Use after changes in those areas and before committing.
model: opus
tools: Read, Grep, Glob, Bash
disallowedTools: Edit, Write
maxTurns: 15
---
# Security reviewer
You review changes for security defects. You report findings. You do not change code.
## Steps
1. Read REVIEW.md in full. It holds the review criteria.
2. Find the base branch (usually main). Run `git status` and `git diff <base>...HEAD`. Use Bash only for read-only git commands such as git diff, git status and git log.
3. Read the changed files in full where context matters.
4. Check each area in REVIEW.md: IDOR on client_id, document_id and period_id; missing Policy or middleware; 2FA bypass; plaintext on disk or the public disk; Storage::url or temporaryUrl for documents; missing download audit rows; update or delete paths on document_access_logs; tax IDs, tokens or file contents in logs, exceptions or JSON; missing negative tests for another client's data.
## Report format
- Blocking issues first, then should-fix, then nits.
- Each item: file:line, the problem in one sentence, and a concrete fix.
- If nothing blocking was found, say so explicitly.
- List the areas you did not check and why.
## Rules
- Do not edit or write files.
- Never print secret values, tokens or file contents from storage. Refer to them by name only.
- Cite a line for every finding. If you are unsure, phrase it as a question instead.
.claude/agents/test-runner.md---
name: test-runner
description: Runs php artisan test, or a filtered subset, and returns a short summary of failures only. Use for full test runs to keep output out of the main context.
model: haiku
tools: Bash, Read, Grep, Glob
maxTurns: 8
---
# Test runner
You run the Laravel test suite and report results. You do not fix code.
## Steps
1. If the caller gave a filter, run `php artisan test --filter=<filter>`. Otherwise run `php artisan test`.
2. Read the summary line for the pass and fail counts.
3. For each failure, use Read on the reported file at the reported line to confirm the assertion context.
## Report format
- Result: PASS or FAIL, with pass count and fail count.
- For each failure, at most 5 lines:
- Test name
- file:line
- First line of the assertion message
- Likely cause in one sentence
- No other commentary.
## Rules
- Do not edit files. Do not propose large fixes or refactors.
- Never print values from .env or any file under storage.
- If the suite cannot start (missing database, missing vendor folder), report the first error line and stop.
.claude/settings.json{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"defaultMode": "default",
"allow": [
"Bash(php artisan test:*)",
"Bash(vendor/bin/pint:*)",
"Bash(vendor/bin/phpstan:*)",
"Bash(composer install)",
"Bash(npm run build)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Read(./.env.example)"
],
"ask": [
"Bash(composer require:*)",
"Bash(composer update:*)",
"Bash(php artisan migrate:*)",
"Bash(npm install:*)",
"Bash(git push:*)",
"Bash(git commit:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.local)",
"Read(./.env.production)",
"Read(./.env.staging)",
"Read(./.env.testing)",
"Read(./storage/app/private/**)",
"Bash(php artisan migrate:fresh:*)",
"Bash(php artisan migrate:reset:*)",
"Bash(php artisan db:wipe:*)",
"Bash(rm -rf:*)",
"Bash(git push --force:*)"
]
},
"blockWritesOutsideWorkingDirectories": true,
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.php) vendor/bin/pint --quiet \"$f\" ;; esac"
}
]
}
]
}
}
.claude/skills/migration-safety/SKILL.md---
name: migration-safety
description: Rules for creating or changing MySQL migrations in a database that holds client data. Use whenever a file under database/migrations is created or edited.
when_to_use: Use when adding a table, column, index or foreign key, or when reviewing a migration. Also use when a user asks to run or roll back migrations.
paths: database/migrations/**
---
# Migration safety
This database holds client records and tax documents. Follow these rules for every migration.
## Editing rules
- Never edit a migration that has been committed. Create a new migration with a clear name, such as `add_due_on_index_to_checklist_items_table`.
- Always write a working `down()`. If the change cannot be reversed, say so in a comment above `down()` and explain what data would be lost.
## Keys and deletes
- Add every foreign key with an explicit `onDelete` choice.
- For `documents` and `document_access_logs`, use `restrictOnDelete`. Never use cascade.
## Indexes
Index the columns used in dashboard and checklist queries: `client_id`, `period_id`, `status`, and `due_on`. Add an index with the migration that introduces the column, not later.
## document_access_logs
- Columns: `document_id`, `user_id`, `action`, `ip`, `user_agent`, `created_at`.
- No `updated_at`, no update columns, no delete columns.
- No soft deletes.
- No migration may add an update or delete path to this table.
## Running migrations
1. Review the SQL with `php artisan migrate --pretend`.
2. Ask the user before running `php artisan migrate`.
3. Never run `migrate:fresh`, `migrate:reset` or `db:wipe`.
4. Before any production migration, remind the user to take a database backup and confirm it restores.
## Checklist before reporting done
- [ ] No committed migration was edited.
- [ ] Each new foreign key has an explicit `onDelete`.
- [ ] Dashboard columns are indexed.
- [ ] `down()` works, or the comment explains why it cannot.
- [ ] `--pretend` output reviewed.
.claude/skills/secure-file-handling/SKILL.md---
name: secure-file-handling
description: Use for any work on uploads, downloads, documents, tax returns or engagement letters, including app/Services/Documents, Document controllers and Document models. Enforces encrypted private storage and audited streaming.
when_to_use: Use when adding or changing file upload, file download, document storage, document models, or the document_access_logs table. Also use when reviewing code that touches client files.
---
# Secure file handling
Every file feature follows this pattern. Code examples are in `examples.md` next to this file. Do not copy them into controllers.
## Storage on write
1. Validate in a Form Request: allowed types (pdf, jpg, png, csv, xlsx), max size from config, and the real MIME type read from file content, not the client's extension.
2. Queue the malware scan job when a scanner is configured. Reject and delete the upload if the scan fails. Store only after validation passes.
3. Generate a random 32-byte data key for each file.
4. Encrypt the content with AES-256-GCM. For large files, use libsodium secretstream. Never reuse a nonce.
5. Wrap the data key with the app key from the env variable `APP_DOC_KEY`.
6. Write the ciphertext to the private disk under a random storage path.
7. Save the documents row with: wrapped key, sha256 of the plaintext, size, mime, original name as metadata only, and `uploaded_by`.
Never use the original file name in a storage path.
## Download
1. Authorize with a Policy. Load the document through the client-ownership scope.
2. Write one `document_access_logs` row with user, ip, user agent and action. Do this before the stream starts.
3. Stream-decrypt with `response()->streamDownload`.
4. Set these headers:
- `Content-Disposition` with the sanitized original name
- `Cache-Control: no-store`
- `X-Content-Type-Options: nosniff`
Downloads always go through an authenticated request. Do not use signed URLs or temporary URLs.
Decryption happens only inside the streaming service, never in a controller.
## Tests to write for any file feature
- Ciphertext on disk is not equal to the plaintext.
- A user from another client gets 403, and no success log row is written.
- A successful download writes a log row with user, ip and action.
- Tampered ciphertext fails to decrypt.
- The sha256 of the decrypted output matches the stored hash.
## Never
- `Storage::disk('public')` for documents
- `Storage::url()` or `temporaryUrl()` for documents
- Logging file bytes, decrypted content, or the data key
- Reusing a nonce
- Decrypting in a controller
- Printing `APP_DOC_KEY` or any other env value
## Key handling
`APP_DOC_KEY` is a placeholder in the repo. The real value stays in `.env` and in a backup outside the repo. Losing the key makes stored files unrecoverable. Key rotation is a later task. Do not design around it now.
.claude/skills/verify/SKILL.md---
name: verify
description: Run before saying any task is done. Runs Pint, Larastan, the tests and any needed build or migration checks, then reports each step's result.
when_to_use: Use after changing code in app, routes, database or resources, and before claiming a task is complete. Also use when the user asks whether a change works.
---
# Verify
Run these steps in order. Stop at the first failure, fix it, and start again from Step 1.
## Step 1: Code style
Run `vendor/bin/pint --test`.
If it fails, run `vendor/bin/pint` to fix the files, then run `vendor/bin/pint --test` again.
## Step 2: Static analysis
Run `vendor/bin/phpstan analyse --memory-limit=1G`.
If Larastan is not installed yet, skip this step and write "Skipped: Larastan not installed" in the report.
## Step 3: Tests
For speed, first run the tests for the code you changed:
`php artisan test --filter=Name`
Then run the full suite. Delegate the full run to the test-runner subagent so the output stays out of the main context. If you run it yourself, use `php artisan test`.
The test-runner returns pass count, fail count, and each failure with test name, file:line and assertion message. Use that to fix failures.
## Step 4: Frontend build
Run `npm run build` only when files in `resources/js`, `resources/css` or the Blade layouts changed. Otherwise write "Skipped: no frontend changes" in the report.
## Step 5: Migration and file-path checks
For new or changed migrations, run `php artisan migrate --pretend` and read the SQL it prints. Check that:
- No committed migration was edited.
- Foreign keys have an explicit `onDelete`.
- Dashboard columns (`client_id`, `period_id`, `status`, `due_on`) are indexed.
Do not run `php artisan migrate` without asking the user first.
For upload or download changes, run the feature tests that assert:
- The file on disk is ciphertext, not plaintext.
- A `document_access_logs` row is written for each download.
- A client cannot access another client's document.
## Report format
List each step on its own line with one of: PASS, FAIL, or SKIPPED (with reason).
For a FAIL, give the command, the first failing assertion or error line, and the file:line if known.
Never say the task is done if any step failed. A skipped step needs a reason. If you could not run a step, say so and say what remains.
Keep the report under 20 lines.
INSTALL.md# Install and setup
## Prerequisites
- PHP and Composer versions that match `composer.json`
- MySQL server and a database user for the app
- Node.js and npm for `npm run build`
- Claude Code 2.1.296 or newer (`claude --version`)
## 1. Add the project files
Copy `CLAUDE.md`, `SPEC.md`, `PLAN.md`, `REVIEW.md`, `INSTALL.md` and the `.claude/` folder into the repo root. Commit them.
## 2. Create or open the Laravel app
If there is no app yet:
```
composer create-project laravel/laravel .
```
Set MySQL values in `.env`. Use placeholders in shared files and fill in real values on your machine only:
```
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=portal
DB_USERNAME=portal_app
DB_PASSWORD=YOUR_DB_PASSWORD
```
## 3. Generate the document key
```
php -r "echo base64_encode(random_bytes(32));"
```
Put the output in `.env` as `APP_DOC_KEY=YOUR_BASE64_32_BYTE_KEY`.
Keep a separate backup of this key outside the repo. Losing it makes every stored file unrecoverable.
## 4. Install dev tools
```
composer require --dev laravel/pint larastan/larastan pestphp/pest
```
Skip any package already in `composer.json`.
## 5. Install Claude Code plugins
Start `claude` in the repo, then run these inside Claude Code:
```
/plugin install code-review@claude-plugins-official
/plugin install security-guidance@claude-plugins-official
/plugin install feature-dev@claude-plugins-official
/plugin install commit-commands@claude-plugins-official
```
## 6. Check permissions and skills
- Run `/permissions`. Confirm the deny rules for `.env`, `.env.*`, `storage/app/private/**` and destructive artisan commands.
- Run `/skill-doctor`. Confirm `verify`, `secure-file-handling` and `migration-safety` load.
## 7. Start Phase 1
Paste the Phase 1 `/goal` prompt from `PLAN.md`. Run `/clear` before each later phase.
- `.env` is denied to Claude. Never paste real keys or passwords into prompts.
## Production notes
- Serve the portal over HTTPS only.
- Encrypt database backups. Test a restore at least once.
- Store `APP_DOC_KEY` outside the repo, in a password manager or secrets vault, with a separate backup.
- Use two database users. The migration user has schema rights. The runtime user has INSERT and SELECT only on `document_access_logs`.
- Back up the database before any production migration.
- Ask the firm about legal review of e-signature validity, the retention period and data residency.
## Token habits
- `/clear` between phases and unrelated tasks.
- `/compact` at natural breaks.
- `/usage` to see where tokens went.
PLAN.md# Build plan
Build the portal in five phases. Run each phase in a fresh session: use `/clear`, then paste that phase's `/goal` prompt. The `/goal` evaluator only sees the conversation, so every prompt requires the check output to be printed there.
## Phase 1 Foundation
Scope:
- Laravel app on MySQL, with users, roles, clients and client_user tables
- Fortify login with mandatory TOTP 2FA for clients and staff
- Rate limits on login and 2FA; Policies and client-ownership scope
- Pint and Larastan configured
Files likely touched: config/fortify.php, config/database.php, app/Models/*, app/Http/Middleware/*, app/Policies/*, database/migrations/*, routes/web.php, phpunit.xml or tests/Pest.php, phpstan.neon, pint.json, tests/Feature/*
`/goal` prompt:
/goal Phase 1 Foundation is complete when ALL of these are true and the output of each check is shown in this conversation: (1) The Laravel app runs against MySQL with migrations for users (role: client, staff, admin), clients and client_user. (2) Fortify login requires TOTP 2FA for every client and staff account; a logged-in user without confirmed 2FA is redirected to 2FA setup on every portal route by middleware. (3) Login and 2FA challenge routes are rate limited. (4) Policies and a client-ownership scope let a client load only their own client record; a feature test proves client A gets 403 or 404 on client B. (5) Feature tests cover login with 2FA, the 2FA redirect, and role access to a placeholder /staff/dashboard and /portal home. (6) Pint and Larastan are installed and configured. (7) `vendor/bin/pint --test`, `vendor/bin/phpstan analyse` and `php artisan test` all exit 0, and their final lines are printed. Start by reading SPEC.md and CLAUDE.md. Constraints: no new dependencies beyond laravel/fortify, laravel/pint, larastan/larastan and the test framework already in composer.json; no edits to committed migrations; never read or print .env or any secret; ask before running `php artisan migrate`; never run migrate:fresh or db:wipe; use the test-runner subagent for the full run; run the verify skill before finishing. Do not build upload, checklist or dashboard logic yet. Record assumptions under Open questions in SPEC.md. At the end tick Phase 1 in the checklist below. Stop after 40 turns and report exactly what remains.
## Phase 2 Secure documents
Scope:
- Encrypted private storage service (per-file data key, wrapped with APP_DOC_KEY)
- Upload and streamed download endpoints with real MIME and size validation
- Document Policies; append-only document_access_logs table
- Tests proving ciphertext on disk and one log row per download
Files likely touched: app/Services/Documents/*, app/Http/Controllers/Document*, app/Http/Requests/*, app/Models/Document.php, app/Models/DocumentAccessLog.php, app/Policies/DocumentPolicy.php, database/migrations/*, config/filesystems.php, tests/Feature/Documents/*
`/goal` prompt:
/goal Phase 2 Secure documents is complete when ALL of these are true and the output of each check is shown in this conversation: (1) Uploads of pdf, jpg, png, csv and xlsx are validated for type, real MIME and size from config, then stored encrypted on the private disk under a random path, with a documents row holding sha256, encrypted_key and original_name as metadata only. (2) Downloads are authorized by DocumentPolicy, write exactly one document_access_logs row before the stream starts, and stream-decrypt with response()->streamDownload using Cache-Control no-store and X-Content-Type-Options nosniff. (3) document_access_logs has no update or delete code path and the model throws on either. (4) Feature tests prove: stored bytes differ from plaintext; wrong client gets 403 with no success log row; the log row holds user, ip and action; tampered ciphertext fails to decrypt. (5) `vendor/bin/pint --test`, `vendor/bin/phpstan analyse` and `php artisan test` all exit 0, and their final lines are printed. Start by reading SPEC.md, CLAUDE.md and the secure-file-handling skill. Constraints: no new dependencies (use libsodium from PHP core); no Storage::disk('public'), temporaryUrl or signed URLs; never log file bytes or keys; no edits to committed migrations; ask before running `php artisan migrate`; use the test-runner subagent for the full run; run the security-reviewer subagent on the diff; run the verify skill before finishing. Do not build checklist or letter logic yet. At the end tick Phase 2 in the checklist below. Stop after 40 turns and report exactly what remains.
## Phase 3 Monthly checklist
Scope:
- Periods and checklist_items tables with indexes on client_id, period_id, status, due_on
- Client upload page per month linked to checklist items
- Missing-items view for clients and staff
- Reminder notifications (log mail driver in dev)
Files likely touched: app/Models/Period.php, app/Models/ChecklistItem.php, app/Services/Checklist/*, app/Jobs/*, app/Notifications/*, resources/views/portal/*, routes/web.php, database/migrations/*, tests/Feature/Checklist/*
`/goal` prompt:
/goal Phase 3 Monthly checklist is complete when ALL of these are true and the output of each check is shown in this conversation: (1) Periods and checklist_items exist with migrations and indexes on client_id, period_id, status and due_on. (2) A client can upload a file for a chosen month and link it to a checklist item through the Phase 2 document service; the page shows required versus received items. (3) Staff can mark an item received or waived, and the change is covered by a Policy check and a feature test. (4) The missing-items view lists only the logged-in client's items. (5) A reminder notification queues for each client with missing items, and a test asserts it is sent to the log mail driver. (6) `vendor/bin/pint --test`, `vendor/bin/phpstan analyse` and `php artisan test` all exit 0, and their final lines are printed. Start by reading SPEC.md and CLAUDE.md. Constraints: no new dependencies without asking; no edits to committed migrations; never print .env or secrets; ask before running `php artisan migrate`; use the test-runner subagent for the full run; run the verify skill before finishing. Do not build the late-clients dashboard or letters yet. At the end tick Phase 3 in the checklist below. Stop after 40 turns and report exactly what remains.
## Phase 4 Letters and returns
Scope:
- Engagement letter send and sign flow: typed name, consent checkbox, timestamp, IP, document hash
- Signed PDF copy generated and stored as a document
- Staff upload of tax returns; client download page
- Feature tests for every download path
Files likely touched: app/Services/Letters/*, app/Services/Documents/*, app/Http/Controllers/Letter*, app/Http/Controllers/Return*, app/Models/EngagementLetter.php, database/migrations/*, resources/views/portal/*, tests/Feature/Letters/*, tests/Feature/Returns/*
`/goal` prompt:
/goal Phase 4 Letters and returns is complete when ALL of these are true and the output of each check is shown in this conversation: (1) Staff can send an engagement letter to a client; the client signs by typing their full name and ticking a consent checkbox. (2) Signing stores signed_at, signer_name, signer_ip and the SHA-256 of the letter in engagement_letters, and creates a signed PDF copy stored as a document. (3) Staff can upload tax_return documents; clients see and download only their own returns. (4) Every download path writes one document_access_logs row, proven by feature tests including a negative test for another client's document. (5) The signing page states that the firm is responsible for legal review of e-signatures. (6) `vendor/bin/pint --test`, `vendor/bin/phpstan analyse` and `php artisan test` all exit 0, and their final lines are printed. Start by reading SPEC.md and CLAUDE.md. Constraints: no new dependencies without asking (the PDF library choice goes to SPEC.md Open questions first); no edits to committed migrations; never print .env or secrets; ask before running `php artisan migrate`; use the test-runner subagent for the full run; run the verify skill before finishing. Do not build the late dashboard yet. At the end tick Phase 4 in the checklist below. Stop after 40 turns and report exactly what remains.
## Phase 5 Staff dashboard and hardening
Scope:
- Late-clients dashboard driven by a scheduled job and a cached table
- Audit log viewer for staff
- Rate limits confirmed; session timeout confirmed
- security-reviewer pass; backup and restore notes in INSTALL.md
Files likely touched: app/Jobs/*, app/Console/Kernel.php or routes/console.php, app/Services/Late/*, app/Http/Controllers/Staff*, resources/views/staff/*, config/session.php, INSTALL.md, database/migrations/*, tests/Feature/Staff/*
`/goal` prompt:
/goal Phase 5 Staff dashboard and hardening is complete when ALL of these are true and the output of each check is shown in this conversation: (1) A scheduled job computes late status per client for the current period and caches it in a table; the dashboard reads only that table. (2) The dashboard lists only clients with a required item missing past due_on, and a test shows it with no N+1 queries. (3) Staff can open the audit log viewer, which is read-only, paginated and Policy-protected. (4) Login and 2FA rate limits and the session timeout are covered by tests. (5) The security-reviewer subagent has run on the phase diff and every blocking issue is fixed or listed in SPEC.md Open questions. (6) INSTALL.md has backup and restore notes covering the database and APP_DOC_KEY stored outside the repo. (7) `vendor/bin/pint --test`, `vendor/bin/phpstan analyse` and `php artisan test` all exit 0, and their final lines are printed. Start by reading SPEC.md, REVIEW.md and CLAUDE.md. Constraints: no new dependencies without asking; no edits to committed migrations; never print .env or secrets; ask before running `php artisan migrate`; use the test-runner subagent for the full run; run the verify skill before finishing. At the end tick Phase 5 in the checklist below. Stop after 40 turns and report exactly what remains.
## Checklist
- [ ] Phase 1 Foundation
- [ ] Phase 2 Secure documents
- [ ] Phase 3 Monthly checklist
- [ ] Phase 4 Letters and returns
- [ ] Phase 5 Staff dashboard and hardening
REVIEW.md# Review criteria
Used by `/code-review` (through the import in CLAUDE.md) and by the security-reviewer subagent.
## Authorization
- Every route and Livewire action has a Policy or middleware check.
- Look for IDOR on `client_id`, `document_id` and `period_id` in URLs, form fields and Livewire properties.
- Client queries use the client-ownership scope. Controllers alone are not enough.
## Files
- No use of the public disk for client files.
- No `Storage::url` or `temporaryUrl` for documents.
- Decrypt only inside the streaming service.
- Never trust uploaded file names. Storage names are random.
## Audit
- Each download path writes one `document_access_logs` row before the stream starts.
- `document_access_logs` has no update or delete code paths. The model throws on both.
## Two-factor
- No portal route is reachable by a logged-in client without confirmed 2FA.
- Recovery codes are hashed or encrypted at rest.
## Data exposure
- No tax IDs, tokens, TOTP secrets or file contents in logs, exceptions or JSON responses.
- Tax ID and SSN fields are masked in views.
- Mass assignment is guarded on every model that takes request input.
## Database
- New migration instead of edits to a committed migration.
- Indexes on `client_id`, `period_id`, `status` and `due_on`.
- No N+1 queries on dashboard pages.
## Tests
- Each new behavior has a feature test.
- Each client-data endpoint has a negative test: another client's data returns 403 or 404.
## Style
- Pint is clean. Larastan passes at the configured level.
- No dead code.
## Report format
- Blocking issues first, then should-fix, then nits.
- Cite file and line for every item.
- Say so explicitly when nothing blocking was found.
SPEC.md# Client portal spec
## Goals
- Let clients of a small accounting firm upload monthly receipts and statements, see what is missing, sign engagement letters and download tax returns.
- Give staff a dashboard of late clients and a full audit trail of document access.
- Protect client data with encryption at rest, mandatory 2FA, strict per-client isolation and an append-only download log.
## Non-goals
- Accounting or bookkeeping features
- Payments
- Client-to-staff chat
- Mobile app
## User stories: clients
- As a client, I log in with my password and a TOTP code.
- As a client, I upload files for a chosen month.
- As a client, I see a checklist of required and received items for each month.
- As a client, I sign an engagement letter.
- As a client, I download my tax returns.
## User stories: staff
- As staff, I see a dashboard of clients who are late for the current month.
- As staff, I open a client and mark checklist items as received or waived.
- As staff, I upload tax returns and engagement letters for a client.
- As staff, I view the audit log of document access.
## Data model (draft)
- users: id, name, email, password, role (client, staff, admin), two_factor columns from Fortify
- clients: id, name, tax_id (encrypted, masked in views), status
- client_user: client_id, user_id (pivot)
- periods: id, year, month, unique on (year, month)
- checklist_items: id, client_id, period_id, label, status (missing, received, waived), due_on
- documents: id, client_id, period_id (nullable), type (receipt, statement, engagement_letter, tax_return), original_name, storage_path, size, mime, sha256, encrypted_key, uploaded_by
- engagement_letters: id, document_id, signed_at, signer_name, signer_ip, signature_hash
- document_access_logs: id, document_id, user_id, action, ip, user_agent, created_at; no updates, no deletes
## Security requirements
- Encryption at rest: each file gets a random 32-byte data key. Content is encrypted with AES-256-GCM (libsodium secretstream for large files). The data key is wrapped with the app key from env APP_DOC_KEY (placeholder value only, never committed).
- Key rotation is a later task. Record it in Open questions until built.
- Files live on a private disk under random storage paths. The public disk is never used for documents.
- Downloads always stream through an authenticated controller. No signed or temporary URLs.
- Audit log is immutable: the database user has INSERT and SELECT only on document_access_logs, and the model throws on update and delete.
- 2FA is required for all client accounts. Staff also use 2FA (assumed, confirm with the firm).
- Session timeout is configured in config/session.php. Login and 2FA challenges are rate limited.
- Sensitive values (tax IDs, TOTP secrets, recovery codes) are never logged, echoed or returned in JSON. Tax IDs are masked in views.
## Definition of late
A client is late when any required checklist item of the current period is still missing after its due_on date. A scheduled job calculates late status once per day and caches the result in a table, so the dashboard does not recalculate it per request.
## E-signature approach
- The client types their full name and ticks a consent checkbox.
- The system stores the timestamp, IP address and SHA-256 hash of the letter document.
- The system generates a signed PDF copy and stores it as a document.
- This is a typed-name signature. Legal review of e-signature validity is the firm's responsibility, not the software's.
## Email notifications
- Reminder to clients listing missing checklist items.
- Staff notified when a client uploads a file.
- Dev environment uses the log mail driver.
## Acceptance criteria
- A logged-out visitor opening any portal route is redirected to login.
- A logged-in client without confirmed 2FA is redirected to 2FA setup on every portal route.
- Client A requesting a document of client B receives 403 and no success log row is written.
- Each successful download writes exactly one document_access_logs row before the stream starts.
- The stored file on the private disk is not equal to the uploaded plaintext.
- Tampered ciphertext fails to decrypt and returns an error with no partial output.
- Login attempts beyond the configured limit return HTTP 429.
- An upload with a disallowed type or a size above the configured maximum is rejected and nothing is stored.
- The late dashboard lists only clients with a missing required item past due_on in the current period.
- Signing an engagement letter stores signed_at, signer name, IP and document hash, and creates a signed PDF document.
- Reminder emails go to clients with missing items, and staff receive an email on each new upload.
## Open questions for the firm
- Retention period for client documents and logs.
- Data residency requirements (hosting region).
- Whether staff must use 2FA (assumed yes).
- PDF generation library for the signed copy.
- Malware scanning: ClamAV or none for the first release.
- Key rotation schedule for APP_DOC_KEY.
- Legal review of typed-name e-signatures.