Sales
The sales module (app-modules/sales) holds customers, the CRM and the documents of a sale. The
staff view is from quote request to paid invoice. The exhaustive reference is
app-modules/sales/ARCHITECTURE.md, with sections on states, lifecycle events and each service.
The pipeline
Section titled “The pipeline”QuoteRequest -> Quote -> SalesOrder -> Invoice -> CreditNote | +-> PurchaseOrder (one per supplier) -> shipmentsA document is always made by its service, never by hand-building a model:
| Service | Makes |
|---|---|
QuoteBuilder |
A quote: forCustomer(), fromQuoteRequest(), copyOf() |
SalesOrderBuilder |
An order: fromQuote() or forCustomer() |
InvoiceBuilder |
The invoice’s draft. Only ConfirmSalesOrder calls it. |
PurchaseOrderBuilder |
Draft POs from an order’s lines, one per supplier |
CreditNotes |
A credit note, made and issued in one step |
Each step copies forward. A later document has its own lines (LineCopier, keeping a link such as
quote_item_id) and its own copies of the addresses, currency and frozen exchange rate. Changing a
customer’s address changes no issued document.
What every document shares
Section titled “What every document shares”src/Documents/ holds the common parts:
DocumentColumnsandDocumentLineColumnsdefine the shared columns.DocumentLineis the abstract base of all five line tables.Concerns\HasDocumentLines,HasDocumentNumber,HasDocumentAddresses,HasDocumentFilesandEditsCatalogLinesare the shared behaviour.Pricing\DocumentTotalsCalculatoris pure and works in whole pence. A line’s tax rounds half up per line, and the document’s is the sum. Saving a line recalculates its document’s stored totals.Tax\TaxRateResolversuggests a default rate when a line is created. The calculator only applies the rate stored on the line.Pricing\CatalogLinePricerasks catalog’sProductPricer(pricing) and turns the answer into a line. Quote required becomesawaiting_priceat zero.Lines\GeneratedChargeskeeps setup charges as lines owned by their product line (owner_line_id,charge_key), so they follow the price unlessprice_overridden.
The rule that catches people: a line refuses to change once its document’s linesAreLocked(), by
throwing LockedDocumentException.
State machines
Section titled “State machines”Statuses use spatie/laravel-model-states, under src/States/<Document>/. Each abstract state class
declares its whole graph once, and an unlisted transition throws.
Quote (QuoteState)
| From | To | Transition |
|---|---|---|
| Draft | Sent | SendQuote: needs a line, none awaiting a price |
| Sent | Accepted | AcceptQuote |
| Sent | Lost | LoseQuote, with a reason |
| Draft, Sent | Cancelled | CancelQuote |
| Sent, Accepted, Lost | Draft | ReviseQuote: the next revision, same number |
| Accepted | Converted | SalesOrderBuilder::fromQuote() |
| Converted | Accepted | Its draft order was deleted |
Sales order (SalesOrderState)
| From | To | Transition |
|---|---|---|
| Draft | Confirmed | ConfirmSalesOrder: issues its invoice in the same transaction |
| Confirmed | In Production | StartProduction |
| Confirmed, In Production | Partially Shipped | PartlyShipSalesOrder |
| Confirmed, In Production, Partially Shipped | Shipped | ShipSalesOrder |
| Shipped | Completed | CompleteSalesOrder |
| Confirmed, In Production, Partially Shipped | On Hold | HoldSalesOrder |
| On Hold | Any working state | ReleaseSalesOrder |
| Draft, Confirmed, In Production, On Hold | Cancelled | CancelSalesOrder |
The working states can also move back down, for example when a PO is cancelled or a payment removed.
Staff don’t set these working states. Services\SalesOrderProgress::sync() works them out from
the order’s POs, shipments and payments, and is called from the POs, shipping, payments and credit
notes. Add a new thing that affects progress by calling it, not by transitioning directly.
Purchase order (PurchaseOrderState)
| From | To | Transition |
|---|---|---|
| Draft | Sent | SendPurchaseOrder: needs every product line’s proof settled |
| Sent | Acknowledged | AcknowledgePurchaseOrder |
| Sent, Acknowledged | Cancelled | CancelPurchaseOrder: refused once shipped or billed |
Shipping (shipping_status, PurchaseOrderShipping) and the supplier’s invoice
(PurchaseOrderBilling) are tracked apart from this.
Invoice (InvoiceState) and credit note (CreditNoteState) are the same: Draft to Issued
to Voided. How much is paid is not a state on an invoice: it is payment_status
(Enums\PaymentStatus), worked out from the total, payments and credits. Overdue is its own stamp,
set by sales:mark-overdue-invoices.
Artwork proof (ArtworkProofState) is the gate on production: Requested, Sent, Changes Requested,
Approved or Not Required. isSettled() is what SendPurchaseOrder checks.
Transitions and events
Section titled “Transitions and events”Every document transition extends Documents\Transitions\DocumentTransition:
abstract class DocumentTransition extends DefaultTransition{ protected function apply(): void {} // set fields, assign its number, copy lines protected function event(): ?SalesDocumentEvent {} // the moment to announce}handle() runs everything in one database transaction, so a failure leaves nothing half done and
burns no number. Events extend Events\SalesEvent, which is ShouldDispatchAfterCommit, so
listeners only hear about what really happened.
The line between the two, as the class puts it: correctness belongs in a transition, preferences
belong in automation rules listening to its events. A
follow-up task is a rule, not code in SendQuote.
Numbering
Section titled “Numbering”Documents\Numbering\DocumentNumberAllocator hands out numbers from document_sequences, one
sequence per Enums\DocumentType. There is one sequence per type across all sites, not one per
site.
- It locks the row, and
next()must run inside the transaction that issues the document. A rollback rolls the increment back, so numbers are gap-free, as VAT invoices need. HasDocumentNumber::assignNumber()takes the number and keeps an existing one. Each document numbers itself in the transition that first matters:SendQuote,ConfirmSalesOrder,SendPurchaseOrder,IssueInvoiceandIssueCreditNote. A revised quote keeps its number.DocumentType::formatNumber()is the printed form: the number zero-padded to 6 digits, no prefix. This is the one place to change it.
ddev artisan sales:sequence # list themddev artisan sales:sequence invoice --next=27125 # move one forwardstartAt() refuses to move a sequence backwards, as that could re-issue a number.
PDFs and sending
Section titled “PDFs and sending”src/Documents/Output/ has a pair for each printed document: QuotePdf with QuoteDocument, and the
same for invoices, purchase orders and credit notes. Sales orders have no printed document.
*Documentis the plain data the view reads.*Pdfhashtml()for previews,pdf()andcontent()for attachments, throughspatie/laravel-pdf.DocumentBrandingcarries the selling site’s name, logo, colours and legal line, so a document brands by its ownsite_id, not by the current host.- Chromium renders offline. The
sales-documentsdriver (QuotePdf::DRIVER, bound inSalesServiceProvider) sends all traffic to a closed port, so staff-written page content can’t make the server fetch URLs. Everything a document shows is embedded as a data URI. DocumentViewsfinds a quote page’s view in the site’s theme, then its parent, then thesales::version. It uses a view namespace per theme, as no site theme is active in the admin (see themes).
Sending goes through QuoteSender, InvoiceSender, PurchaseOrderSender, CreditNoteSender and
ProofSender, which record a DocumentSend.
The documents disk
Section titled “The documents disk”Files on documents, proof versions and quote artwork go on the disk named by
config('sales.documents_disk'), from DOCUMENTS_DISK: local in development, and the documents
S3 disk in production (config/filesystems.php). Each media row records its own disk, so changing
the setting only affects files added afterwards.
These files are private. Documents\DocumentFileDownload::for($media) is the way out, and only hand
it a file the person may have. It is always a download, never inline, so an uploaded SVG or PDF
can’t run on the site’s origin. On S3 it redirects to a signed link good for 5 minutes, so a large
file doesn’t hold a PHP worker. Authorize first, in the controller, then call it.
Who sees what
Section titled “Who sees what”Staff access is capabilities and policies (authorization),
plus Authorization\CustomerVisibility: customers.view_all sees everything, others see their own
customers, and the unowned pool with quote_requests.view_pool. It is used by admin queries and
policies alike, and is never a global scope, so jobs, automations and imports see everything.
Customers themselves use a separate panel: see customer
panel.
The legacy import
Section titled “The legacy import”sales:import-legacy {steps?*} {--database=} copies customers and documents from the legacy
database over the smooth connection (LEGACY_DB_DATABASE). Steps run in order, each a class in
LegacyImport/Steps/: staff, customers, quotes, orders, purchase-orders, history,
attachments.
- Rows are written with the query builder, and the activity log is off, so no events, automations or emails fire.
LegacyRecordskeepslegacy_records. Each step skips rows already recorded, so a run can be stopped and resumed, and a re-run picks up only rows added since. Rows edited since are not re-read.- An imported proforma becomes a sales order and an invoice sharing its number, so set the sequences
above the legacy maximums with
sales:sequence. attachmentscopies files fromlegacy.attachments_directoryand lists what’s missing instorage/app/legacy-import/missing-attachments.txt.
The storefront quote cart is provisional
Section titled “The storefront quote cart is provisional”QuoteRequest and QuoteRequestItem are real and stay: a submitted request is the start of the
pipeline, and QuoteRequestState guards it (Active, Submitted, Abandoned, Converted). The
SubmitQuoteRequest transition upserts the customer and fires QuoteRequestSubmitted.
The storefront cart around it is provisional and expected to be replaced: Services\QuoteCart, QuoteCartController,
the quote-cart.* routes in routes/web.php, and the Alpine and Blade in themes/default
(js/components/quote-cart.js, views/quote-cart.blade.php). It lets the rest of the pipeline be
driven end to end. Don’t let it shape backend design: don’t add a column, event or service because
the cart wants one. The exception is quote-cart.price, the price endpoint, which the product page
depends on and which only delegates to ProductPricer.
Identity is a visit_token cookie (EnsureVisitToken). quote-cart:recover and quote-cart:prune
handle abandoned carts on the schedule.
Extending
Section titled “Extending”- A new moment in a document’s life: add an event extending
SalesDocumentEvent, fire it from the transition’sevent(), and register a trigger inAutomations\SalesAutomations. - A new status change: add it to the state class’s graph, with a transition class extending
DocumentTransition. Keep side effects inapply(). - A new thing on documents’ lines: add it in
DocumentLineColumnsand migrate all five line tables.