Quotations — Developer Guide
Mirror of the Opportunities developer guide (assignee scope, notes, domain timeline), kept leaner: no pipeline/board, no related Contact/Company/Lead FKs beyond the required Opportunity link. Prefer copying those patterns over inventing new ones.
Backend layout
| Piece | Path |
|---|---|
| Models | app/Models/Quotation.php, QuotationLine, QuotationNote, QuotationActivity |
| Enums | QuotationStatusEnum, QuotationActivityTypeEnum, DocumentDiscountTypeEnum |
| Support | app/Support/Billing/DocumentTotalsCalculator.php, DocumentDiscountRules.php, DocumentHtmlSanitizer.php, QuotationInvoiceGuard.php |
app/Services/Tenant/QuotationPdfService.php, resources/views/quotations/pdf.blade.php | |
| Service | app/Services/Tenant/QuotationService.php (+ ScopesToAssignee) |
| Controller | app/Http/Controllers/Tenant/Api/V1/QuotationController.php |
| Requests | app/Http/Requests/Tenant/Api/V1/Quotation/* |
| Resources | app/Http/Resources/Tenant/Api/V1/Quotation/* |
| Policy | app/Policies/QuotationPolicy.php |
| Events | app/Events/Quotation*.php |
| Subscriber | app/Listeners/QuotationEventSubscriber.php (audit + assignment notification) |
| Notifications | app/Notifications/Tenant/Quotation/QuotationAssignedNotification.php |
| Link rules | LinkableContact, LinkableCompanyForOpportunity, EligibleOpportunityAssignee |
| Dependency migration | database/migrations/2026_07_30_210006_add_quotations_opportunities_dependency.php (mirrors 2026_07_22_000003_add_meetings_calendar_dependency.php) |
| Tests | tests/Feature/Tenant/Quotation/QuotationTest.php, tests/Feature/Central/Module/QuotationsModuleDependencyTest.php |
Domain notes
- Hard dependency: Quotations declares a required
module_dependenciesrow on Opportunities — Marketplace install is blocked until Opportunities is entitled (unlike Opportunities' own soft-only related FKs). - Status machine lives on
QuotationStatusEnum::allowedTransitions()/canTransitionTo():draft → sent → accepted|rejected|expired.QuotationService::transitionStatus()throwsValidationException(422,statusfield) for disallowed transitions, including re-entering the same status (e.g. sending an already-sent quotation). - Content updates (
PUT) and line sync are draft-only viaQuotation::isEditable(). Assignment remains available after send viaPOST …/assign. POST …/statusmaps target status to permissions:sent→quotations.send,accepted→quotations.accept, otherwisequotations.update.send/accept/convertpolicies are assignee-scoped (same asview/update) unless the actor hasquotations.assignor is superadmin.- Convert to invoice (
QuotationService::convertToInvoice()): soft Invoices entitlement (422 if not installed). One-shot viaQuotationInvoiceGuard(any invoice with thisquotation_id, including soft-deleted, estimate converts, or contract bills) inside a transaction withlockForUpdateon the quotation. Soft-deleted invoices still block; errors tell ops to restore or force-delete. Copies lines into a draftCustomerInvoice, setsquotation_id, auto-accepts if sent, recordsconverted, firesQuotationConverted. - Send is status-only — no outbound email delivery on
POST …/send; PDF download is available separately via Download PDF. - Customer email:
POST …/email(quotations.send, assignee-scoped,throttle:billing-document-email) delivers a branded message with optional PDF attachment viaBillingDocumentMailer+QuotationEmailService. Requires a sent quotation (sent/accepted/rejected/expired; draft → 422). For Sent quotes, rotates an acceptance token and appendsFrontendUrl::quotationAccept(). RecordsQuotationActivityTypeEnum::Emailedand tenant email logs (quotation.emailed). - Customer e-signature v1:
POST …/acceptance-link(quotations.send) rotates token hash + expiry. PublicGET/POST /public/quotations/accept/{token}(throttlequotation-acceptance) shows summary / accepts with signer name+email+IP. Columns:acceptance_token_hash,acceptance_token_expires_at,accepted_at,accepted_by_name,accepted_by_email,acceptance_ip. Activity typessignature_requested/signed. SPA guest page/#/accept/quotations/:token. - Line items are fully replaced on create/update (
QuotationService::syncLines());Quotation::recalculateTotals()delegates toDocumentTotalsCalculatorforsubtotal/discount_total/tax_total/totalfrom persistedQuotationLinerows plus documentline_discount_type. Tax is calculated after line discounts. - Lines use required short
nameplus optional longbody, optionalproduct_id(LinkableProduct: Products entitled,products.viewor superadmin, active non-trashed). Memonotesaccept sanitized HTML viaDocumentHtmlSanitizer. - Shared line discounts use
DocumentDiscountTypeEnum(none,percent,fixed) on the parent asline_discount_type; lines store onlydiscount_value. Validation lives inDocumentDiscountRules. - PDF:
GET …/pdf(quotations.view, assignee-scoped,throttle:quotations-pdf) renders fromresources/views/quotations/pdf.blade.phpviaQuotationPdfService— same branded layout as invoices. Totals are right-aligned; Notes / Terms are full-width blocks below (not a side-by-side table cell) so Dompdf paginates long HTML across all pages. Line-item body HTML is a block-level.line-bodyunder each short pricing row (not a tall table cell). - Assignee scoping via
ScopesToAssigneewithquotations.assign. quotations.force.deleteis not granted to any default role — owner/superadmin only.contact_id/company_idare optional and validated for module entitlement + assignee scope, same as Opportunities.
Permissions
quotations.view | create | update | delete | restore | force.delete | assign | send | accept | convertRoutes use module:quotations then can:quotations.* / policies.
Catalog: slug quotations, category sales, is_default_included = false, is_billable = false, sort_order = 50, version 1.9.0. Registered via DefaultModuleRegistrar migration (migrate-only); 1.3.0 added optional product line picker; 1.3.1 hardens linking + sanitizer; 1.4.0 adds convert-to-invoice (soft Invoices entitlement); 1.4.1 adds convert row locks + soft-delete recovery messaging; 1.5.0 dedicated record pages; 1.5.1 PDF long-notes pagination; 1.5.2 PDF long line-body pagination; 1.6.0 customer email delivery (POST …/email); 1.8.0 Automation triggers; 1.9.0 customer e-signature accept links.
API (tenant)
Base: /api/tenant/v1 — full reference tenant-v1-quotations.md.
Frontend
SPA should mirror Opportunities (table + create/edit page, record page) under the existing AppLayout — do not invent a parallel shell.
| Piece | Path (expected) |
|---|---|
| Page | src/pages/quotations/ |
| Form / detail | create/edit page + record page (Overview, Lines, Notes, Activity) — shared DocumentLinesEditor, DocumentTotalsPanel, RichTextEditor for memo notes |
| Service | quotationService in src/api/services.ts |
| Nav | permission: quotations.view, module: 'quotations' (Sales) |
| Playwright | e2e/pages/quotations.page.ts, e2e/tests/quotations/, npm run test:e2e:quotations |
Tests
php artisan test --compact tests/Feature/Tenant/Quotation/QuotationTest.php tests/Feature/Central/Module/QuotationsModuleDependencyTest.php
npm run typecheck && npm run lint && npm run build
npm run test:e2e:quotationsLogging
- Spatie
LogsActivityonQuotation(log namequotations) - Domain
quotation_activitiestimeline PlatformAuditServiceviaQuotationEventSubscriber
Ask EloSync
Ask EloSync Quotation tools (get_quotation, confirmed status/assign/note writes) are registered in AIToolRegistry and confirmed via PendingAiActionService. Status auth mirrors HTTP POST …/status (Sent→send, Accepted→accept, else update) via QuotationAiSupport::authorizeStatusChange and QuotationService::changeStatus. Assign uses EligibleOpportunityAssignee. See AI tools and AI Quotation triage production readiness.