Skip to content

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.

QuoteRequest -> Quote -> SalesOrder -> Invoice -> CreditNote
|
+-> PurchaseOrder (one per supplier) -> shipments

A 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.

src/Documents/ holds the common parts:

  • DocumentColumns and DocumentLineColumns define the shared columns. DocumentLine is the abstract base of all five line tables.
  • Concerns\HasDocumentLines, HasDocumentNumber, HasDocumentAddresses, HasDocumentFiles and EditsCatalogLines are the shared behaviour.
  • Pricing\DocumentTotalsCalculator is 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\TaxRateResolver suggests a default rate when a line is created. The calculator only applies the rate stored on the line.
  • Pricing\CatalogLinePricer asks catalog’s ProductPricer (pricing) and turns the answer into a line. Quote required becomes awaiting_price at zero.
  • Lines\GeneratedCharges keeps setup charges as lines owned by their product line (owner_line_id, charge_key), so they follow the price unless price_overridden.

The rule that catches people: a line refuses to change once its document’s linesAreLocked(), by throwing LockedDocumentException.

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.

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.

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, IssueInvoice and IssueCreditNote. 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.
Terminal window
ddev artisan sales:sequence # list them
ddev artisan sales:sequence invoice --next=27125 # move one forward

startAt() refuses to move a sequence backwards, as that could re-issue a number.

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.

  • *Document is the plain data the view reads. *Pdf has html() for previews, pdf() and content() for attachments, through spatie/laravel-pdf.
  • DocumentBranding carries the selling site’s name, logo, colours and legal line, so a document brands by its own site_id, not by the current host.
  • Chromium renders offline. The sales-documents driver (QuotePdf::DRIVER, bound in SalesServiceProvider) 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.
  • DocumentViews finds a quote page’s view in the site’s theme, then its parent, then the sales:: 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.

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.

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.

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.
  • LegacyRecords keeps legacy_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.
  • attachments copies files from legacy.attachments_directory and lists what’s missing in storage/app/legacy-import/missing-attachments.txt.

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.

  • A new moment in a document’s life: add an event extending SalesDocumentEvent, fire it from the transition’s event(), and register a trigger in Automations\SalesAutomations.
  • A new status change: add it to the state class’s graph, with a transition class extending DocumentTransition. Keep side effects in apply().
  • A new thing on documents’ lines: add it in DocumentLineColumns and migrate all five line tables.