Files
pn-new-crm/09-BUSINESS-RULES.md

505 lines
25 KiB
Markdown
Raw Normal View History

# Port Nimara CRM — Business Rules Specification
**Compiled:** 2026-03-11
**Core Principle:** The system never forces a workflow. All automated actions are convenience features that can be manually overridden by the salesperson at any time.
---
## 1. Berth Status Rules
### BR-001: Configurable Berth Status Transition Rules
Berth status transitions are **admin-configurable** via a rules engine stored in `system_settings` (key: `berth_status_rules`, per-port). Each rule defines: a trigger action, a target status, and a mode.
**Modes:**
- **auto** — Status changes silently with an audit log entry. No user interaction needed.
- **suggest** — A toast/confirmation prompt appears: "Change berth B-12 status to Under Offer?" — salesperson accepts or dismisses. If dismissed, status stays unchanged.
- **off** — No status change, no prompt. The trigger is ignored.
**Default rules (shipped out of the box, admin can modify):**
| Trigger Action | Default Mode | Target Status | Notes |
| ------------------------------------------------------------ | ------------ | ------------- | ------------------------------------------------ |
| First active interest linked to berth | **suggest** | `under_offer` | Only fires when berth is currently `available` |
| All active interests unlinked/archived from berth | **suggest** | `available` | Only fires when berth is currently `under_offer` |
| EOI sent on any linked interest | **auto** | `under_offer` | Sending an EOI signals real engagement |
| EOI fully signed on any linked interest | **auto** | `under_offer` | Keeps under_offer (doesn't escalate yet) |
| Deposit received (interest reaches `deposit_10pct`) | **suggest** | `sold` | Big moment — prompt to confirm |
| Contract signed (interest reaches `contract` or `completed`) | **suggest** | `sold` | Final confirmation |
| Interest manually archived (sole link to berth) | **suggest** | `available` | Same as unlink |
**Admin configuration UI:** Settings → Berth Status Rules page. Table with columns: Trigger, Mode (dropdown: auto/suggest/off), Target Status (dropdown: available/under_offer/sold). Admin can also add custom rules for future trigger types.
**Rule evaluation order:** Rules are evaluated in the order listed above. First matching rule wins. If multiple rules match the same action, only the first applies.
**Manual override always available:** Regardless of rule configuration, the salesperson can manually set any berth status at any time from the berth detail panel. Manual changes are never blocked by the rules engine — rules only govern _automatic_ or _suggested_ transitions.
**Audit trail:** Every status change (auto, suggested-and-accepted, or manual) is logged in the audit log with: old status, new status, trigger action (or "manual"), rule mode used, and the user who accepted/performed it.
### BR-002: Berth Status on Website Map
- Public API serves berth status directly. Website map color-codes: green = available, orange = under_offer, red = sold.
- Status changes propagate to the website in real-time via the public API (no caching on berth status).
### BR-003: Berth Tenure Expiration
- **Trigger:** Daily job checks all fixed-term berths
- **Condition:** Berth `tenure_end_date` is within configurable warning period (default: 6 months)
- **Action:** Create notification for admin. If waiting list entries exist, create notifications for waiting list clients.
- **On expiration:** Berth does NOT auto-change status. Admin must manually decide next steps.
---
## 2. Interest Pipeline Rules
### BR-010: Pipeline Stages
Ordered stages: `open``details_sent``in_communication``visited``signed_eoi_nda``deposit_10pct``contract``completed`
- All forward and backward transitions are allowed
- Salesperson can set any stage at any time (no enforced progression)
- Stage changes are logged in the audit log with before/after values
### BR-011: Auto-Promote on Vessel Dimensions
- **Trigger:** Interest updated with yacht length, width, and draft values (all three populated)
- **Condition:** Current `lead_category` is `general_interest`
- **Action:** Auto-promote `lead_category` to `specific_qualified`
- **Override:** Category can be manually changed back
### BR-012: Auto-Stage on EOI Send
- **Trigger:** EOI document generated and sent via Documenso
- **Action:** Set `eoi_status` = `waiting_for_signatures`, advance `pipeline_stage` to `signed_eoi_nda` (if not already at or past that stage)
- **Override:** Stage can be manually changed
### BR-013: Auto-Stage on Manual EOI Upload
- **Trigger:** Manually uploaded signed EOI document
- **Action:** Set `eoi_status` = `signed`, advance `pipeline_stage` to `signed_eoi_nda`
- **Override:** Stage can be manually changed
### BR-014: Interest Archiving
- Archived interests are excluded from: active pipeline views, dashboard metrics, berth occupancy counts, auto-status calculations
- Archived interests remain searchable via archive view and global search
- Archiving an interest that is the sole link to a berth triggers BR-002 (berth status reset check)
---
## 3. EOI & Document Rules
### BR-020: EOI Generation Prerequisites
Generation is blocked unless ALL of:
1. Client has: full name, at least one email contact
2. Interest has: yacht name, yacht length, yacht width, yacht draft
3. At least one berth linked to the interest
4. No existing manual/uploaded EOI documents on the interest (unless user explicitly overrides)
### BR-021: EOI Signing Order
- Sequential 3-party signing: Client (order 1) → Developer (order 2) → Sales/Approver (order 3)
- Each signer receives notification only after the previous signer completes
- If any signer declines, the document is marked as `declined` and the interest's `eoi_status` updates accordingly
### BR-022: EOI Completion
- **Trigger:** All signers have signed (Documenso `DOCUMENT_COMPLETED` event)
- **Actions:**
1. Download completed signed PDF from Documenso
2. Store in MinIO under client's EOI folder
3. Email signed PDF to all three parties
4. Set `eoi_status` = `signed`
5. Timestamp `all_signed_notified_at`
### BR-023: Signature Reminders
- Gated by: per-interest `reminder_enabled` toggle AND system-wide cooldown window
- Send window: configurable hours (e.g., 09:00-16:00 in port's timezone)
- Cooldown: minimum time between reminders for the same document (configurable, default 24h)
- Delivered via Poste.io transactional email
### BR-024: Document Deduplication
- Documenso webhook events deduplicated via `signature_hash` unique index on `document_events`
- Prevents double-processing when webhooks are retried or fallback polling fires simultaneously
### BR-025: Generic Document Signing
- Any document type (not just EOIs) can be sent through Documenso
- Same webhook infrastructure handles all document types
- Document type tag determines which pipeline auto-actions fire (only EOI type triggers pipeline stage changes)
---
## 4. Client & Duplicate Rules
### BR-030: Website Registration Duplicate Check
- **Trigger:** New interest registration from website (`POST /api/public/interests`)
- **Check:** Does the submitted email match any existing `client_contacts` entry where `channel = 'email'`?
- **Match found:** Create new interest under existing client. Do NOT create new client. Notify salesperson of new interest on existing client.
- **No match:** Create new client + new interest. Run fuzzy duplicate check (BR-031).
### BR-031: Fuzzy Duplicate Detection
- **Trigger:** New client created (manual or website)
- **Matching criteria (scored):**
- Same email across any contact entry: score 1.0 (this triggers auto-merge per BR-030)
- Same phone number: score 0.9
- Similar name (Levenshtein distance < 3) + same phone: score 0.8
- Similar name + similar address: score 0.7
- **Threshold:** Score ≥ 0.7 creates a duplicate alert for manual review
- **Below threshold:** No alert
### BR-032: Client Merge
- Surviving client keeps all their own data
- Merged client's data fills in any blank fields on the surviving record
- All of the merged client's interests, notes, files, timeline entries, and relationships transfer to the surviving client
- The merge is logged in `client_merge_log` with full detail of which fields came from which record
- The merged client record is deleted after transfer
- Merge is reversible from the audit log (super admin only)
### BR-033: Notes Edit Window
- Notes (on clients or interests) are editable by the author for 15 minutes after creation
- After 15 minutes, notes are locked (`is_locked = true`)
- Locked notes cannot be edited or deleted (except by super admin via audit log revert)
---
## 5. Financial Rules
### BR-040: Expense Currency Conversion
- All expenses store: original `amount` + `currency`, plus `amount_usd` (converted) and `exchange_rate` used
- Conversion performed at time of creation using current rate from `currency_rates` table
- Primary business currency: USD
- Local currency: ECD (Eastern Caribbean Dollar)
- Common client currencies: EUR, GBP
- If rate unavailable, expense is saved without conversion and flagged for manual rate entry
### BR-041: Invoice Auto-Numbering
- Format: `INV-YYYYMM-###`
- Sequential within each port per month
- Example: INV-202603-001, INV-202603-002
- Counter resets each month
### BR-042: Invoice Net 10 Discount
- When `payment_terms` = `net10`, apply 2% discount on subtotal
- Discount calculated automatically on invoice creation/update
- `discount_pct` = 2, `discount_amount` = subtotal × 0.02
- Configurable: the 2% rate can be changed in system settings
### BR-043: Parent Company Export
- Expense export for parent company includes:
- All expenses in selection with receipt images
- EUR subtotal (all expenses converted to EUR)
- 5% processing fee on the EUR subtotal
- Configurable: the 5% rate can be changed in system settings
### BR-044: Invoice Overdue Detection
- **Trigger:** Daily job checks all invoices with status `sent`
- **Condition:** `due_date` < today
- **Action:** Update status to `overdue`, create notification for invoice creator and admin
### BR-045: Invoice-Expense Integrity
- When creating an invoice from expenses, both the invoice and each linked expense are updated in a single database transaction
- If any part fails, the entire operation rolls back
- Bidirectional link maintained via `invoice_expenses` junction table (no comma-separated IDs)
---
## 6. Notification Rules
### BR-050: Notification Creation
Notifications are created by the system (not users). Events that trigger notifications:
| Event | Recipient(s) | Type |
| ------------------------------------- | ---------------------------------------------- | ------------------- |
| New website registration | Salesperson(s) at port | `new_registration` |
| Reminder due today | Reminder assignee | `reminder_due` |
| Reminder overdue | Reminder assignee + creator | `reminder_overdue` |
| EOI signer completed | Next signer + interest owner | `eoi_signed` |
| All signers completed | All signers + interest owner | `eoi_completed` |
| Duplicate client detected | All users with `clients.create` permission | `duplicate_alert` |
| Invoice overdue | Invoice creator + admin | `invoice_overdue` |
| Berth became available (waiting list) | Waiting list clients (via email) + salesperson | `waiting_list` |
| Background job failure | Super admin | `system_alert` |
| Follow-up reminder auto-created | Interest owner / assigned salesperson | `follow_up_created` |
| Berth tenure expiring | Admin | `tenure_expiring` |
### BR-051: Notification Preferences
- Each user can configure per-notification-type: in-app (on/off), email (on/off)
- System alerts to super admin are always delivered (cannot be suppressed)
- Defaults: all notification types on for both in-app and email
### BR-052: Notification Cooldown
- Notifications of the same type for the same entity are throttled
- Default cooldown: 1 hour (configurable per notification type)
- Prevents notification spam for rapid successive events
---
## 7. Reminder & Calendar Rules
### BR-060: Follow-Up Reminder (Auto-Generated)
- **Trigger:** Interest has `reminder_enabled = true` and `reminder_days` set
- **Check:** Has there been any activity (note, status change, email, call log) on this interest in the last `reminder_days` days?
- **No activity:** Create a CRM reminder "Follow up with {client name}" assigned to the interest's salesperson (marked `auto_generated = true`), and create a notification
- **Activity found:** Reset the timer. Check again in `reminder_days` days.
- **Schedule:** Checked hourly via BullMQ recurring job
- **Google Calendar:** Auto-generated follow-up reminders are NOT automatically pushed to Google Calendar (salesperson can manually toggle sync on each one)
### BR-061: Google Calendar Sync
- **Push:** When a reminder is created/updated with `sync_to_calendar = true`, create/update a Google Calendar event via the API. Store the `google_calendar_event_id` on the reminder record.
- **Pull:** Three sync triggers: (1) Background poll every 30 minutes for all connected users, (2) On user login if calendar is connected, (3) On navigation to any calendar-displaying page (dashboard, reminders, client detail) if last sync > 5 min ago. All syncs fetch upcoming events (next 14 days) and upsert into `google_calendar_cache`.
- **Two-way detection:** On each pull sync, if a CRM-pushed event was deleted or moved in Google Calendar, update the corresponding CRM reminder's `due_at` or mark as `dismissed`.
- **Token refresh:** If access token is expired, use refresh token to obtain a new one. If refresh fails (revoked), mark the connection as disconnected and notify the user.
- **Scope:** Calendar sync is per-user, not per-port. A user's calendar connection works across all ports they have access to.
### BR-062: Reminder Snooze
- **Action:** Salesperson clicks "Snooze" on a reminder → selects snooze duration (1 hour, 4 hours, tomorrow, next week, custom)
- **Effect:** Reminder status changes to `snoozed`, `snoozed_until` is set. Reminder resurfaces as `pending` when the snooze time passes.
- **Google Calendar:** If synced, the Google Calendar event is updated to the new time.
---
## 8. Multi-Tenancy Rules
### BR-070: Port Scoping
- Every database query for port-scoped tables MUST include `WHERE port_id = :currentPortId`
- Middleware extracts current port from session/header and injects into request context
- Drizzle query builder enforced via wrapper functions: `withPortScope(query, portId)`
- Missing port context = 400 error (never fall through to unscoped query)
### BR-071: Super Admin Bypass
- Super admin (`is_super_admin = true`) can:
- Query across all ports (omit port_id filter)
- Access any port's admin panel
- Manage global roles and system settings
- View unified cross-port dashboard
- Still respects audit logging (all super admin actions logged)
### BR-072: Port Switcher & Single-Port Mode
- **Single-port mode:** When only 1 active port exists, all multi-port UI is hidden (port switcher, port columns in tables, port selection on entity forms, port filter dropdowns). Port context set automatically behind the scenes.
- **Multi-port mode:** When 2+ active ports exist, users with roles at multiple ports see a port switcher. Switching port changes all data context immediately.
- Mode is derived dynamically from `SELECT count(*) FROM ports WHERE is_active = true` — no manual toggle needed.
- Current port stored in session, not URL (avoid URL manipulation)
- Port switch logged in audit log
### BR-073: New Port Setup
- Creating a new port auto-inherits: all global roles, all global system settings
- Port-specific overrides start empty (inherit globals until overridden)
- Onboarding wizard guides: port details → berth import → user assignment → branding
---
## 9. Audit & Undo Rules
### BR-080: Audit Log Completeness
- Every create, update, delete, archive, restore, merge, login, logout action is logged
- Update logs include: field name, old value (JSON), new value (JSON)
- No exceptions — even bulk operations log individual changes
### BR-081: Undo Eligibility
- **Undoable:** Field value changes, status changes, berth link/unlink, tag changes, role changes
- **Not undoable:** File deletions (file removed from storage), sent emails, signed documents, completed Documenso flows
- Undo creates a new audit entry with `revert_of` pointing to the original change
- Undo only available to super admin
### BR-082: Audit Retention
- Audit logs are never automatically deleted
- Exportable as CSV for archival
- Future: configurable retention policy (archive logs older than X years)
---
## 10. File Management Rules
### BR-090: Client-Grouped Storage
- Files uploaded via a client context are stored in `/clients/{client_id}/{category}/`
- Category determined by upload context (EOI page → eoi folder, expense → receipts folder)
- Files uploaded without client context go to `/port/{port_id}/general/`
### BR-091: File Deletion
- File deletion removes: MinIO object + `files` table entry
- Deletion logged in audit log (with filename and path for reference)
- Files referenced by other records (e.g., receipt on an expense, PDF on an invoice) cannot be deleted until the reference is removed
### BR-092: S3 Migration
- One-time migration job: scan all existing MinIO files → attempt to match to clients → propose reorganization
- Uncertain matches flagged for manual review
- Migration runs as a BullMQ job with progress tracking
- Rollback: original paths stored in migration log, can undo file moves
---
## 11. Search Rules
### BR-100: Global Search Scope
- Searches: client full name, company name, email, phone, yacht name; berth mooring number, area; interest pipeline stage; document titles; invoice numbers; expense establishment names; note content
- Results scoped to current port (unless super admin doing cross-port search)
- Archived records included in results but marked as archived
- Results ranked by relevance with entity type grouping
### BR-101: Duplicate Search on Create
- Before creating a new client, the create form runs a real-time search to show potential matches
- Uses same fuzzy matching as BR-031 but displays as suggestions, not blocks
- User can choose to proceed with creation or select an existing client
---
## 12. Webhook Rules
### BR-110: Webhook Delivery
- Webhooks fire asynchronously via BullMQ (do not block the triggering operation)
- Retry: 3 attempts with exponential backoff (1s, 10s, 100s)
- After 3 failures: move to dead letter queue, create system alert
- Payload signed with HMAC-SHA256 using the webhook's secret (header: `X-CRM-Signature`)
### BR-111: Webhook Events
Events that can trigger webhooks:
`client.created`, `client.updated`, `interest.created`, `interest.updated`, `interest.stage_changed`, `berth.status_changed`, `document.signed`, `document.completed`, `invoice.created`, `invoice.paid`, `expense.created`, `registration.new`
### BR-112: Webhook Port Scoping
- Webhooks are port-scoped: each webhook belongs to a specific port and only fires for events within that port
- Super admin cross-port actions trigger the webhook for the port where the data resides (not the super admin's "home" port)
- Webhook `port_id` is set on creation and cannot be changed (create a new webhook for a different port)
---
## 13. Scheduled Report Rules
### BR-120: Report Generation
- Reports generated as PDF files via BullMQ scheduled job
- Date range automatically calculated: daily (last 24h), weekly (last 7 days), monthly (last calendar month)
- Generated PDFs stored temporarily in MinIO, attached to email, then cleaned up after 7 days
- If generation fails, retry once, then create system alert
### BR-121: Report Delivery
- Sent via Poste.io to all configured recipients
- Both CRM users (by user ID → email) and external email addresses supported
- Delivery failures logged but do not prevent delivery to other recipients
---
## 14. Form & Credential Rules
### BR-130: Form Expiry
- Form submission tokens have a configurable expiry (default: 7 days)
- **Trigger:** Hourly background job checks `form_submissions` for `status = 'pending'` AND `expires_at < now()`
- **Action:** Set status to `expired`. Expired forms return a friendly "this link has expired" page.
- Salesperson can regenerate a new form link for the same client/interest
### BR-131: Email Account Credential Security
- User email credentials encrypted with AES-256-GCM before storage in `email_accounts.credentials_enc`
- Encryption key stored in environment variable (never in database or source code)
- Credentials decrypted only at point of use (SMTP/IMAP connection), never logged or exposed in API responses
- On credential update, old credentials are overwritten (not versioned)
- Failed IMAP/SMTP connection after 3 consecutive attempts disables the account and notifies the user
### BR-132: Custom Field Validation
- Required custom fields are enforced on entity create/update
- Select-type fields validate against defined options
- Deleting a custom field definition also deletes all corresponding values (cascade)
- Custom fields are included in export operations
### BR-133: Milestone Auto-Population
- When a document of type `eoi` is sent: auto-set `interest.date_eoi_sent` (if not already set)
- When a document of type `eoi` is completed (all signed): auto-set `interest.date_eoi_signed`
- When a document of type `contract` is sent: auto-set `interest.date_contract_sent`
- When a document of type `contract` is completed: auto-set `interest.date_contract_signed`
- All auto-populated dates are overridable by manual edit
### BR-134: Waiting List Priority Notification Order
- When a berth becomes available (status changes to `available` from `under_offer` or `sold`):
- High-priority waiting list clients are notified first
- Within same priority level, notify in position order (first in line first)
- Notifications staggered: 1 hour delay between each client notification to allow first responders to claim
- First client to respond and confirm interest gets the berth linked (salesperson confirms manually)
---
## 15. Document Template Rules
### BR-140: Merge Field Resolution
- When generating a document from a template, all `{{entity.field}}` tokens are resolved against the provided context (client, interest, berth, port)
- Missing required context (e.g., template references `{{berth.mooring_number}}` but no berth provided) → return validation error listing missing data
- Optional merge fields that resolve to null/empty are replaced with empty string (not left as raw tokens)
- Merge fields support nested access: `{{client.contacts.primary_email}}`, `{{interest.berth.mooring_number}}`
### BR-141: Template Management
- Only users with `admin.manage_forms` permission can create, edit, or delete templates
- All users with `documents.create` permission can generate documents from templates
- Deleting a template does NOT delete previously generated documents — they are standalone files
- Templates are port-scoped (each port has its own template library)
### BR-142: Generated Document Storage
- Documents generated from templates are stored as PDF files in MinIO under the client's correspondence folder
- A `documents` table entry is created with `document_type = 'template_generated'` and reference to the source template
- Generated documents appear in the client's file list and document history
---
## 16. Record PDF Export Rules
### BR-150: PDF Branding
- All record export PDFs include the port's logo and primary color from `ports` settings
- If no logo configured, a text-only header with the port name is used
- PDF layout is consistent across all record types (header, content sections, footer with generation timestamp)
### BR-151: PDF Content Scope
- Client PDF: includes all non-archived data — contacts, vessel details, linked interests with current status, recent 20 activity entries, files list (names only, not embedded)
- Berth PDF: includes full spec sheet data, current status, pricing in all configured currencies, linked interests summary, maintenance log summary
- Interest PDF: includes client info, berth info, pipeline stage, EOI/contract status, all milestones, all notes, recent 20 timeline entries
- Custom fields are included in exports for the relevant entity type
### BR-152: PDF Generation
- PDFs generated server-side via @pdfme (same library used for EOI generation)
- Generation runs synchronously for single-record exports (no background job needed — fast enough)
- If generation fails, return error to user (no silent failure)