PSA/docs/billing/quoting-system.md
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

620 lines
24 KiB
Markdown

# Quoting System
The Quoting System enables MSPs to create, send, and manage pricing proposals for clients directly within Alga PSA. Quotes integrate with the existing billing infrastructure — service catalog, tax, discounts, document templates, PDF generation, email, and client portal — and can be converted into contracts and/or invoices upon acceptance.
## Table of Contents
1. [Overview](#overview)
2. [Quote Lifecycle](#quote-lifecycle)
3. [Data Model](#data-model)
4. [Line Items](#line-items)
5. [Tax and Discounts](#tax-and-discounts)
6. [Totals Calculation](#totals-calculation)
7. [Versioning](#versioning)
8. [Document Templates and PDF Generation](#document-templates-and-pdf-generation)
9. [Email Sending](#email-sending)
10. [Client Portal](#client-portal)
11. [Conversion Workflows](#conversion-workflows)
12. [Approval Workflow](#approval-workflow)
13. [Quote Templates](#quote-templates)
14. [Auto-Expiration](#auto-expiration)
15. [Permissions and Security](#permissions-and-security)
16. [Feature Flags](#feature-flags)
17. [Key Files](#key-files)
---
## Overview
Quotes are pre-sale pricing proposals that MSPs create for clients. A quote contains line items (from the service catalog or custom entries), optional items the client can toggle, discounts, and tax calculations. Once accepted by the client, a quote can be converted into contracts (for recurring services) and invoices (for one-time charges).
Key capabilities:
- Full CRUD with tenant isolation
- Sequential numbering (Q-0001, Q-0002, ...) via `SharedNumberingService`
- Status-driven lifecycle with enforced transitions
- Reusable business templates (predefined line item configurations)
- Professional PDF generation using the shared AST template engine
- Client portal integration for self-service review and acceptance
- Atomic conversion to contracts and/or invoices
- Optional internal approval workflow (configurable per tenant)
- Activity audit trail on all state changes
---
## Quote Lifecycle
Quotes follow a status-driven lifecycle with enforced transitions.
### Statuses
| Status | Description |
|--------|-------------|
| `draft` | Being composed, not yet visible to client |
| `pending_approval` | Submitted for internal approval (optional workflow) |
| `approved` | Approved internally, ready to send |
| `sent` | Delivered to client, awaiting response |
| `accepted` | Client accepted the proposal |
| `rejected` | Client declined the proposal |
| `expired` | Past `valid_until` date without response |
| `converted` | Successfully converted to contract/invoice |
| `cancelled` | Manually cancelled by MSP |
| `superseded` | Replaced by a newer revision |
### Status Transitions
```
draft ──────────► sent (if approval disabled)
draft ──────────► pending_approval ──► approved ──► sent (if approval enabled)
draft ──────────► cancelled
sent ───────────► accepted / rejected / expired / cancelled
accepted ───────► converted
sent/rejected ──► superseded (when a new revision is created)
```
### Auto-Expiration
Quotes with status `sent` are automatically expired when `valid_until < today`:
- **On access**: checked every time a quote is fetched via `getById`
- **Background job**: scheduled job bulk-expires quotes across all tenants
---
## Data Model
### `quotes` Table
Primary entity table with Citus-compatible composite keys.
| Column | Type | Description |
|--------|------|-------------|
| `tenant` | UUID | Tenant isolation key |
| `quote_id` | UUID | Primary identifier |
| `quote_number` | TEXT | Human-readable number (Q-0001) |
| `client_id` | UUID | Associated client |
| `contact_id` | UUID | Primary contact |
| `title` | TEXT | Quote title |
| `description` | TEXT | Scope of work / description |
| `status` | TEXT | Current lifecycle status |
| `is_template` | BOOLEAN | If true, this is a reusable business template |
| `quote_date` | DATE | Date of the quote |
| `valid_until` | DATE | Expiration date |
| `subtotal` | BIGINT | Sum of line item amounts (cents) |
| `discount_total` | BIGINT | Sum of discount amounts (cents) |
| `tax` | BIGINT | Sum of tax amounts (cents) |
| `total_amount` | BIGINT | Final total (cents) |
| `currency_code` | TEXT | Currency (defaults to tenant currency) |
| `po_number` | TEXT | Client PO reference |
| `internal_notes` | TEXT | Internal notes (not visible to client) |
| `client_notes` | TEXT | Client-facing notes |
| `terms_and_conditions` | TEXT | T&C text |
| `version` | INT | Version number (1-based) |
| `parent_quote_id` | UUID | Links revision chain |
| `opportunity_id` | UUID | Optional CRM link |
| `sent_at` | TIMESTAMP | When first sent |
| `accepted_at` | TIMESTAMP | When accepted |
| `accepted_by` | UUID | Portal user who accepted |
| `rejected_at` | TIMESTAMP | When rejected |
| `rejection_reason` | TEXT | Client's rejection comment |
| `viewed_at` | TIMESTAMP | When client first viewed |
| `converted_contract_id` | UUID | Link to converted contract |
| `converted_invoice_id` | UUID | Link to converted invoice |
| `template_id` | UUID | Document template override |
Indexes: `(tenant, client_id)`, `(tenant, status)`, `(tenant, quote_number)`, `(tenant, parent_quote_id)`
### `quote_items` Table
Line items modeled on `invoice_charges` with quote-specific fields.
| Column | Type | Description |
|--------|------|-------------|
| `tenant` | UUID | Tenant isolation key |
| `quote_item_id` | UUID | Primary identifier |
| `quote_id` | UUID | Parent quote |
| `service_id` | UUID | Service catalog reference (nullable for custom items) |
| `service_name` | TEXT | Denormalized service name |
| `service_sku` | TEXT | Denormalized SKU |
| `description` | TEXT | Line item description |
| `quantity` | BIGINT | Item quantity |
| `unit_price` | BIGINT | Price per unit (cents) |
| `total_price` | BIGINT | quantity * unit_price (cents) |
| `unit_of_measure` | TEXT | e.g., "hour", "user", "unit" |
| `billing_method` | TEXT | fixed, hourly, usage, per_unit |
| `is_optional` | BOOLEAN | Client can toggle on/off |
| `is_selected` | BOOLEAN | Client's current selection (default true) |
| `is_recurring` | BOOLEAN | Recurring vs one-time |
| `billing_frequency` | TEXT | monthly, quarterly, etc. |
| `is_taxable` | BOOLEAN | Subject to tax |
| `tax_region` | TEXT | Tax jurisdiction |
| `tax_rate` | DECIMAL | Applied tax rate |
| `tax_amount` | BIGINT | Calculated tax (cents) |
| `is_discount` | BOOLEAN | If true, this is a discount line |
| `discount_type` | TEXT | percentage or fixed |
| `discount_percentage` | DECIMAL | For percentage discounts |
| `applies_to_item_id` | UUID | Scoped to specific item |
| `applies_to_service_id` | UUID | Scoped to specific service |
| `phase` | TEXT | Phase/section label for grouping |
| `display_order` | INT | Sort position |
### `quote_activities` Table
Audit trail for all quote state changes.
| Column | Type | Description |
|--------|------|-------------|
| `tenant` | UUID | Tenant isolation key |
| `activity_id` | UUID | Primary identifier |
| `quote_id` | UUID | Parent quote |
| `activity_type` | TEXT | created, updated, sent, accepted, etc. |
| `description` | TEXT | Human-readable description |
| `performed_by` | UUID | User who performed action |
| `metadata` | JSONB | Additional context |
| `created_at` | TIMESTAMP | When activity occurred |
### Document Template Tables
| Table | Purpose |
|-------|---------|
| `quote_document_templates` | Tenant-specific PDF rendering templates (AST JSON) |
| `standard_quote_document_templates` | System-wide default templates |
| `quote_document_template_assignments` | Tenant-to-template mapping |
---
## Line Items
### Service Catalog Integration
When adding an item from the service catalog, the following fields are denormalized from the catalog:
- `service_name`, `service_sku`, `unit_price`, `unit_of_measure`, `billing_method`
Rate overrides are supported — the `unit_price` on the quote item can differ from the catalog default.
### Custom Line Items
Items without a `service_id` are custom/manual entries where the user provides all fields directly.
### Optional Items
Items marked `is_optional = true` can be toggled by the client in the portal. The `is_selected` field tracks the client's current selection. Optional items with `is_selected = false` are excluded from totals and from conversion.
### Recurring vs One-Time
The `is_recurring` flag and `billing_frequency` determine how items are handled during conversion:
- **Recurring items** → contract lines
- **One-time items** → invoice charges
### Phase Grouping
The `phase` text field groups items visually in the UI and PDF. Items with the same phase label are rendered together under a section header.
### Ordering
`display_order` controls the visual sort order. Reorder operations update all items in batch.
---
## Tax and Discounts
### Tax Calculation
Tax integrates with the existing `taxService.calculateTax()`:
- Called per line item based on `tax_region` and item taxability
- Honors client tax exemptions
- Supports reverse-charge scenarios
- `tax_source` field tracks whether tax was calculated internally or externally
### Discounts
Discounts are represented as separate line items with `is_discount = true`:
| Scope | Field | Behavior |
|-------|-------|----------|
| Item-scoped | `applies_to_item_id` | Applies to a specific line item |
| Service-scoped | `applies_to_service_id` | Applies to all items of a given service |
| Quote-level | Neither set | Applies to the quote total |
Discount types:
- **Percentage**: uses `discount_percentage` field
- **Fixed**: uses `unit_price` as the fixed discount amount
---
## Totals Calculation
Totals are recalculated on every item mutation (add, update, remove, reorder).
```
subtotal = SUM(total_price) for non-discount, selected items
discount_total = SUM(discount amounts) for applicable discount lines
tax = SUM(tax_amount) for selected, taxable items
total_amount = subtotal - discount_total + tax
```
Items with `is_optional = true` and `is_selected = false` are excluded from all totals.
Service: `packages/billing/src/services/quoteCalculationService.ts`
---
## Versioning
When a sent or rejected quote needs changes, a **revision** is created rather than editing in place:
1. A new `quotes` row is created with `version = prev + 1`
2. `parent_quote_id` links to the original quote (first version)
3. All `quote_items` are cloned to the new version
4. The old version's status is set to `superseded`
5. The same `quote_number` is reused across versions (displayed as "Q-0042 v2")
The version history chain can be queried via `parent_quote_id` to display all versions with navigation in the UI.
---
## Document Templates and PDF Generation
### Template Architecture
Quote document templates use the same AST (Abstract Syntax Tree) engine as invoice templates:
- **AST schema**: Extended to support quote-specific node types
- **Value bindings**: `quoteNumber`, `quoteDate`, `validUntil`, `status`, `scope`, totals, T&C, version
- **Collection bindings**: `lineItems` (with optional/recurring flags), phases
- **Standard templates**: `standard-quote-default` (clean layout) and `standard-quote-detailed` (full branding, phase grouping)
### PDF Generation Flow
```
Fetch quote → mapDbQuoteToViewModel → Evaluate AST → Render HTML → Puppeteer → PDF buffer → Store in file storage
```
### Preview
In-browser preview renders the template without Puppeteer, using the same AST evaluation and React rendering pipeline as invoices.
### Template Selection Priority
1. Per-quote `template_id` override
2. Tenant default quote template (via `quote_template_assignments`)
3. Fallback to `standard-quote-default`
### Key Files
- Adapter: `packages/billing/src/lib/adapters/quoteAdapters.ts`
- Bindings: `packages/billing/src/lib/quote-template-ast/bindings.ts`
- Standard templates: `packages/billing/src/lib/quote-template-ast/standardTemplates.ts`
- Template selection: `packages/billing/src/lib/quote-template-ast/templateSelection.ts`
- PDF service: `packages/billing/src/services/pdfGenerationService.ts`
---
## Email Sending
### Send Quote Action
1. Validates quote is in sendable state (draft or approved)
2. Generates PDF
3. Sends email to one or more addresses (defaults to primary contact, supports additional recipients)
4. Updates `sent_at` timestamp, status → `sent`
5. Logs activity
### Email Templates
| Template | Trigger |
|----------|---------|
| Quote Sent | When MSP sends quote to client |
| Quote Reminder | For quotes approaching `valid_until` |
| Quote Accepted | Sent to MSP when client accepts |
### Email Logging
All sent emails are logged in `email_sending_logs` with `entity_type = 'quote'`, including delivery metadata.
Templates: `packages/billing/src/lib/quote-email-templates.ts`
---
## Client Portal
### Quotes Tab
A "Quotes" tab is added to the client portal billing overview, accessible to all client portal users with billing permissions.
- Quote list: DataTable with Quote #, Title, Amount, Status, Date
- Status filter
- Row click navigates to quote detail
### Quote Detail
- Full line items display with optional item toggles
- Toggling optional items recalculates totals client-side
- Optional item selections are persisted server-side (survives page reload)
- Client-friendly status text ("Awaiting Your Response", "Accepted", etc.)
- Terms and conditions display
### Accept / Reject
- **Accept**: Persists client's optional item selections, sets `accepted_at` and `accepted_by` (portal user), status → `accepted`. The MSP then reviews the client's configuration choices before converting.
- **Reject**: Opens comment field, sets `rejected_at` and `rejection_reason`, status → `rejected`
### Viewed Tracking
`viewed_at` is set on the client's first portal view of the quote (deduplicated — only the first view is recorded). Visible to the MSP on the quote detail page.
### Key Files
- Portal actions: `packages/client-portal/src/client-portal-actions/client-billing.ts`
- Portal quote list: `packages/client-portal/src/components/billing/QuotesTab.tsx`
- Portal quote detail: `packages/client-portal/src/components/billing/QuoteDetailPage.tsx`
---
## Conversion Workflows
Accepted quotes can be converted into contracts, invoices, or both.
### Quote to Contract
For each **recurring** quote item (where `is_selected = true`):
1. Creates a new contract (status: draft)
2. Creates `contract_line` entries matching billing method
3. Creates service configurations (`_fixed_config`, `_hourly_config`, `_usage_config`)
4. Creates `client_contracts` assignment
5. Sets `quote.converted_contract_id`
### Quote to Invoice
For each **one-time** quote item (where `is_selected = true`):
1. Creates a new invoice (status: draft, `is_manual: true`)
2. Creates `invoice_charge` entries with matching fields
3. Calculates invoice totals
4. Sets `quote.converted_invoice_id`
### Combined Conversion
For quotes with both recurring and one-time items:
1. Contract conversion runs for recurring items
2. Invoice conversion runs for one-time items
3. Both run in a single transaction (atomic — rolls back on failure)
4. Both `converted_contract_id` and `converted_invoice_id` are set
5. Status → `converted`
### Conversion Preview
Before converting, a preview dialog shows:
- Which items map to contract lines vs invoice charges
- Contract details (name, billing config)
- Invoice details
- User confirms before proceeding
### Post-Conversion Links
- Quote detail shows links to the created contract/invoice
- Contract detail page links back to the source quote
Service: `packages/billing/src/services/quoteConversionService.ts`
---
## Approval Workflow
An optional internal approval workflow can be enabled per tenant.
### Flow
```
Draft → Submit for Approval → Pending Approval → Approve → Approved → Send
→ Request Changes → Draft
```
### Configuration
- Approval can be enabled/disabled per tenant via billing settings
- When disabled, drafts can be sent directly
- When enabled, drafts must go through approval before sending
### Permission
A dedicated `quotes:approve` permission controls who can approve quotes, separate from the general `billing:update` permission.
### Approval Dashboard
An approval dashboard (following the `ManagerApprovalDashboard` pattern) lists quotes pending approval with approve/reject actions and optional comments.
### Key Files
- Settings: `packages/billing/src/lib/quoteApprovalSettings.ts`
- Dashboard: `packages/billing/src/components/billing-dashboard/quotes/QuoteApprovalDashboard.tsx`
- Route: `/msp/quote-approvals`
---
## Quote Templates
There are two distinct concepts of "templates" in the quoting system:
### Business Templates (Reusable Configurations)
Quotes with `is_template = true` serve as reusable configurations with predefined line items. They follow the contract template pattern:
- Not numbered, don't go through the status lifecycle
- "Create from Template" copies a template's items into a new draft quote
- "Save as Template" creates a business template from an existing quote
- Templates store predefined line items, default terms, and description/scope
### Document Templates (PDF Rendering)
Stored in `quote_document_templates` and rendered via the shared AST engine. These define how a quote looks as a PDF/HTML document, not what it contains. See [Document Templates and PDF Generation](#document-templates-and-pdf-generation).
---
## Auto-Expiration
Quotes past their `valid_until` date are expired through two mechanisms:
### On-Access Check
Every call to `getById` checks if `valid_until < today` and status is `sent`. If so, the quote is automatically set to `expired` before being returned.
### Background Job
A scheduled job runs periodically to bulk-expire qualifying quotes across all tenants, complementing the on-access check for quotes that aren't actively being viewed.
Handler: `server/src/lib/jobs/handlers/expireQuotesHandler.ts`
---
## Permissions and Security
| Permission | Scope |
|------------|-------|
| `billing:read` | View quotes |
| `billing:create` | Create quotes |
| `billing:update` | Edit quotes |
| `billing:delete` | Delete/archive quotes |
| `quotes:approve` | Approve/reject quotes in approval workflow |
All server actions are wrapped with `withAuth()`. All database queries are tenant-scoped.
Client portal users with billing permissions can:
- View their own quotes
- Toggle optional items
- Accept or reject quotes
Client portal users **cannot**:
- Edit quote content
- Manually change quote status
- View other clients' quotes
---
## Feature Flags
The quoting system is gated behind feature flags for gradual rollout. The feature flag controls visibility of:
- Quotes tab in the billing dashboard
- Quote-related menu items in the sidebar
- Quote approval dashboard route
Runtime flag: defined in `packages/core/src/lib/featureFlagRuntime.ts`
---
## Key Files
### Types and Schemas
| File | Purpose |
|------|---------|
| `packages/types/src/interfaces/quote.interfaces.ts` | Core TypeScript interfaces (`IQuote`, `IQuoteItem`, `IQuoteActivity`, `QuoteStatus`) |
| `packages/billing/src/schemas/quoteSchemas.ts` | Zod validation schemas and status transition rules |
### Models (Data Access)
| File | Purpose |
|------|---------|
| `packages/billing/src/models/quote.ts` | Quote CRUD, listing, auto-expiration |
| `packages/billing/src/models/quoteItem.ts` | Line item CRUD, reorder, service catalog lookup |
| `packages/billing/src/models/quoteActivity.ts` | Audit trail entries |
| `packages/billing/src/models/quoteDocumentTemplate.ts` | Document template CRUD |
### Server Actions
| File | Purpose |
|------|---------|
| `packages/billing/src/actions/quoteActions.ts` | All quote server actions (CRUD, send, convert, approve, duplicate) |
| `packages/billing/src/actions/quoteDocumentTemplates.ts` | Document template actions |
| `packages/billing/src/actions/quoteTemplatePreview.ts` | Template preview actions |
### Services
| File | Purpose |
|------|---------|
| `packages/billing/src/services/quoteCalculationService.ts` | Totals recalculation |
| `packages/billing/src/services/quoteConversionService.ts` | Quote to contract/invoice conversion |
| `packages/billing/src/services/pdfGenerationService.ts` | PDF generation (invoices, quotes, documents) |
### UI Components (MSP)
| File | Purpose |
|------|---------|
| `packages/billing/src/components/billing-dashboard/quotes/QuotesTab.tsx` | Quote list with filters |
| `packages/billing/src/components/billing-dashboard/quotes/QuoteForm.tsx` | Create/edit form |
| `packages/billing/src/components/billing-dashboard/quotes/QuoteDetail.tsx` | Read-only detail view with actions |
| `packages/billing/src/components/billing-dashboard/quotes/QuoteLineItemsEditor.tsx` | Line item management |
| `packages/billing/src/components/billing-dashboard/quotes/QuotePreviewPanel.tsx` | In-browser PDF preview |
| `packages/billing/src/components/billing-dashboard/quotes/QuoteStatusBadge.tsx` | Status badge component |
| `packages/billing/src/components/billing-dashboard/quotes/QuoteConversionDialog.tsx` | Conversion preview and confirmation |
| `packages/billing/src/components/billing-dashboard/quotes/QuoteApprovalDashboard.tsx` | Internal approval dashboard |
| `packages/billing/src/components/billing-dashboard/quotes/QuoteDocumentTemplateEditor.tsx` | Document template editor |
### UI Components (Client Portal)
| File | Purpose |
|------|---------|
| `packages/client-portal/src/components/billing/QuotesTab.tsx` | Client quote list |
| `packages/client-portal/src/components/billing/QuoteDetailPage.tsx` | Client quote detail with accept/reject |
| `packages/client-portal/src/client-portal-actions/client-billing.ts` | Portal server actions |
### Template AST
| File | Purpose |
|------|---------|
| `packages/billing/src/lib/quote-template-ast/bindings.ts` | Quote-specific AST bindings |
| `packages/billing/src/lib/quote-template-ast/standardTemplates.ts` | Default and detailed template ASTs |
| `packages/billing/src/lib/quote-template-ast/templateSelection.ts` | Template resolution logic |
| `packages/billing/src/lib/adapters/quoteAdapters.ts` | DB → ViewModel mapping |
### Database
| File | Purpose |
|------|---------|
| `server/migrations/20260320100000_create_quotes_tables.cjs` | Core tables migration |
| `server/migrations/20260320101000_add_tax_source_to_quotes.cjs` | Tax source field |
| `server/migrations/20260320102000_create_quote_document_templates.cjs` | Document template tables |
| `server/migrations/20260320103000_create_standard_quote_document_templates.cjs` | Standard document templates |
| `server/migrations/20260320104000_create_quote_document_template_assignments.cjs` | Template assignment table |
| `server/migrations/20260320105000_add_quote_approval_permission.cjs` | `quotes:approve` permission |
| `server/migrations/20260320160000_expand_quote_status_check_for_approval.cjs` | Adds `pending_approval` / `approved` statuses |
| `server/migrations/20260320170000_allow_quote_number_revisions.cjs` | Quote number reuse across revisions |
| `server/migrations/20260320180000_add_quote_to_document_associations_entity_type.cjs` | Quote-document associations |
| `server/migrations/20260409120000_add_cost_to_quote_items.cjs` | Cost tracking on quote items |
| `server/migrations/20260415120000_add_location_to_quote_items.cjs` | Location tracking on quote items |
| `server/migrations/20260416120000_add_by_location_standard_quote_template.cjs` | By-location standard template |
| `server/seeds/dev/13_next_number.cjs` | QUOTE entity type seed |
### Routes
| Route | Purpose |
|-------|---------|
| `/msp/billing?tab=quotes` | Billing dashboard quotes tab (quote detail rendered in-page, not via a dedicated route) |
| `/msp/quote-approvals` | Approval dashboard |
| `/msp/quote-document-templates` | Document template editor |
| `/client-portal/billing/quotes/[quoteId]` | Client portal quote detail |
### Tests
| Location | Coverage |
|----------|----------|
| `packages/billing/tests/quote/` | Unit tests (schemas, calculations, templates, PDF, emails) |
| `server/src/test/infrastructure/billing/quotes/` | Infrastructure tests (CRUD, conversion, expiration) |
| `packages/client-portal/src/client-portal-actions/` | Client portal action tests |