Quote-to-Cash in ERP: Quotation to Invoice to Payment

Photo by Grand Canyon NPS on flickr
Quote-to-cash, sometimes called order-to-cash, is the full chain of documents and steps that turn a customer quotation into collected revenue. It typically covers quotation, sales order, delivery or fulfillment, invoicing, and payment matching, with each stage validating the one before it before advancing.
A plain status text field lets any part of the codebase write any value at any time, which allows invalid jumps like marking an unshipped order as paid. A state machine defines a fixed set of states and only permits specific transitions through one code path, so invalid sequences are rejected automatically rather than caught later during a data cleanup.
You need a many-to-many allocation table between payments and invoices rather than a single invoice id column on the payment record. One incoming payment can then be split across several invoices, and each invoice can also receive partial amounts from more than one payment over time.
It is possible but risky. Deriving invoice lines straight from the sales order breaks down as soon as a shipment is partial, because there is no trigger to invoice only what was actually delivered. Tying invoice generation to the delivery record keeps invoiced amounts aligned with what physically left the warehouse.
The excess amount should be recorded as a credit balance on the customer record rather than silently discarded or force-applied to an unrelated invoice. That credit can then be manually or automatically allocated to a future invoice, preserving a clear audit trail of where the money actually came from.

Photo by Grand Canyon NPS on flickr
Key Takeaway
Quote-to-cash in ERP breaks down when quotation, sales order, delivery, invoice, and payment share one status field: model each document as a separate finite state machine with only one code path allowed to move it forward, derive invoices strictly from delivery records, and build payment allocation as many-to-many between payments and invoices from day one.
Every ERP system eventually has to answer the same question: how does a quotation become cash in the bank without anyone double-shipping an order or invoicing a customer twice. The answer is not a single feature. It is a pipeline of distinct documents, each with its own lifecycle, chained together by a state machine that refuses to let a record skip a step it has not earned.
This post walks through the quote-to-cash workflow the way I have built it for ERP clients: quotation, sales order, delivery, invoice, and payment, with the guardrails that stop the pipeline from silently drifting out of sync. It assumes a relational database, a backend service layer, and a willingness to model business rules as code rather than as tribal knowledge.
A naive implementation gives every document a status text field and lets any part of the codebase write to it. That works for a demo and fails in production, because nothing stops a shipping clerk's bug from marking an order paid, or a race condition from invoicing the same delivery twice. The fix is to treat each document type as a finite state machine: a fixed set of states, a fixed set of allowed transitions, and a single code path that is the only thing permitted to move a record from one state to the next.
Quote-to-Cash State Machine
DRAFT ──► SENT ──► ACCEPTED ──► CONVERTED (Sales Order)
│ │
└──► REJECTED ▼
CONFIRMED
│
▼
ALLOCATED ──► PICKED ──► SHIPPED
│
▼
INVOICED
│
┌───────────────────────┤
▼ ▼
PARTIALLY_PAID OVERDUE
│ │
└──────────┬────────────┘
▼
PAID (terminal)Notice that the sales order does not become invoiced the moment it is confirmed. It has to pass through allocation, picking, and shipping first, each a checkpoint that a real-world event actually happened. A payment then only ever pushes an invoiced order toward partially paid or paid, never backward. Once you draw the graph, most of the edge cases people usually patch with special-case logic turn into transitions that simply are not defined, so the code rejects them by construction.
Quote-to-cash is really five separate aggregates that reference each other by foreign key, not one giant table. Splitting them cleanly is what lets you reprice a quotation without touching a shipped order, or issue a partial invoice without blocking the rest of the sales order. It also means each document can carry its own approval rules, its own numbering sequence, and its own retention policy, which matters once finance and audit teams start asking for document-level history rather than a single flattened order record.
| Document | Owns | Typical terminal state |
|---|---|---|
| Quotation | Proposed line items, pricing, and validity window before a commitment exists | Accepted or rejected |
| Sales order | The confirmed commitment: quantities, agreed price, delivery address, and credit check result | Fully shipped and invoiced |
| Delivery | Warehouse allocation, picking, packing, and the shipment reference | Shipped and confirmed received |
| Invoice | The legal claim for payment, tied to one or more deliveries | Paid or written off |
| Payment | The actual cash movement and its allocation across one or more invoices | Fully allocated |
Model the quotation and the sales order as two separate tables even though they share almost every column. A quotation is a proposal and can be edited freely. A sales order is a commitment and every change to it should be an audited event. Merging them into one table with a status flag makes that distinction impossible to enforce later.
The conversion step is where most custom ERPs earn their keep, because it is the moment a sales rep's promise turns into something the warehouse and finance teams are obligated to honor. A clean conversion handler does four things in a single transaction, and none of them are optional if you want the books to stay correct.
-- PostgreSQL: sales_order status column with a guarded transition table
CREATE TYPE sales_order_status AS ENUM (
'draft', 'confirmed', 'allocated', 'picked',
'shipped', 'invoiced', 'partially_paid', 'overdue', 'paid'
);
CREATE TABLE sales_order (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quotation_id UUID REFERENCES quotation(id),
customer_id UUID NOT NULL REFERENCES customer(id),
status sales_order_status NOT NULL DEFAULT 'draft',
total_amount NUMERIC(14, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Allowed transitions live in application code, not a trigger,
-- so the workflow engine can log who approved each hop.
CREATE TABLE sales_order_transition (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sales_order_id UUID NOT NULL REFERENCES sales_order(id),
from_status sales_order_status NOT NULL,
to_status sales_order_status NOT NULL,
actor_id UUID NOT NULL REFERENCES employee(id),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);A sales order should never be invoiced directly. It should be invoiced because a delivery happened, or because a contract milestone was met. That distinction matters for tax compliance in most jurisdictions, and it matters for your own data integrity: if invoicing reads straight from the sales order, a partial shipment has no way to trigger a partial invoice, and the whole pipeline degrades into invoicing everything up front and hoping fulfillment catches up.
Do not let the invoice module compute its own line items from the sales order. Always derive invoice lines from the delivery record that triggered the invoice. Otherwise a partially shipped order and a fully invoiced order can exist at the same time, and reconciling that mismatch months later during an audit is far more expensive than adding the extra foreign key now.
Once a delivery closes, the invoice generation job creates an invoice referencing that delivery's line items, its own sequential invoice number, a due date computed from the customer's payment terms, and a status that starts at sent. Payment matching is the part teams underestimate, because in the real world payments rarely arrive as one clean transfer per invoice.
Build the payment allocation table from day one as many-to-many between payment and invoice, even if your first customer only ever pays one invoice at a time. Retrofitting a many-to-many relationship after finance has years of one-to-one payment data is one of the most painful migrations in an ERP codebase.
The state machine buys you correctness, but you also want visibility into where documents are stuck. A dashboard query that counts sales orders sitting in confirmed for more than a set number of days, or invoices sitting in sent past their due date, turns the pipeline from a black box into something the sales and finance teams can actually manage day to day. Log every transition with an actor and a timestamp from the start, because that transition table becomes the single source of truth for every future dispute about who approved what and when. As the ERP grows, this same transition log is also what lets you add automated alerts, such as flagging a sales order that has been allocated for a week without shipping, without rewriting any of the core workflow logic.