# Feature: Invoices (quotes + invoices)

## Purpose

Team-scoped quoting and invoicing for jobs/appointments: discount codes, customer payments, provider payout hold-until-invoice-paid, Dashboard profit/outstanding widgets, CSV exports for Xero / QuickBooks / Sage, editable PDF templates, and email delivery with additional recipients.

## Boundaries

- **Owns:** `invoice_settings`, `quotes`, `quote_line_items`, `invoices`, `invoice_line_items`, `invoice_payments`, `discount_codes`, `discount_code_redemptions`, `invoice_document_sends`; Inertia under `Pages/Invoices`, `Pages/Quotes`, `Pages/DiscountCodes`; Blade PDF/email views under `resources/views/invoices`, `resources/views/quotes`, `resources/views/emails/invoices`
- **Does not own:** Jobs appointments, Providers CRM, company business hours, Customers CRM, sundry product catalog
- **Depends on (platform only):** User / Team / auth; Laravel Mail; Dompdf (`barryvdh/laravel-dompdf`)
- **Depends on (other domains):** none required — soft IDs to customers/jobs/appointments/provider payments via DB helpers; soft-reads `customers` / `customer_contacts` emails for recipient pickers; soft-reads `customer_service_sites` (`bedrooms`, `bathrooms`, `square_footage`) when auto-generating quote lines; soft-reads `sundry_products` (`name`, `unit_amount`, `sku`, `unit_label`) for quote/invoice pickers and `sundry_product_id` line resolution (`SoftSundryProducts`). Optional QuickBooks: `class_exists(QuickBooksInvoiceDelivery)` to skip ServDiary mail and queue a QBO send when the team is connected; QuickBooks calls `ApplyExternalInvoicePayment` for webhook payments
- **Platform UI:** Quotes / Invoices / Discount codes nav in `AppLayout.vue`; Dashboard overview via soft `DashboardMetrics` (`forUser` returns null for staff / customer / provider so they do not see other customers’ billing); Company Settings hold flag; optional QuickBooks card on invoice settings and send-via-QuickBooks UI on invoice show. Quote pricing rates on Invoice settings are also soft-read by Jobs when a portal customer creates a job (`SoftPortalJobPricing`).

## Models

| Model | Table | Notes |
|-------|-------|-------|
| `InvoiceSetting` | `invoice_settings` | Prefixes/next numbers; due/valid days; currency; quote pricing (`price_per_bedroom`, `price_per_bathroom`, `price_per_square_foot`); company letterhead; invoice/quote header/intro/footer HTML templates |
| `Quote` | `quotes` | Soft customer/job/service site/discount; statuses draft/sent/accepted/declined/expired/converted |
| `QuoteLineItem` | `quote_line_items` | Quantity × unit. **Intentional snapshot:** generated room/sqft lines copy qty and unit amount at create time; later rate or site changes do not rewrite existing quotes. Soft `sundry_product_id` (no FK) plus copied `description` / `unit_amount` when a catalog product is picked; later catalog edits do not rewrite existing quotes |
| `Invoice` | `invoices` | Soft customer/job/appointment/quote/discount; statuses draft/sent/paid/void/overdue |
| `InvoiceLineItem` | `invoice_line_items` | Same snapshot shape as quote lines, including optional soft `sundry_product_id` |
| `InvoicePayment` | `invoice_payments` | Customer payments; methods `cash`/`card`/`bank`/`other`/`quickbooks` (`quickbooks` is written by the QuickBooks webhook, not the manual form) |
| `DiscountCode` | `discount_codes` | Fixed/%, date range, weekdays, max usage, eligibility, booking type, apply_to |
| `DiscountCodeRedemption` | `discount_code_redemptions` | Soft invoice/quote |
| `InvoiceDocumentSend` | `invoice_document_sends` | Audit of emailed documents; soft `document_id`; **intentional denormalisation** of `to_email` / `cc_emails` / `subject` as a send snapshot |

Jobs soft pricing: `service_jobs.unit_price`, `pricing_unit` (`per_visit`|`flat`|`hourly`).  
Providers: `provider_payments.status` includes `held`; soft `invoice_id`.  
Company settings: `hold_provider_payments_until_invoice_paid`.

Migrations: `2026_08_12_180000_create_invoices_tables.php`, `2026_08_13_003200_add_invoice_document_templates_and_sends.php`, `2026_08_27_204600_add_quote_site_pricing_to_invoices.php`, `2026_08_28_100100_add_sundry_product_id_to_document_line_items.php`.

## Document templates & email

- Templates live on `invoice_settings` (one set per team). Placeholders: `{{document_number}}`, `{{customer_name}}`, `{{total}}`, `{{currency}}`, `{{due_on}}` / `{{valid_until}}`, `{{issued_on}}`.
- PDF via Dompdf Blade views; download at `GET /invoices/{invoice}/pdf` and `GET /quotes/{quote}/pdf`.
- Email (`POST .../email`): PDF attachment + markdown HTML body (amount + brief line summary) using the platform [ALFRED mail theme](transactional-email.md). To required; up to 10 CC addresses (contact checkboxes + free-text).
- Soft recipient options from customer primary email + `customer_contacts`.
- Emailing a **draft** invoice/quote also marks it sent (same side-effects as Mark sent). Mark sent remains status-only without email **unless** QuickBooks is connected — then mark-sent and email both queue a QuickBooks create+send and ServDiary Mail is skipped. See [QuickBooks](quickbooks.md).

## Behaviour highlights

- Quote create (web + API) auto-generates line items from the selected service site when submitted lines are empty or blank: one line per bedroom/bathroom count × the matching invoice-settings rate, plus square footage × `price_per_square_foot`. Zero counts or zero rates are skipped. If no site lines are produced, falls back to seeding a single line from the job `unit_price`. Explicit lines (including sundry product picks) are not overwritten. Generation is **create-only** (quote edit does not recompute).
- Portal job create (Jobs domain) uses the same room/sqft rates to set `service_jobs.unit_price` when the customer cannot enter a price. Staff set the rates at **Invoices → Settings** (`GET /invoice-settings`).
- Quote/invoice create and edit UIs pass active `sundryProducts` options (empty when the Products table is missing). Picking a product appends a line with snapshot `description` / `unit_amount` / `sundry_product_id`. Qty, price, and description remain editable; submitted values win over the catalog. Missing description/amount are filled from the catalog when `sundry_product_id` is sent. Inactive catalog rows are omitted from pickers.
- Quote accept → convert copies lines (including `sundry_product_id`) to draft invoice (`accepted` only).
- One invoice per appointment (generate from completed appointments).
- Draft/sent invoice edit UI includes discount code selection (same options as create).
- Invoice paid → release held provider payments (including payments applied by QuickBooks webhooks via `ApplyExternalInvoicePayment`).
- Exports: `GET /invoices/export?format=csv|xero|quickbooks|sage`.
- Profit = paid invoice totals − paid provider payouts (tips excluded).

## Permissions

Editor+ create/read/update; owner/admin delete; staff/customer/provider forbidden. Email uses update ability.

## Tests

`laravel/tests/Feature/Domains/Invoices/` — includes `InvoiceDocumentEmailTest` (Mail::fake, PDF download, template settings, staff denial), `QuoteSitePricingTest` (room/sqft auto-generation, skip zeros, explicit-line override, job fallback, API create), and sundry-product snapshot coverage in `InvoiceWebTest`. External/webhook payments use `ApplyExternalInvoicePayment` (same paid/held-payout rules as recording a payment in the UI).
